Home (mirrors S-4, S-3 while the partner has not joined): global balance hero with the seesaw and §9.3 wording, Settle up, active accounts with balance and this month's count, collapsed "Arkiverade (n)" kept for the session, empty, loading and error states, New expense when an active account exists. Account detail (S-5): header with default split, split bar, archived badge and balance; expenses and settlements interleaved by date, grouped by month with household spend (settlement rows carry the allocation, AC-11.7); overflow menu with Edit, Archive/Unarchive (undo snackbar, D-071) and Delete only when allowed (AC-6.5); not-found, loading and error states. Account editor (S-6): name (1-60 code points, name_taken on the field), two percent fields that always sum to 100 with two decimals, slider and presets, the "Av 100,00 kr" preview; discard dialog with a title and no body (D-080) on every navigation away; E-18/E-19 apply the returned row. The account preset for W-5 and W-7 is the query parameter `account` (proposed D-144). Tests over a shared snapshot fixture and the fake server. Co-Authored-By: Claude Fable 5.1 <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.