W-7 (/app/settle, mirrors S-9): global, per account (?account=<id>) and editing (?settlement=<id>). Defaults per AC-11.3/11.4/11.6, proposal from proposeAllocation over all non-deleted accounts with nets excluding the edited payment, rows follow the proposal until one is edited, "Föreslå igen", swap, over-settle warnings and confirm dialog (AC-11.5), remaining indicator, add account, untouched info line (D-093), date and note, §14.5.3 errors live, one POST /sync mutation, §14.4.6 rejections shown with a re-pull, discard dialog on leaving with changes (D-080). W-8 (/app/settlements/:id, mirrors S-10): hero, date, note, allocations, audit timeline (AC-12.4), deleted and missing states, edit link and soft delete with confirmation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
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.
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 · <browser> and keeps its token in localStorage.
The gate
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/<name>/ 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/<name>/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 newepochor 409cursor_aheadstarts again from 0.ledgerBalances(ledger)applies the ported §9.3 rules;live(map)drops tombstones. Pass aselecttouseLedgerfor 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).UpdatedLineshows "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 callrefresh()to pull. - Expenses and settlements go through
POST /sync(api/push.ts): build the entity with ids fromlib/ids.ts(uuidv7(),timestamp()),pushMutations(api, [expenseMutation(entity)]), thenapply(rowsOfResults(results)). Arejectedresult carries its reason (SPEC §14.4.6) for the page to show; the web keeps no pending rows. - Errors are
ApiError(status,codeof SPEC §14.3.2 ornetwork,field,retryAfterSeconds). Map codes to theerror_*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 <token> 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) plussrc/i18n/web-strings.csv(web-only keys, prefixweb_, same columnskey,en,sv,notes, so a docs agent can fold them intostrings.csv). - Android placeholders become 1-based braces with the same numbers:
%1$sand%1$d→{1}, a bare%d→{1}(then{2}, …),%%→%. Rows whose notes sayformatted=falseare 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/<name>/i18n/sv.json and en.json (flat, keys
web_<name>_…, 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.tsportsMoneyFormatterandAmountInput(:core:design):formatMoney(ore, language)gives1 234,50 kr/SEK 1,234.50with U+00A0; amount entry is string-parsed to öre.lib/dates.tsportsDateFormatterwith its own month and weekday tables.lib/split.ts,lib/balances.ts,lib/settlement.tsportkvitt-domain(compute_split,compute_balances,propose_allocation,validate_settlement,over_settled_accounts);src/lib/vectors.test.tsruns every case ofcontracts/test-vectors/*.jsonagainst them, likeserver/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 toPORTEDin that test so its vectors run.lib/effect.tsis 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
<html data-theme="light|dark"> (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-<token>, --cat-<token>-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-<style>, --tracking-* |
§2.7 type scale | font shorthands; sp → rem (16 px root) so text follows the browser's font size; amounts add .amount (tabular figures) |
--s1 … --s12, --gutter, --row-min, --ledger-row-min, --top-bar-height, --nav-height |
§2.8 spacing | dp → px 1:1 |
--radius-* |
§2.9 shapes | dp → px; the Expressive MaterialShapes use their stated fallbacks (category tile 30 %, avatars circles) |
--elevation-card, --elevation-3 |
§2.10 | light cards: two soft layers in --shadow; dark: none |
--motion-* |
§2.11 | the springs approximated as CSS durations and curves; all 0 under prefers-reduced-motion; navigation has no motion (D-108) |
The typeface is the system UI face (D-020 bundles no font): Roboto where installed, else the platform's.
Icons
Material Symbols Rounded (design-spec §2.12), licence Apache License 2.0 (Google), from
@material-symbols/svg-500 (outline, weight 500) and @material-symbols/svg-600 (filled, weight 600). No other icon
set is used. scripts/gen-icons.mjs copies the path data of the listed icons into src/design/icons.generated.ts;
<Icon name="home" /> or <Icon name="home" filled /> renders one inline. Five spec names have another file name
in Material Symbols 0.48 (same glyph): card_giftcard → redeem, expand_more/expand_less →
keyboard_arrow_down/up, smartphone → mobile, phonelink_erase → mobile_cancel; the tables keep the spec
names. To add an icon: add its name to the list in the script and run npm run icons:import.
Adding a feature page (W1 to W5)
- Replace the placeholder in
src/features/<name>/<Page>.tsx; keep the paths ofsrc/app/routes.ts. New sub-routes go in the feature'sindex.ts. - Start the page with
<TopBar title={…} back={paths.…} actions={…} />(sets the tab title too). - Read with
useLedger(select)/useMe()/ auseQueryon a REST read; write as in "Data". - Use the shared components and tokens; no literal colours, sizes or strings in feature code.
- Strings from
t(); add missing ones in the feature'si18n/(see Strings). Dates and money only throughlib/dates.tsandlib/money.ts. - Register page-level shortcuts with
useShortcut(key, handler)(app/shortcuts.tsx); a later registration of a key wins, so a page's Esc overrides the shell's. - Tests next to the page (
*.test.tsx):renderApp(path, { server })fromsrc/test/render.tsxwith aFakeServeranswering the endpoints the page calls; no mocking libraries. npm run checkmust pass.
Dependencies
Runtime (all MIT; the 51 production packages, transitive included, are MIT, ISC or BSD-3-Clause): react and
react-dom 19.3.1, react-router 7.18.4, @tanstack/react-query 5.104.5, openapi-fetch 0.17.0, recharts
3.10.1 (with react-is 19.3.1, its peer).
Development: vite 8.3.5, @vitejs/plugin-react 6.1.2, typescript 5.9.3 (Apache-2.0; 5.9 because
typescript-eslint supports TypeScript below 6.1 and openapi-typescript asks for 5.x), openapi-typescript
7.13.0, vitest 5.0.5, jsdom 30.1.3, @testing-library/react 16.3.3, @testing-library/dom 10.4.2,
@testing-library/jest-dom 7.0.1, @testing-library/user-event 14.6.8, eslint 10.13.0, @eslint/js 10.0.1,
typescript-eslint 8.72.0, eslint-plugin-react-hooks 7.1.1, eslint-config-prettier 10.1.8, globals 17.13.0,
prettier 3.9.10, @types/react 19.3.0, @types/react-dom 19.3.0, @types/node 26.6.5,
@material-symbols/svg-500 and svg-600 0.48.1 (Apache-2.0). None of them ship in the build except the icon path
data.