@cavulsqa/create 0.1.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 +63 -0
- package/bin/create.mjs +142 -0
- package/lib/scaffold.mjs +124 -0
- package/package.json +60 -0
- package/templates/f7-app/.claude/rules/data-fetching.md +67 -0
- package/templates/f7-app/.claude/rules/database.md +65 -0
- package/templates/f7-app/.claude/rules/framework7-ui.md +79 -0
- package/templates/f7-app/.claude/rules/modules.md +43 -0
- package/templates/f7-app/.claude/rules/native.md +47 -0
- package/templates/f7-app/.claude/skills/f7-design/SKILL.md +67 -0
- package/templates/f7-app/.claude/skills/f7-design/components.md +49 -0
- package/templates/f7-app/.claude/skills/f7-design/icons.md +52 -0
- package/templates/f7-app/.claude/skills/module-architecture/SKILL.md +65 -0
- package/templates/f7-app/.claude/skills/module-architecture/file-templates.md +183 -0
- package/templates/f7-app/.claude/skills/reactive-data/SKILL.md +89 -0
- package/templates/f7-app/.claude/skills/reactive-data/testing.md +55 -0
- package/templates/f7-app/CLAUDE.md +105 -0
- package/templates/f7-app/auto-imports.d.ts +667 -0
- package/templates/f7-app/capacitor.config.ts +14 -0
- package/templates/f7-app/components.d.ts +59 -0
- package/templates/f7-app/index.html +15 -0
- package/templates/f7-app/package.json +51 -0
- package/templates/f7-app/src/App.vue +72 -0
- package/templates/f7-app/src/app/tabs.ts +25 -0
- package/templates/f7-app/src/assets/css/app.css +1 -0
- package/templates/f7-app/src/assets/css/icons.css +50 -0
- package/templates/f7-app/src/assets/fonts/material-icons-outlined.woff2 +0 -0
- package/templates/f7-app/src/assets/fonts/material-icons-round.woff2 +0 -0
- package/templates/f7-app/src/domains/sales/sales.repository.ts +458 -0
- package/templates/f7-app/src/env.d.ts +23 -0
- package/templates/f7-app/src/locales/en.json +179 -0
- package/templates/f7-app/src/locales/fr.json +179 -0
- package/templates/f7-app/src/main.ts +50 -0
- package/templates/f7-app/src/modules/demo/components/DemoBusLog.vue +26 -0
- package/templates/f7-app/src/modules/demo/components/DemoCreateOrderSheet.vue +160 -0
- package/templates/f7-app/src/modules/demo/components/DemoOrderList.vue +73 -0
- package/templates/f7-app/src/modules/demo/components/DemoPipelineBenchmark.vue +52 -0
- package/templates/f7-app/src/modules/demo/components/DemoStatCards.vue +63 -0
- package/templates/f7-app/src/modules/demo/composables/useReactiveDemo.ts +168 -0
- package/templates/f7-app/src/modules/demo/router/routes/demo.routes.ts +34 -0
- package/templates/f7-app/src/modules/demo/views/DemoView.vue +126 -0
- package/templates/f7-app/src/modules/demo/views/OrderDetailView.vue +131 -0
- package/templates/f7-app/src/modules/demo/views/OrderSearchView.vue +106 -0
- package/templates/f7-app/src/modules/home/composables/useHomeFeatures.ts +133 -0
- package/templates/f7-app/src/modules/home/router/routes/home.routes.ts +29 -0
- package/templates/f7-app/src/modules/home/views/FeatureDetailView.vue +108 -0
- package/templates/f7-app/src/modules/home/views/HomeView.vue +53 -0
- package/templates/f7-app/src/modules/settings/router/routes/settings.routes.ts +16 -0
- package/templates/f7-app/src/modules/settings/views/SettingsView.vue +126 -0
- package/templates/f7-app/src/plugins/capacitor/index.ts +14 -0
- package/templates/f7-app/src/plugins/capacitor/useAndroidBackButton.ts +68 -0
- package/templates/f7-app/src/plugins/capacitor/useKeyboard.ts +61 -0
- package/templates/f7-app/src/plugins/capacitor/useSplashScreen.ts +11 -0
- package/templates/f7-app/src/plugins/capacitor/useStatusBar.ts +15 -0
- package/templates/f7-app/src/plugins/framework7.plugin.ts +41 -0
- package/templates/f7-app/src/plugins/i18n.plugin.ts +10 -0
- package/templates/f7-app/src/plugins/seed.plugin.ts +17 -0
- package/templates/f7-app/src/plugins/sqlite.plugin.ts +21 -0
- package/templates/f7-app/src/router/global/global.routes.ts +16 -0
- package/templates/f7-app/src/router/index.ts +18 -0
- package/templates/f7-app/src/shared/components/error/404.vue +12 -0
- package/templates/f7-app/src/shared/components/metrics/MetricsPanel.vue +79 -0
- package/templates/f7-app/src/shared/composables/theme/useAppTheme.ts +68 -0
- package/templates/f7-app/src/shared/composables/useTabbarVisibility.ts +50 -0
- package/templates/f7-app/src/shared/database/database.ts +86 -0
- package/templates/f7-app/src/shared/database/index.ts +3 -0
- package/templates/f7-app/src/shared/database/migrations.ts +81 -0
- package/templates/f7-app/src/shared/database/queries.ts +22 -0
- package/templates/f7-app/src/shared/database/schema.ts +59 -0
- package/templates/f7-app/src/shared/utils/resolvers/resolvers.ts +152 -0
- package/templates/f7-app/tests/icons.test.ts +81 -0
- package/templates/f7-app/tests/sales.repository.test.ts +329 -0
- package/templates/f7-app/tsconfig.json +21 -0
- package/templates/f7-app/tsconfig.node.json +14 -0
- package/templates/f7-app/vite.config.ts +95 -0
|
@@ -0,0 +1,47 @@
|
|
|
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`) closes the topmost layer before it navigates, in order:
|
|
13
|
+
actions, dialog, sheet, popover, popup, login screen, panel. Then a popup's own view history, then
|
|
14
|
+
the router, then it minimises rather than exits. The order matters: an actions sheet over a popup
|
|
15
|
+
must close first or back dismisses the popup underneath and orphans the sheet.
|
|
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 tab bar, which otherwise steals a row
|
|
19
|
+
from the field being typed into, and leaves a message bar's accessory row alone.
|
|
20
|
+
- **Status bar** overlays the web view; the page owns the inset.
|
|
21
|
+
- **Splash** stays up until `hideSplashScreen()`, called on a frame boundary so there is no flash of
|
|
22
|
+
an unpainted shell.
|
|
23
|
+
|
|
24
|
+
Do not simplify these into a single listener. Each branch is there because of a specific device
|
|
25
|
+
behaviour, and the comments say which.
|
|
26
|
+
|
|
27
|
+
## The bootstrap must never fail silently
|
|
28
|
+
|
|
29
|
+
`main.ts` opens the database before mounting, inside a `try`, and renders the failure on the page if
|
|
30
|
+
it throws. An earlier version awaited it at module top level: a rejection produced an empty `#app`
|
|
31
|
+
and a completely silent console, which is the worst possible failure for whoever generates from this
|
|
32
|
+
template. The timeout exists so a hang cannot masquerade as a blank screen either.
|
|
33
|
+
|
|
34
|
+
Anything else added to the bootstrap follows the same shape: guarded, and loud when it fails.
|
|
35
|
+
|
|
36
|
+
## Fixed elements and the shell
|
|
37
|
+
|
|
38
|
+
The tab bar lives in the shell, outside every page, inside `.views.tabs`. So:
|
|
39
|
+
|
|
40
|
+
- A page already ends above it — do not add a bottom offset to a FAB to "clear" it. That counts it
|
|
41
|
+
twice.
|
|
42
|
+
- FAB buttons open upward (`position="top"`), or they land behind it.
|
|
43
|
+
|
|
44
|
+
## Proof obligations
|
|
45
|
+
|
|
46
|
+
Say which platform you tested on. "Type-checks" is not a claim about a device, and neither is a
|
|
47
|
+
browser. If you have not run it on Android, say so.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: f7-design
|
|
3
|
+
description: Framework7 UI work in this app. Use whenever the task is to build or change a screen, view, page, component, layout, navbar, tabbar, toolbar, list, sheet, popup, panel, FAB, card, chip, searchbar, swipeout, or any mobile UI. Covers the component-first rule, the resolver allowlist you must update before using a new component, the icon ligature trap, and why this app writes no CSS.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Framework7 UI workflow
|
|
7
|
+
|
|
8
|
+
Mobile-first, Android-first, native-feeling on both themes. Reach for a Framework7 component before
|
|
9
|
+
building anything by hand — it already handles the theme, dark mode, safe areas and the gestures.
|
|
10
|
+
|
|
11
|
+
## Before building a screen
|
|
12
|
+
|
|
13
|
+
1. Check whether Framework7 has the component: https://framework7.io/vue/. The catalogue wired into
|
|
14
|
+
this app is [components.md](components.md).
|
|
15
|
+
2. In the allowlist → use `<F7Xxx>` directly. **No import** — `Framework7VueResolver` resolves it.
|
|
16
|
+
3. Not in the allowlist → add the kebab name to `framework7Components` in
|
|
17
|
+
`src/shared/utils/resolvers/resolvers.ts` **first**. Skip this and the component silently fails
|
|
18
|
+
to resolve.
|
|
19
|
+
4. Before adding a name, confirm the installed `framework7-vue` actually exports it. `f7-toolbar-pane`
|
|
20
|
+
is a Framework7 9 CSS class with no Vue component in framework7-vue 8 — resolving it throws a
|
|
21
|
+
runtime `SyntaxError`, not a warning. Use the class on a plain `div` in that case.
|
|
22
|
+
5. Hand-build only when Framework7 has nothing suitable.
|
|
23
|
+
|
|
24
|
+
## Layout idiom
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
F7Page > F7Navbar > [F7NavRight, F7Subnavbar]
|
|
28
|
+
F7BlockTitle section heading
|
|
29
|
+
F7Block strong inset prose or a stat surface
|
|
30
|
+
F7List strong inset dividers rows
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- Rounded surfaces: `class="rounded-2xl!"`. The `!` matters — Tailwind loads after the F7 bundle.
|
|
34
|
+
- `F7ListItem` renders `subtitle`, `text` and `#media` **only in a media list**. Outside one they
|
|
35
|
+
are dropped with no warning.
|
|
36
|
+
- Long descriptive text goes in `#text` in a media list, never `#footer` — footers are sized for a
|
|
37
|
+
few words and long text overlaps the title.
|
|
38
|
+
- Detail-screen actions go in `F7Toolbar bottom`, which spaces links evenly and clears the safe
|
|
39
|
+
area. A hand-built fixed bar does neither.
|
|
40
|
+
|
|
41
|
+
## Write no CSS
|
|
42
|
+
|
|
43
|
+
No backgrounds, heights, safe-area padding or body font sizes. The theme owns them, for iOS and
|
|
44
|
+
Material, light and dark. `assets/css/app.css` is one line.
|
|
45
|
+
|
|
46
|
+
Tailwind is for layout inside a component — flex, grid, gaps, a size on a number. The moment you
|
|
47
|
+
reach for a colour, use a Framework7 component or a theme variable instead. Theme colour is set once
|
|
48
|
+
via `colors.primary` in `src/plugins/framework7.plugin.ts`, never as a CSS override: Framework7
|
|
49
|
+
derives tints, shades, ripples and the dark variants from that value.
|
|
50
|
+
|
|
51
|
+
## Icons
|
|
52
|
+
|
|
53
|
+
Read [icons.md](icons.md) before typing an icon name. framework7-icons is a **ligature font**: a
|
|
54
|
+
wrong name renders nothing at all, with no warning. This app has shipped invisible icons twice.
|
|
55
|
+
|
|
56
|
+
## Navigation and gestures
|
|
57
|
+
|
|
58
|
+
- Tabs are data in `src/app/tabs.ts`; each is a view with its own history.
|
|
59
|
+
- A pushed page calls `useHiddenTabbar()`.
|
|
60
|
+
- Swipeout inside swipeable tabs needs `swiper-no-swiping` on the list, or one drag does both.
|
|
61
|
+
- A route's `async` is Framework7's hook, not an async function — resolve from a promise.
|
|
62
|
+
- `f7route` / `f7router` are props: `defineProps<{ f7route: Router.Route }>()`.
|
|
63
|
+
|
|
64
|
+
## Before saying it works
|
|
65
|
+
|
|
66
|
+
`vp test` (includes the icon check) and `pnpm type-check`. Then say whether you have actually seen
|
|
67
|
+
the screen. A screen that compiles can still be an empty box.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Component catalogue
|
|
2
|
+
|
|
3
|
+
The allowlist is the `framework7Components` array in
|
|
4
|
+
`src/shared/utils/resolvers/resolvers.ts`. Only names in it resolve; anything else renders as an
|
|
5
|
+
unknown element.
|
|
6
|
+
|
|
7
|
+
## Wired
|
|
8
|
+
|
|
9
|
+
Shell and navigation
|
|
10
|
+
: `f7-app`, `f7-view`, `f7-views`, `f7-page`, `f7-page-content`, `f7-navbar`, `f7-nav-left`,
|
|
11
|
+
`f7-nav-right`, `f7-nav-title`, `f7-nav-title-large`, `f7-toolbar`, `f7-subnavbar`, `f7-link`,
|
|
12
|
+
`f7-tabs`, `f7-tab`, `f7-panel`, `f7-searchbar`
|
|
13
|
+
|
|
14
|
+
Lists and inputs
|
|
15
|
+
: `f7-list`, `f7-list-group`, `f7-list-item`, `f7-list-item-row`, `f7-list-item-cell`,
|
|
16
|
+
`f7-list-item-content`, `f7-list-button`, `f7-list-input`, `f7-checkbox`, `f7-toggle`,
|
|
17
|
+
`f7-stepper`
|
|
18
|
+
|
|
19
|
+
Actions and surfaces
|
|
20
|
+
: `f7-button`, `f7-segmented`, `f7-fab`, `f7-fab-button`, `f7-fab-buttons`, `f7-card`,
|
|
21
|
+
`f7-card-header`, `f7-card-content`, `f7-card-footer`, `f7-popup`, `f7-popover`, `f7-actions`,
|
|
22
|
+
`f7-actions-group`, `f7-actions-button`, `f7-actions-label`, `f7-sheet`, `f7-login-screen`
|
|
23
|
+
|
|
24
|
+
Content
|
|
25
|
+
: `f7-block`, `f7-block-title`, `f7-block-header`, `f7-block-footer`, `f7-icon`, `f7-chip`,
|
|
26
|
+
`f7-badge`, `f7-preloader`, `f7-progressbar`, `f7-skeleton-block`, `f7-skeleton-text`,
|
|
27
|
+
`f7-accordion*`, `f7-swipeout*`, `f7-messages*`, `f7-messagebar*`, `f7-photo-browser`,
|
|
28
|
+
`f7-virtual-list`, `f7-list-index`, `f7-row`, `f7-col`
|
|
29
|
+
|
|
30
|
+
## Deliberately absent
|
|
31
|
+
|
|
32
|
+
`f7-toolbar-pane`
|
|
33
|
+
: Framework7 9 ships the CSS class; framework7-vue 8 exports no component. Resolving it fails as a
|
|
34
|
+
runtime `SyntaxError`. Use `<div class="toolbar-pane">`.
|
|
35
|
+
|
|
36
|
+
## Adding one
|
|
37
|
+
|
|
38
|
+
1. Confirm the installed `framework7-vue` exports it — check its entry, not the docs, since the Vue
|
|
39
|
+
bindings lag the core.
|
|
40
|
+
2. Add the kebab name to the array.
|
|
41
|
+
3. Use the PascalCase tag with no import.
|
|
42
|
+
|
|
43
|
+
## Programmatic APIs
|
|
44
|
+
|
|
45
|
+
`f7` is auto-imported. `f7.dialog.*`, `f7.toast.*`, `f7.sheet.*`, `f7.actions.*`, `f7.fab.*`,
|
|
46
|
+
`f7.tab.show()`.
|
|
47
|
+
|
|
48
|
+
Check the option names against `framework7/components/<name>/<name>.d.ts` rather than from memory —
|
|
49
|
+
an action-sheet heading is `{ text, label: true }`, and emphasis is `strong`, not `bold`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Icons
|
|
2
|
+
|
|
3
|
+
Three systems. Pick by context.
|
|
4
|
+
|
|
5
|
+
## 1. Framework7 icon font — chrome and list media
|
|
6
|
+
|
|
7
|
+
```vue
|
|
8
|
+
<F7Icon f7="checkmark_seal_fill" color="green" />
|
|
9
|
+
<F7Icon ios="f7:house_fill" md="material:home" />
|
|
10
|
+
<!-- per-theme, for the tab bar -->
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**The trap.** framework7-icons is a ligature font. A name it does not carry renders _nothing_ — no
|
|
14
|
+
warning, no fallback, no console message. Invisible in review, invisible in a screenshot you did not
|
|
15
|
+
look closely at.
|
|
16
|
+
|
|
17
|
+
Rules that came from getting this wrong twice:
|
|
18
|
+
|
|
19
|
+
- **Verify against the font, never from memory.** `tests/icons.test.ts` checks every name used in
|
|
20
|
+
the app against `Framework7Icons-Regular.ttf`. It is the authority; run it.
|
|
21
|
+
- The name **keeps the underscore before a digit**: `arrow_2_circlepath`, `square_grid_2x2_fill`,
|
|
22
|
+
`square_stack_3d_down_right_fill`, `rectangle_3_offgrid_fill`.
|
|
23
|
+
- **Do not derive names from `framework7-icons/react/*`.** Those files are SVG wrappers whose names
|
|
24
|
+
do not match the ligatures. Trusting them turned four working icons into fragments of other
|
|
25
|
+
glyphs.
|
|
26
|
+
- **SF Symbols names are not Framework7 names.** It is `search`, not `magnifyingglass`.
|
|
27
|
+
- Browse real names at https://framework7.io/icons/.
|
|
28
|
+
|
|
29
|
+
## 2. Material icons — the `md` theme
|
|
30
|
+
|
|
31
|
+
`icon-md="material:home"` needs the Material font, which is bundled in `src/assets/css/icons.css`
|
|
32
|
+
with a `font-feature-settings: "liga"` rule. Without it the icon renders as the literal word
|
|
33
|
+
"home". The font is self-hosted, not from a CDN, because an offline-first app should not lose its
|
|
34
|
+
icons with the network.
|
|
35
|
+
|
|
36
|
+
## 3. unplugin-icons SVGs — feature UI
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<ILucideZap class="text-yellow-500" />
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Resolved by `IconsResolver` with the `framework7`, `material-symbols` and `lucide` collections
|
|
43
|
+
enabled, and `autoInstall: true`. Unlike the font, a wrong name here **fails at build time**, which
|
|
44
|
+
makes it the safer choice for anything decorative or one-off.
|
|
45
|
+
|
|
46
|
+
## Choosing
|
|
47
|
+
|
|
48
|
+
| Context | Use |
|
|
49
|
+
| ------------------------------ | ---------------------------- |
|
|
50
|
+
| Tab bar, navbar, native chrome | `F7Icon` with `ios=` / `md=` |
|
|
51
|
+
| List row media, status glyphs | `F7Icon f7="…" color="…"` |
|
|
52
|
+
| Feature illustration, one-offs | `<ILucide… />` |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: module-architecture
|
|
3
|
+
description: Where code goes in this app and how to add a feature end to end. Use when creating a new screen, module, route, domain, repository, composable, or when deciding whether something belongs in modules, domains or shared. Covers the dependency direction, the file layout of a module, and the checklist for wiring a feature into the shell.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module architecture
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
src/
|
|
10
|
+
├── app/tabs.ts the tab bar, as data
|
|
11
|
+
├── router/index.ts aggregates module routes, catch-all last
|
|
12
|
+
├── domains/<domain>/<x>.repository.ts SQL only
|
|
13
|
+
├── modules/<feature>/
|
|
14
|
+
│ ├── router/routes/<feature>.routes.ts
|
|
15
|
+
│ ├── views/<Name>View.vue
|
|
16
|
+
│ ├── components/*.vue
|
|
17
|
+
│ └── composables/use*.ts
|
|
18
|
+
└── shared/ what two modules genuinely both need
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The seam
|
|
22
|
+
|
|
23
|
+
| Layer | Owns | Never contains |
|
|
24
|
+
| ---------- | --------------------------------------- | -------------------------------------------- |
|
|
25
|
+
| View | Wiring a composable to components | Business logic, SQL |
|
|
26
|
+
| Composable | State, queries, actions for one feature | Markup |
|
|
27
|
+
| Repository | Plain functions over Kysely | `ref`, lifecycle, Framework7, module imports |
|
|
28
|
+
| Component | Props in, emits out | Database access |
|
|
29
|
+
|
|
30
|
+
Dependencies run **modules → domains → shared → packages**, never backwards. A repository importing
|
|
31
|
+
from a module, or `shared` importing from `modules`, means something is in the wrong place.
|
|
32
|
+
|
|
33
|
+
## Adding a feature
|
|
34
|
+
|
|
35
|
+
1. `modules/<feature>/` with the four folders. Templates: [file-templates.md](file-templates.md).
|
|
36
|
+
2. SQL in `domains/<domain>/<domain>.repository.ts`, taking the database as its first parameter.
|
|
37
|
+
3. Routes in `modules/<feature>/router/routes/<feature>.routes.ts`, default-exported, registered in
|
|
38
|
+
`src/router/index.ts` **before** the global catch-all.
|
|
39
|
+
4. A tab in `src/app/tabs.ts` only if it is a top-level section — with both `iconIos` and `iconMd`.
|
|
40
|
+
Otherwise it is a pushed page, and pushed pages call `useHiddenTabbar()`.
|
|
41
|
+
5. Strings in **both** `locales/en.json` and `locales/fr.json`. A missing key renders as the key.
|
|
42
|
+
Remember `@` and `|` are message syntax.
|
|
43
|
+
6. A test in `tests/` for anything with SQL.
|
|
44
|
+
7. `vp check`, `vp test`, `pnpm type-check`.
|
|
45
|
+
|
|
46
|
+
## shared/ is not a junk drawer
|
|
47
|
+
|
|
48
|
+
Something moves to `shared/` when a second module imports it **today**. The test: name the second
|
|
49
|
+
caller. If you cannot, it lives in the module that uses it.
|
|
50
|
+
|
|
51
|
+
## Auto-imports
|
|
52
|
+
|
|
53
|
+
`ref`, `computed`, `watch`, lifecycle hooks, `useI18n`, `@vueuse/core`, `f7`, `f7ready` and
|
|
54
|
+
everything under `shared/composables`, `shared/utils`, `plugins` and `modules/**/composables` are
|
|
55
|
+
auto-imported — no import line. Components under `shared/components` and `modules/**/{views,components}`
|
|
56
|
+
resolve the same way.
|
|
57
|
+
|
|
58
|
+
Two things are **not** auto-imported and must be declared:
|
|
59
|
+
|
|
60
|
+
- `f7route` / `f7router` — Framework7 passes them to a route component as props.
|
|
61
|
+
- Repositories and anything under `domains/` — imported explicitly, because a domain is a boundary
|
|
62
|
+
you should see being crossed.
|
|
63
|
+
|
|
64
|
+
`auto-imports.d.ts` and `components.d.ts` are generated. Never hand-edit them; if the editor
|
|
65
|
+
disagrees with the build, run the dev server once to regenerate.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# File templates
|
|
2
|
+
|
|
3
|
+
Copy these shapes. They encode decisions that are easy to get wrong once and then repeat everywhere.
|
|
4
|
+
|
|
5
|
+
## Route file
|
|
6
|
+
|
|
7
|
+
`modules/<feature>/router/routes/<feature>.routes.ts`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { Router } from "framework7/types";
|
|
11
|
+
|
|
12
|
+
const featureRoutes: Router.RouteParameters[] = [
|
|
13
|
+
{
|
|
14
|
+
name: "feature",
|
|
15
|
+
path: "/feature/",
|
|
16
|
+
// `async` is Framework7's route hook, not an async function - resolve from the promise.
|
|
17
|
+
async({ resolve }) {
|
|
18
|
+
void import("@/modules/feature/views/FeatureView.vue").then((view) => {
|
|
19
|
+
resolve({ component: view.default });
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "feature-detail",
|
|
25
|
+
path: "/feature/:id/",
|
|
26
|
+
async({ resolve }) {
|
|
27
|
+
void import("@/modules/feature/views/FeatureDetailView.vue").then((view) => {
|
|
28
|
+
resolve({ component: view.default });
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export default featureRoutes;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`await` inside that hook does not compile — the property is literally named `async`.
|
|
38
|
+
|
|
39
|
+
## Repository
|
|
40
|
+
|
|
41
|
+
`domains/<domain>/<domain>.repository.ts`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import type { Kysely } from "kysely";
|
|
45
|
+
import { nowISO } from "@cavulsqa/mobile-db";
|
|
46
|
+
import type { Database } from "@/shared/database/schema";
|
|
47
|
+
|
|
48
|
+
export interface ThingRow {
|
|
49
|
+
id: number;
|
|
50
|
+
name: string;
|
|
51
|
+
totalCents: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reads take the database as a parameter - that is what makes them testable. */
|
|
55
|
+
export function listThings(db: Kysely<Database>, term: string): Promise<ThingRow[]> {
|
|
56
|
+
let query = db.selectFrom("thing").select(["id", "name", "total_cents as totalCents"]);
|
|
57
|
+
if (term.trim()) query = query.where("name", "like", `%${term.trim()}%`);
|
|
58
|
+
return query.orderBy("id", "desc").limit(40).execute();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** All-or-nothing work goes in one transaction. */
|
|
62
|
+
export async function saveThing(
|
|
63
|
+
db: Kysely<Database>,
|
|
64
|
+
input: { name: string; lines: Array<{ productId: number; quantity: number }> },
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
await db.transaction().execute(async (trx) => {
|
|
67
|
+
const thing = await trx
|
|
68
|
+
.insertInto("thing")
|
|
69
|
+
.values({ created_at: nowISO(), name: input.name })
|
|
70
|
+
.returning("id")
|
|
71
|
+
.executeTakeFirstOrThrow();
|
|
72
|
+
|
|
73
|
+
for (const line of input.lines) {
|
|
74
|
+
await trx
|
|
75
|
+
.insertInto("thing_line")
|
|
76
|
+
.values({ thing_id: thing.id, ...line })
|
|
77
|
+
.execute();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
No `ref`, no lifecycle, no Framework7, no imports from `modules/`.
|
|
84
|
+
|
|
85
|
+
## Composable
|
|
86
|
+
|
|
87
|
+
`modules/<feature>/composables/useFeature.ts`
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { listThings, saveThing, type ThingRow } from "@/domains/thing/thing.repository";
|
|
91
|
+
import { getDatabase, rdb } from "@/shared/database/database";
|
|
92
|
+
import { uniqueQueryKey, useReactiveQuery } from "@/shared/database/queries";
|
|
93
|
+
|
|
94
|
+
export function useFeature() {
|
|
95
|
+
const term = ref("");
|
|
96
|
+
const busy = ref(false);
|
|
97
|
+
|
|
98
|
+
const query = useReactiveQuery(() => listThings(getDatabase().db, term.value), {
|
|
99
|
+
// Every table the SQL touches. A join means each joined table.
|
|
100
|
+
tables: ["thing"],
|
|
101
|
+
queryKey: uniqueQueryKey("feature:things"),
|
|
102
|
+
debounce: 250,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const things = computed<ThingRow[]>(() => query.data.value ?? []);
|
|
106
|
+
|
|
107
|
+
// Writes go through `rdb`, which announces the tables they touched.
|
|
108
|
+
async function save(input: Parameters<typeof saveThing>[1]) {
|
|
109
|
+
busy.value = true;
|
|
110
|
+
try {
|
|
111
|
+
await saveThing(rdb, input);
|
|
112
|
+
} finally {
|
|
113
|
+
busy.value = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { term, things, loading: query.loading, busy, save };
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`ref`, `computed` and the composable itself are auto-imported. Repositories are not.
|
|
122
|
+
|
|
123
|
+
## View
|
|
124
|
+
|
|
125
|
+
`modules/<feature>/views/FeatureView.vue`
|
|
126
|
+
|
|
127
|
+
```vue
|
|
128
|
+
<template>
|
|
129
|
+
<F7Page>
|
|
130
|
+
<F7Navbar :title="t('feature.title')" large :sliding="true" />
|
|
131
|
+
|
|
132
|
+
<F7BlockTitle>{{ t("feature.section") }}</F7BlockTitle>
|
|
133
|
+
<F7List v-if="things.length" media-list strong inset dividers class="rounded-2xl!">
|
|
134
|
+
<F7ListItem
|
|
135
|
+
v-for="thing in things"
|
|
136
|
+
:key="thing.id"
|
|
137
|
+
:title="thing.name"
|
|
138
|
+
:link="`/feature/${String(thing.id)}/`"
|
|
139
|
+
>
|
|
140
|
+
<template #media><F7Icon f7="cube_box_fill" color="blue" /></template>
|
|
141
|
+
<template #subtitle>
|
|
142
|
+
<span class="text-[13px] opacity-60">{{ thing.totalCents }}</span>
|
|
143
|
+
</template>
|
|
144
|
+
</F7ListItem>
|
|
145
|
+
</F7List>
|
|
146
|
+
<F7Block v-else strong inset class="rounded-2xl!">
|
|
147
|
+
<p class="m-0 text-sm opacity-70">{{ t("feature.empty") }}</p>
|
|
148
|
+
</F7Block>
|
|
149
|
+
</F7Page>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<script setup lang="ts">
|
|
153
|
+
import { useFeature } from "@/modules/feature/composables/useFeature";
|
|
154
|
+
|
|
155
|
+
const { t } = useI18n();
|
|
156
|
+
const { things } = useFeature();
|
|
157
|
+
</script>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
No `f7-*` imports, no `<style>`. `subtitle` needs the media list.
|
|
161
|
+
|
|
162
|
+
## Pushed detail view
|
|
163
|
+
|
|
164
|
+
Same shape, plus:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { useHiddenTabbar } from "@/shared/composables/useTabbarVisibility";
|
|
168
|
+
|
|
169
|
+
const { t } = useI18n();
|
|
170
|
+
|
|
171
|
+
// A pushed page owns the whole screen; the tab bar belongs to the tab roots.
|
|
172
|
+
useHiddenTabbar();
|
|
173
|
+
|
|
174
|
+
const props = defineProps<{ f7route: Router.Route; f7router: Router.Router }>();
|
|
175
|
+
const id = Number(props.f7route.params.id ?? 0);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Actions on a detail screen go in `F7Toolbar bottom`.
|
|
179
|
+
|
|
180
|
+
## Test
|
|
181
|
+
|
|
182
|
+
`tests/<domain>.repository.test.ts` — see the reactive-data skill's testing guide for the harness.
|
|
183
|
+
Assert the arithmetic, the empty state and the idempotence, not just that rows come back.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reactive-data
|
|
3
|
+
description: Reading and writing the local SQLite database in this app. Use whenever the task involves a query, a mutation, a repository, a migration, the schema, useReactiveQuery, the change bus, stale data, a screen not refreshing, query metrics, or testing data access. Covers the tables invalidation contract, why every write goes through rdb, and how to test SQL without a device.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Reactive data workflow
|
|
7
|
+
|
|
8
|
+
SQLite is the source of truth. A screen reads it through a reactive query; a write announces the
|
|
9
|
+
tables it touched and every query watching one of them refetches. Nothing calls refetch by hand.
|
|
10
|
+
|
|
11
|
+
## Adding a read
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const query = useReactiveQuery(() => searchOrders(getDatabase().db, term.value), {
|
|
15
|
+
tables: ["sales_order", "order_line", "customer"],
|
|
16
|
+
queryKey: uniqueQueryKey("demo:orders"),
|
|
17
|
+
debounce: 250,
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
1. Put the SQL in a repository: `src/domains/<domain>/<domain>.repository.ts`, taking the database
|
|
22
|
+
as its first parameter. Never reach for the singleton inside a repository — that is what makes it
|
|
23
|
+
testable.
|
|
24
|
+
2. **List every table the SQL touches in `tables`.** Count them in the query, not from memory: a
|
|
25
|
+
join means each joined table. Under-list and the screen goes stale with no error; over-list and
|
|
26
|
+
an unrelated write re-runs an expensive query.
|
|
27
|
+
3. `queryKey`: default to `uniqueQueryKey("prefix")`. A stable literal means "share this result with
|
|
28
|
+
any other query using the same key", which is right for one list rendered twice and wrong for
|
|
29
|
+
anything parameterised.
|
|
30
|
+
4. `debounce` so a burst of writes causes one refetch.
|
|
31
|
+
|
|
32
|
+
## Adding a write
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
await saveOrder(rdb, payload); // announces its tables
|
|
36
|
+
await saveOrder(getDatabase().db, …); // writes, and no screen notices
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`rdb` is the reactive wrapper. A raw write lands in SQLite silently and the UI keeps showing old
|
|
40
|
+
rows until something unrelated refetches — no error, nothing to see in review.
|
|
41
|
+
|
|
42
|
+
All-or-nothing work goes in one transaction. A helper anyone can press twice must survive being
|
|
43
|
+
pressed twice: insert-where-missing, or `onConflict(...).doNothing()`.
|
|
44
|
+
|
|
45
|
+
## Schema changes
|
|
46
|
+
|
|
47
|
+
`schema.ts` and `migrations.ts` are edited together — a field in one and not the other is a runtime
|
|
48
|
+
error the compiler cannot see. Migrations are numbered, never renamed, never edited after shipping.
|
|
49
|
+
Money is integer cents. Index what you filter and join by.
|
|
50
|
+
|
|
51
|
+
## Testing
|
|
52
|
+
|
|
53
|
+
Always. [testing.md](testing.md) has the harness: a Kysely on `createSqlJsDialect()`, migrate, then
|
|
54
|
+
assert against real rows — including the arithmetic. A total that type-checks can still be computed
|
|
55
|
+
wrong, and a device is not needed to catch that.
|
|
56
|
+
|
|
57
|
+
## Diagnosing "the screen did not update"
|
|
58
|
+
|
|
59
|
+
In order:
|
|
60
|
+
|
|
61
|
+
1. Did the write go through `rdb`?
|
|
62
|
+
2. Does the query's `tables` include the table that changed?
|
|
63
|
+
3. Is the query's `queryKey` shared with a different query? Check the console for the conflict
|
|
64
|
+
warning.
|
|
65
|
+
4. Is the page off-screen? `usePageVisibility` suppresses refetches for hidden pages by design.
|
|
66
|
+
5. Only then look at the packages.
|
|
67
|
+
|
|
68
|
+
## Proof obligations
|
|
69
|
+
|
|
70
|
+
Name the tables the SQL touches and confirm `tables` matches. Say whether a test covers it. Never
|
|
71
|
+
claim a data path works on the strength of a type-check.
|
|
72
|
+
|
|
73
|
+
## Inserting a parent and its children
|
|
74
|
+
|
|
75
|
+
The parent and its children go in one transaction, and the parent's id comes from `insertId`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
await db.transaction().execute(async (trx) => {
|
|
79
|
+
const inserted = await trx.insertInto("sales_order").values({ ... }).executeTakeFirstOrThrow();
|
|
80
|
+
const orderId = Number(inserted.insertId ?? 0);
|
|
81
|
+
if (!orderId) throw new Error("the order was written but the database reported no id for it");
|
|
82
|
+
...
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`.returning("id")` looks like the obvious way and is the wrong one: inside an open transaction the
|
|
87
|
+
plugin runs the statement through `query()` and discards its RETURNING rows, so kysely throws
|
|
88
|
+
`no result` from a write that succeeded. `insertId` comes from `last_insert_rowid()` and works on
|
|
89
|
+
both sides of the boundary. Full note in [database.md](../../rules/database.md).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Testing data access
|
|
2
|
+
|
|
3
|
+
The queries run against real SQLite — sql.js, the same dialect `@cavulsqa/mobile-db` uses for its
|
|
4
|
+
own tests. No device, no emulator, about a second for the suite.
|
|
5
|
+
|
|
6
|
+
## The harness
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { beforeEach, expect, test } from "vite-plus/test";
|
|
10
|
+
import { Kysely } from "kysely";
|
|
11
|
+
import { Migrator } from "kysely/migration";
|
|
12
|
+
import { createSqlJsDialect } from "@cavulsqa/mobile-db/testing";
|
|
13
|
+
import { migrations } from "../src/shared/database/migrations.js";
|
|
14
|
+
import type { Database } from "../src/shared/database/schema.js";
|
|
15
|
+
|
|
16
|
+
let db: Kysely<Database>;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
db = new Kysely<Database>({ dialect: await createSqlJsDialect() });
|
|
20
|
+
await new Migrator({
|
|
21
|
+
db,
|
|
22
|
+
provider: { getMigrations: () => Promise.resolve(migrations) },
|
|
23
|
+
}).migrateToLatest();
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A fresh in-memory database per test, with the app's real migrations applied. `Migrator` comes from
|
|
28
|
+
`kysely/migration` — the root export is a compile-time error in kysely 0.29.
|
|
29
|
+
|
|
30
|
+
## What to assert
|
|
31
|
+
|
|
32
|
+
Type-checking proves the query compiles. These are the things it cannot prove:
|
|
33
|
+
|
|
34
|
+
- **Arithmetic.** Two lines at quantity 1 and 2 × 1000 cents must total 3000. Write the number.
|
|
35
|
+
- **Empty state.** An aggregate over no rows returns `0`, not `null`. `coalesce` is easy to forget
|
|
36
|
+
and the screen shows a blank tile.
|
|
37
|
+
- **Joins.** Every joined row actually resolves — product names present, customer attached.
|
|
38
|
+
- **Filters.** A search matches on each field it claims to, and returns `[]` for no match.
|
|
39
|
+
- **State machines.** A status cycle lands where you expect at each step.
|
|
40
|
+
- **Idempotence.** Anything a person can press twice, pressed twice. `seedSampleData` threw
|
|
41
|
+
`UNIQUE constraint failed: tag.label` on the second press and only a test caught it.
|
|
42
|
+
- **Missing rows.** A lookup for an id that does not exist returns `null`, and an update against one
|
|
43
|
+
is a no-op rather than a throw.
|
|
44
|
+
|
|
45
|
+
## The icon test
|
|
46
|
+
|
|
47
|
+
`tests/icons.test.ts` guards a different silent failure: it reads the framework7-icons ttf and
|
|
48
|
+
asserts every name used in `src/` is a real ligature, plus that the five names this app has already
|
|
49
|
+
got wrong stay unresolvable. Extend the second list whenever a wrong name gets through.
|
|
50
|
+
|
|
51
|
+
## Running
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
vp test # from templates/f7-app
|
|
55
|
+
```
|