From 2ae65a5a9fedcf6849691391060779f6add9abe1 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 21 Oct 2026 21:00:03 +0200 Subject: [PATCH] W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or cursor_ahead. Login pairs the browser as "Web · " with an invite code (E-3); every call sends the SPEC §14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css (light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .gitignore | 3 + web/.prettierignore | 6 + web/.prettierrc.json | 5 + web/README.md | 224 + web/eslint.config.js | 28 + web/index.html | 15 + web/package-lock.json | 4628 +++++++++++++++++ web/package.json | 61 + web/public/favicon.svg | 1 + web/scripts/check-generated.mjs | 36 + web/scripts/gen-i18n.mjs | 60 + web/scripts/gen-icons.mjs | 190 + web/src/api/ApiProvider.tsx | 42 + web/src/api/client.ts | 122 + web/src/api/ledger.test.ts | 106 + web/src/api/ledger.ts | 205 + web/src/api/me.ts | 14 + web/src/api/pictures.ts | 34 + web/src/api/push.ts | 45 + web/src/api/queryClient.ts | 25 + web/src/api/schema.d.ts | 2724 ++++++++++ web/src/api/session.ts | 84 + web/src/api/types.ts | 26 + web/src/api/useApi.ts | 24 + web/src/app/NotFoundPage.tsx | 19 + web/src/app/PlaceholderPage.module.css | 6 + web/src/app/PlaceholderPage.tsx | 18 + web/src/app/PreferencesCard.module.css | 10 + web/src/app/PreferencesCard.tsx | 42 + web/src/app/RequireAuth.tsx | 14 + web/src/app/Shell.module.css | 169 + web/src/app/Shell.test.tsx | 58 + web/src/app/Shell.tsx | 136 + web/src/app/SignOutButton.tsx | 59 + web/src/app/TopBar.module.css | 30 + web/src/app/TopBar.tsx | 31 + web/src/app/UpdatedLine.module.css | 42 + web/src/app/UpdatedLine.tsx | 58 + web/src/app/buildInfo.ts | 21 + web/src/app/lazyPage.ts | 10 + web/src/app/providers.tsx | 31 + web/src/app/router.tsx | 44 + web/src/app/routes.ts | 34 + web/src/app/shortcuts.tsx | 47 + web/src/app/theme.ts | 36 + web/src/app/useDocumentTitle.ts | 8 + web/src/app/useNow.ts | 15 + web/src/app/useSession.ts | 7 + web/src/components/AmountField.module.css | 7 + web/src/components/AmountField.test.tsx | 36 + web/src/components/AmountField.tsx | 44 + web/src/components/Avatar.module.css | 23 + web/src/components/Avatar.test.tsx | 21 + web/src/components/Avatar.tsx | 46 + web/src/components/Button.module.css | 79 + web/src/components/Button.test.tsx | 30 + web/src/components/Button.tsx | 45 + web/src/components/Card.module.css | 20 + web/src/components/Card.test.tsx | 12 + web/src/components/Card.tsx | 22 + web/src/components/CategoryIcon.module.css | 10 + web/src/components/CategoryIcon.test.tsx | 24 + web/src/components/CategoryIcon.tsx | 65 + web/src/components/Dialog.module.css | 37 + web/src/components/Dialog.test.tsx | 57 + web/src/components/Dialog.tsx | 85 + web/src/components/EmptyState.module.css | 26 + web/src/components/EmptyState.test.tsx | 30 + web/src/components/EmptyState.tsx | 26 + .../KeyboardShortcutsHelp.module.css | 17 + .../components/KeyboardShortcutsHelp.test.tsx | 24 + web/src/components/KeyboardShortcutsHelp.tsx | 40 + web/src/components/LedgerRow.module.css | 101 + web/src/components/LedgerRow.test.tsx | 40 + web/src/components/LedgerRow.tsx | 123 + web/src/components/Money.module.css | 29 + web/src/components/Money.test.tsx | 20 + web/src/components/Money.tsx | 26 + web/src/components/Segmented.module.css | 41 + web/src/components/Segmented.test.tsx | 35 + web/src/components/Segmented.tsx | 70 + web/src/components/TextField.module.css | 56 + web/src/components/TextField.test.tsx | 32 + web/src/components/TextField.tsx | 91 + web/src/components/Toast.module.css | 51 + web/src/components/Toast.test.tsx | 42 + web/src/components/Toast.tsx | 86 + web/src/design/Icon.tsx | 26 + web/src/design/global.css | 77 + web/src/design/icons.generated.ts | 189 + web/src/design/tokens.css | 228 + .../features/accounts/AccountDetailPage.tsx | 9 + .../features/accounts/AccountEditorPage.tsx | 15 + web/src/features/accounts/index.ts | 9 + web/src/features/analytics/AnalyticsPage.tsx | 8 + web/src/features/analytics/DrillDownPage.tsx | 9 + web/src/features/analytics/InsightsPage.tsx | 8 + web/src/features/analytics/index.ts | 9 + web/src/features/auth/LoginPage.module.css | 82 + web/src/features/auth/LoginPage.test.tsx | 120 + web/src/features/auth/LoginPage.tsx | 217 + web/src/features/auth/index.ts | 5 + web/src/features/auth/inviteCode.ts | 34 + .../features/expenses/ExpenseDetailPage.tsx | 9 + .../features/expenses/ExpenseEditorPage.tsx | 15 + web/src/features/expenses/index.ts | 9 + web/src/features/home/HomePage.tsx | 8 + web/src/features/home/index.ts | 5 + web/src/features/search/SearchPage.tsx | 8 + web/src/features/search/index.ts | 5 + web/src/features/settings/AboutPage.tsx | 9 + web/src/features/settings/CategoriesPage.tsx | 9 + web/src/features/settings/DevicesPage.tsx | 16 + web/src/features/settings/InvitePage.tsx | 9 + web/src/features/settings/ProfilePage.tsx | 9 + web/src/features/settings/RecurringPage.tsx | 9 + web/src/features/settings/SettingsPage.tsx | 17 + web/src/features/settings/index.ts | 13 + web/src/features/settle/SettlePage.tsx | 9 + .../features/settle/SettlementDetailPage.tsx | 9 + web/src/features/settle/index.ts | 8 + web/src/i18n/I18nProvider.tsx | 54 + web/src/i18n/catalog.test.ts | 59 + web/src/i18n/catalog.ts | 90 + web/src/i18n/en.json | 603 +++ web/src/i18n/language.ts | 10 + web/src/i18n/sv.json | 603 +++ web/src/i18n/useI18n.ts | 9 + web/src/i18n/web-strings.csv | 34 + web/src/lib/balances.ts | 62 + web/src/lib/csv.test.ts | 28 + web/src/lib/csv.ts | 71 + web/src/lib/dates.test.ts | 74 + web/src/lib/dates.ts | 244 + web/src/lib/effect.test.ts | 11 + web/src/lib/effect.ts | 20 + web/src/lib/ids.test.ts | 16 + web/src/lib/ids.ts | 25 + web/src/lib/money.test.ts | 85 + web/src/lib/money.ts | 134 + web/src/lib/settlement.ts | 114 + web/src/lib/shortcuts.test.ts | 44 + web/src/lib/shortcuts.ts | 69 + web/src/lib/split.ts | 78 + web/src/lib/storage.ts | 40 + web/src/lib/vectors.test.ts | 142 + web/src/main.tsx | 26 + web/src/test/fakeServer.ts | 54 + web/src/test/fixtures.ts | 151 + web/src/test/render.tsx | 59 + web/src/test/setup.ts | 10 + web/src/vite-env.d.ts | 6 + web/tsconfig.json | 23 + web/vite.config.ts | 60 + 154 files changed, 15651 insertions(+) create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc.json create mode 100644 web/README.md create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/scripts/check-generated.mjs create mode 100644 web/scripts/gen-i18n.mjs create mode 100644 web/scripts/gen-icons.mjs create mode 100644 web/src/api/ApiProvider.tsx create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/ledger.test.ts create mode 100644 web/src/api/ledger.ts create mode 100644 web/src/api/me.ts create mode 100644 web/src/api/pictures.ts create mode 100644 web/src/api/push.ts create mode 100644 web/src/api/queryClient.ts create mode 100644 web/src/api/schema.d.ts create mode 100644 web/src/api/session.ts create mode 100644 web/src/api/types.ts create mode 100644 web/src/api/useApi.ts create mode 100644 web/src/app/NotFoundPage.tsx create mode 100644 web/src/app/PlaceholderPage.module.css create mode 100644 web/src/app/PlaceholderPage.tsx create mode 100644 web/src/app/PreferencesCard.module.css create mode 100644 web/src/app/PreferencesCard.tsx create mode 100644 web/src/app/RequireAuth.tsx create mode 100644 web/src/app/Shell.module.css create mode 100644 web/src/app/Shell.test.tsx create mode 100644 web/src/app/Shell.tsx create mode 100644 web/src/app/SignOutButton.tsx create mode 100644 web/src/app/TopBar.module.css create mode 100644 web/src/app/TopBar.tsx create mode 100644 web/src/app/UpdatedLine.module.css create mode 100644 web/src/app/UpdatedLine.tsx create mode 100644 web/src/app/buildInfo.ts create mode 100644 web/src/app/lazyPage.ts create mode 100644 web/src/app/providers.tsx create mode 100644 web/src/app/router.tsx create mode 100644 web/src/app/routes.ts create mode 100644 web/src/app/shortcuts.tsx create mode 100644 web/src/app/theme.ts create mode 100644 web/src/app/useDocumentTitle.ts create mode 100644 web/src/app/useNow.ts create mode 100644 web/src/app/useSession.ts create mode 100644 web/src/components/AmountField.module.css create mode 100644 web/src/components/AmountField.test.tsx create mode 100644 web/src/components/AmountField.tsx create mode 100644 web/src/components/Avatar.module.css create mode 100644 web/src/components/Avatar.test.tsx create mode 100644 web/src/components/Avatar.tsx create mode 100644 web/src/components/Button.module.css create mode 100644 web/src/components/Button.test.tsx create mode 100644 web/src/components/Button.tsx create mode 100644 web/src/components/Card.module.css create mode 100644 web/src/components/Card.test.tsx create mode 100644 web/src/components/Card.tsx create mode 100644 web/src/components/CategoryIcon.module.css create mode 100644 web/src/components/CategoryIcon.test.tsx create mode 100644 web/src/components/CategoryIcon.tsx create mode 100644 web/src/components/Dialog.module.css create mode 100644 web/src/components/Dialog.test.tsx create mode 100644 web/src/components/Dialog.tsx create mode 100644 web/src/components/EmptyState.module.css create mode 100644 web/src/components/EmptyState.test.tsx create mode 100644 web/src/components/EmptyState.tsx create mode 100644 web/src/components/KeyboardShortcutsHelp.module.css create mode 100644 web/src/components/KeyboardShortcutsHelp.test.tsx create mode 100644 web/src/components/KeyboardShortcutsHelp.tsx create mode 100644 web/src/components/LedgerRow.module.css create mode 100644 web/src/components/LedgerRow.test.tsx create mode 100644 web/src/components/LedgerRow.tsx create mode 100644 web/src/components/Money.module.css create mode 100644 web/src/components/Money.test.tsx create mode 100644 web/src/components/Money.tsx create mode 100644 web/src/components/Segmented.module.css create mode 100644 web/src/components/Segmented.test.tsx create mode 100644 web/src/components/Segmented.tsx create mode 100644 web/src/components/TextField.module.css create mode 100644 web/src/components/TextField.test.tsx create mode 100644 web/src/components/TextField.tsx create mode 100644 web/src/components/Toast.module.css create mode 100644 web/src/components/Toast.test.tsx create mode 100644 web/src/components/Toast.tsx create mode 100644 web/src/design/Icon.tsx create mode 100644 web/src/design/global.css create mode 100644 web/src/design/icons.generated.ts create mode 100644 web/src/design/tokens.css create mode 100644 web/src/features/accounts/AccountDetailPage.tsx create mode 100644 web/src/features/accounts/AccountEditorPage.tsx create mode 100644 web/src/features/accounts/index.ts create mode 100644 web/src/features/analytics/AnalyticsPage.tsx create mode 100644 web/src/features/analytics/DrillDownPage.tsx create mode 100644 web/src/features/analytics/InsightsPage.tsx create mode 100644 web/src/features/analytics/index.ts create mode 100644 web/src/features/auth/LoginPage.module.css create mode 100644 web/src/features/auth/LoginPage.test.tsx create mode 100644 web/src/features/auth/LoginPage.tsx create mode 100644 web/src/features/auth/index.ts create mode 100644 web/src/features/auth/inviteCode.ts create mode 100644 web/src/features/expenses/ExpenseDetailPage.tsx create mode 100644 web/src/features/expenses/ExpenseEditorPage.tsx create mode 100644 web/src/features/expenses/index.ts create mode 100644 web/src/features/home/HomePage.tsx create mode 100644 web/src/features/home/index.ts create mode 100644 web/src/features/search/SearchPage.tsx create mode 100644 web/src/features/search/index.ts create mode 100644 web/src/features/settings/AboutPage.tsx create mode 100644 web/src/features/settings/CategoriesPage.tsx create mode 100644 web/src/features/settings/DevicesPage.tsx create mode 100644 web/src/features/settings/InvitePage.tsx create mode 100644 web/src/features/settings/ProfilePage.tsx create mode 100644 web/src/features/settings/RecurringPage.tsx create mode 100644 web/src/features/settings/SettingsPage.tsx create mode 100644 web/src/features/settings/index.ts create mode 100644 web/src/features/settle/SettlePage.tsx create mode 100644 web/src/features/settle/SettlementDetailPage.tsx create mode 100644 web/src/features/settle/index.ts create mode 100644 web/src/i18n/I18nProvider.tsx create mode 100644 web/src/i18n/catalog.test.ts create mode 100644 web/src/i18n/catalog.ts create mode 100644 web/src/i18n/en.json create mode 100644 web/src/i18n/language.ts create mode 100644 web/src/i18n/sv.json create mode 100644 web/src/i18n/useI18n.ts create mode 100644 web/src/i18n/web-strings.csv create mode 100644 web/src/lib/balances.ts create mode 100644 web/src/lib/csv.test.ts create mode 100644 web/src/lib/csv.ts create mode 100644 web/src/lib/dates.test.ts create mode 100644 web/src/lib/dates.ts create mode 100644 web/src/lib/effect.test.ts create mode 100644 web/src/lib/effect.ts create mode 100644 web/src/lib/ids.test.ts create mode 100644 web/src/lib/ids.ts create mode 100644 web/src/lib/money.test.ts create mode 100644 web/src/lib/money.ts create mode 100644 web/src/lib/settlement.ts create mode 100644 web/src/lib/shortcuts.test.ts create mode 100644 web/src/lib/shortcuts.ts create mode 100644 web/src/lib/split.ts create mode 100644 web/src/lib/storage.ts create mode 100644 web/src/lib/vectors.test.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/test/fakeServer.ts create mode 100644 web/src/test/fixtures.ts create mode 100644 web/src/test/render.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 932b31c..cd4676b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ infra/dev/backups/ *.sqlite.gz *.sqlite-wal *.sqlite-shm +# Web client (web/): dependencies and build output +web/node_modules/ +web/dist/ diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..33dc180 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +src/api/schema.d.ts +src/design/icons.generated.ts +src/i18n/sv.json +src/i18n/en.json diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..dc8c710 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 120, + "trailingComma": "all" +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..593000a --- /dev/null +++ b/web/README.md @@ -0,0 +1,224 @@ +# Kvitt web + +The browser client of Kvitt: a single-page app with the same features as the Android app (SPEC §13.3 screens, §4 +acceptance criteria), desktop first and usable on a phone (one column under 720 px), in Swedish and English. It is +online-only (no PWA, no offline copy). `kvitt-server` serves the built files under **`/app/`** (any unknown path under +`/app/` falls back to `/app/index.html`), and the API lives at the root of the same origin (`/me`, `/sync`, …), so +there is no CORS and no server address to configure. + +## Run + +Node 22.18 or later (the generators load `src/lib/csv.ts` with Node's built-in type stripping); this Mac has Node 25. + +```bash +cd web +npm ci # dependencies into web/node_modules (git-ignored) +npm run dev # http://localhost:5173/app/ ; API paths are proxied to KVITT_DEV_API +KVITT_DEV_API=http://localhost:8080 npm run dev # the default; point it at the development stack (infra/dev) +npm run build # web/dist/, what kvitt-server serves under /app/ +``` + +To sign in, create a "Pair a device" invite on the phone (Settings › Pair a device) and type the code on +`/app/login`. The browser becomes a device named `Web · ` and keeps its token in `localStorage`. + +## The gate + +```bash +cd web && npm run check +``` + +`check` runs, in order and stopping at the first failure: + +| Step | Script | What it checks | +| --------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Generated files | `generated:check` | `src/api/schema.d.ts` equals `openapi-typescript ../contracts/openapi.yaml`; `src/i18n/{sv,en}.json` equal what `docs/ux/strings.csv` + `src/i18n/web-strings.csv` give; `src/design/icons.generated.ts` equals the icon list | +| Types | `typecheck` | `tsc --noEmit`, strict, `noUncheckedIndexedAccess` | +| Lint | `lint` | ESLint (typescript-eslint strict type-checked, react-hooks), zero warnings | +| Format | `format:check` | Prettier | +| Tests | `test` | Vitest + Testing Library on jsdom: the contract vectors, formatters, components, auth flow, shell | +| Build | `build` | `vite build` | + +Other scripts: `api:types` (regenerate the API types after a contract change), `i18n:import` (regenerate the string +catalogs), `icons:import` (regenerate the icon table), `format` (Prettier write), `dev`, `preview`. + +## Layout + +``` +web/ + index.html, vite.config.ts (base /app/, dev proxy, vendor chunks, Vitest), tsconfig.json, eslint.config.js + scripts/ gen-i18n.mjs, gen-icons.mjs, check-generated.mjs + src/ + main.tsx entry: theme, session, query client, router + app/ router.tsx (route table, lazy pages), routes.ts (path builders), providers.tsx, + Shell (sidebar / bottom bar), TopBar, UpdatedLine, RequireAuth, PlaceholderPage, + shortcuts.tsx (provider + useShortcut), theme.ts, PreferencesCard, SignOutButton, + NotFoundPage, buildInfo.ts, useSession, useNow, useDocumentTitle + api/ client.ts (openapi-fetch, headers, 401, ApiError, unwrap), schema.d.ts (generated), + types.ts (wire objects), session.ts, ledger.ts (sync-pull ledger), me.ts, pictures.ts, + push.ts (POST /sync), queryClient.ts, ApiProvider.tsx, useApi.ts + i18n/ sv.json, en.json (generated), web-strings.csv, catalog.ts, I18nProvider, useI18n, language.ts + design/ tokens.css, global.css, Icon.tsx, icons.generated.ts (generated) + components/ Button, TextField, AmountField, Dialog, Segmented, Avatar, CategoryIcon, Money, + LedgerRow (+ SegmentedGroup), Card, EmptyState, Toast, KeyboardShortcutsHelp + lib/ money, split, balances, settlement (kvitt-domain port), dates, effect, csv, ids, + shortcuts, storage + features// auth (sign-in, built), home, accounts, expenses, settle, analytics (with insights), search, + settings (placeholders for W1 to W5) + test/ setup.ts, fakeServer.ts (hand-written fetch fake), fixtures.ts, render.tsx +``` + +## Routes + +Fixed for the feature plans; always link with the builders in `src/app/routes.ts` (`paths.account(id)`, …). Each +feature exports its route objects from `src/features//index.ts` (`homeRoutes`, `accountsRoutes`, +`expensesRoutes`, `searchRoutes`, `settleRoutes`, `analyticsRoutes`, `settingsRoutes`, `authRoutes`), paths relative +to `/app`, pages loaded lazily with `lazyPage(() => import('./Page'))` (`src/app/lazyPage.ts`). `src/app/router.tsx` +only spreads those arrays, so a feature plan changes its pages and sub-routes without editing `src/app/`. + +| Path under `/app` | Page | Screen | +| ---------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------- | +| `/` | `features/home/HomePage` | S-4 | +| `/login` | `features/auth/LoginPage` | S-1 (web) | +| `/accounts/new`, `/accounts/:id/edit` | `features/accounts/AccountEditorPage` | S-6 | +| `/accounts/:id` | `features/accounts/AccountDetailPage` | S-5 | +| `/expenses/new`, `/expenses/:id/edit` | `features/expenses/ExpenseEditorPage` | S-7 | +| `/expenses/:id` | `features/expenses/ExpenseDetailPage` | S-8 | +| `/settle` | `features/settle/SettlePage` | S-9 | +| `/settlements/:id` | `features/settle/SettlementDetailPage` | S-10 | +| `/analytics` | `features/analytics/AnalyticsPage` | S-11 | +| `/analytics/:groupKind/:groupId` | `features/analytics/DrillDownPage` | S-12 (`groupKind` `category` or `account`) | +| `/insights` | `features/analytics/InsightsPage` | web | +| `/search` | `features/search/SearchPage` | web | +| `/settings` | `features/settings/SettingsPage` | S-13 (holds the web's language/theme card and sign-out) | +| `/settings/categories`, `recurring`, `devices`, `profile`, `invite`, `about` | `features/settings/*Page` | S-15, S-19, S-20, S-14, S-21, S-22 | + +## Data + +The web has no local database. What it reads: + +- **The ledger** (users, categories, accounts, recurring templates, expenses, settlements, audit entries) comes only + through sync pulls, because the API has no REST reads for them (SPEC §7). `useLedger()` (`api/ledger.ts`) holds + the ledger in the TanStack Query cache: the first read pulls from cursor 0 in pages of 1000, later reads pull from + the cursor it holds. A new `epoch` or 409 `cursor_ahead` starts again from 0. `ledgerBalances(ledger)` applies the + ported §9.3 rules; `live(map)` drops tombstones. Pass a `select` to `useLedger` for derived views. +- **REST reads**: `useMe()` (E-7), and the feature plans add devices (E-15), invites (E-13), health (E-1) the same + way (`useQuery` + `unwrap(api.GET(…))`). Profile pictures: `useUserPicture(userId, pictureVersion)`. +- **Refresh**: every query refetches when the window gets focus and every 60 s while visible + (`api/queryClient.ts`). `UpdatedLine` shows "Uppdaterad för 3 min sedan" with a button that reads now. + +What it writes: + +- REST writes (accounts, categories, templates, profile, invites, devices) return the changed row; merge it with + `useLedgerWriter().apply({ accounts: [row] })`, or call `refresh()` to pull. +- Expenses and settlements go through `POST /sync` (`api/push.ts`): build the entity with ids from `lib/ids.ts` + (`uuidv7()`, `timestamp()`), `pushMutations(api, [expenseMutation(entity)])`, then + `apply(rowsOfResults(results))`. A `rejected` result carries its reason (SPEC §14.4.6) for the page to show; the + web keeps no pending rows. +- Errors are `ApiError` (`status`, `code` of SPEC §14.3.2 or `network`, `field`, `retryAfterSeconds`). Map codes to + the `error_*` strings. + +## Auth + +`/app/login` accepts an invite code (E-3) like the phone's pairing screen: the code is normalised per SPEC §8.3 and +shown in groups of four, a pasted invite link gives its code and the inviter's name, and the server is this origin. +The display name field appears only when the server asks for it (`admin_setup` and `partner` codes answer +`invalid_request` on `display_name`). The token, user id, device id and invite kind are stored in `localStorage` +under `kvitt.session`. Every call sends `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version` (`package.json` +`kvitt.versionCode`), `X-Kvitt-App-Version-Name` (`package.json` `version`), `Accept-Language` (the page language), +and `Authorization: Bearer ` while signed in. Any 401 signs out (`unauthorized` or `device_revoked`) and the +router shows `/app/login` with S-18's text for `device_revoked` (D-130), "Du loggades ut" otherwise. After a +`partner` invite the first page is `/app/settings/profile` (AC-2.10). Signing out from Settings forgets the token only; the device row +stays on the server until it is removed under Devices on the phone (a device cannot revoke itself). + +## Strings + +`src/i18n/{sv,en}.json` are generated, flat key → string, never edited by hand: + +- Every row of `docs/ux/strings.csv` (the app's strings, same keys) plus `src/i18n/web-strings.csv` (web-only keys, + prefix `web_`, same columns `key,en,sv,notes`, so a docs agent can fold them into `strings.csv`). +- Android placeholders become 1-based braces with the same numbers: `%1$s` and `%1$d` → `{1}`, a bare `%d` → `{1}` + (then `{2}`, …), `%%` → `%`. Rows whose notes say `formatted=false` are copied as they are. +- Plurals keep the app's keys `key:one` / `key:other`. + +In components: `const { t, tp, language } = useI18n();` then `t('common_and', 'Jacob', 'Anna')` and +`tp('sync_pending', count)` (the count fills `{1}` unless other arguments are given). Keys are typed, so a missing key +fails `tsc`. The language follows `navigator.language` (`sv*` is Swedish, anything else English, as the app's +`KvittLanguage.of`); Settings stores an override in `localStorage` (`kvitt.language`). + +To add a string in a feature: put it in `src/features//i18n/sv.json` and `en.json` (flat, keys +`web__…`, the same keys in both, never a shared key; placeholders `{1}`, …). `src/i18n/catalog.ts` merges every +feature's files at build time (`import.meta.glob`), so features never edit the shared files; `t()` accepts any +`web_` key, and `src/i18n/catalog.test.ts` fails when a feature's two files differ in keys or reuse a shared key. A +string the app also has belongs in `docs/ux/strings.csv` (ask the docs agent; then `npm run i18n:import`). Shared +web-only strings live in `src/i18n/web-strings.csv` (W0's). + +## Formatting and domain rules + +- `lib/money.ts` ports `MoneyFormatter` and `AmountInput` (`:core:design`): `formatMoney(ore, language)` gives + `1 234,50 kr` / `SEK 1,234.50` with U+00A0; amount entry is string-parsed to öre. +- `lib/dates.ts` ports `DateFormatter` with its own month and weekday tables. +- `lib/split.ts`, `lib/balances.ts`, `lib/settlement.ts` port `kvitt-domain` (`compute_split`, `compute_balances`, + `propose_allocation`, `validate_settlement`, `over_settled_accounts`); `src/lib/vectors.test.ts` runs every case + of `contracts/test-vectors/*.json` against them, like `server/crates/kvitt-domain/tests/vectors.rs`. The app-only + functions (`spend_series`, `resolve_range`, `suggest_category`) are listed as not ported; the plan that ports one + moves it to `PORTED` in that test so its vectors run. +- `lib/effect.ts` is the §9.2 effect line. + +## Design tokens + +`src/design/tokens.css` holds every token of `docs/ux/design-spec.md` §2 as a CSS custom property. Colours carry their +light and dark value in one `light-dark()` declaration; the scheme follows `prefers-color-scheme`, and +`` (Settings › Theme, stored as `kvitt.theme`) overrides it. + +| Tokens | Source | Mapping | +| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `--primary` … `--outline-variant`, `--scrim` | §2.1 Material 3 colour roles | role names 1:1 (`on-primary-container` → `--on-primary-container`) | +| `--owed`, `--owing`, `--settled`, `--warning`, `--pending`, `--avatar-1/2`, `--card`, `--on-cat`, `--shadow`, tints | §2.2 Kvitt semantic roles and derived tints | 1:1 | +| `--cat-`, `--cat--container` | §2.3 (SPEC §9.7 tokens) | the 14 colour tokens | +| `--chart-cat-1…8`, `--chart-grid`, `--chart-axis-label` | §2.4 | accounts use `--chart-cat-(position mod 8 + 1)` | +| `--type-