Files
kvitt/docs/ux/design-spec.md
T

1210 lines
119 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kvitt: design spec (U2)
Status: binding design spec for v1, SPEC §13.4 phase 2, written 2026-10-18 for the owner's pick: direction 3 **"Kalas", muted** (UX-1), typeface per D-020. It replaces U1's `screens.md`, `components.md` and `tokens.md`.
---
## 1. How to use this file
**Who reads it.** M6 builds `:core:design` from §2 (tokens) and §3 (components). M7a–M7f build their screens from §4 (one subsection per screen) and §5 (the add-expense flow). Every screenshot test of a feature covers exactly the states listed under "States" for its screen (SPEC §13.3: this list is the binding one).
**Sources of truth.**
| Question | Source | Wins over |
|---|---|---|
| Rules (limits, rounding, sync, who may do what) | `SPEC.md` | everything here |
| Look, layout, sizes, states, interaction, TalkBack structure | this file | the PNGs |
| Exact words (sv, en), keys, plurals | `strings.csv` | the quoted words here |
| Pictures of each state | `screens/index.html` and `screens/<frame-id>-light.png` / `-dark.png` | nothing; a PNG that disagrees with this file is fixed |
| Category suggestion rule | `category-suggestion.md` (CS-1 … CS-7), keyword table `category-keywords.csv` | — |
| Navigation, screen inventory, data ownership | `ia.md`; flows `flows.md` | — |
Swedish words in quotes are as they appear on screen; `strings.csv` holds the final wording and the English. Keys cited in parentheses (`editor_amount`) are the keys of the current `strings.csv` (D-017 module prefixes: `onboarding_`, `home_`, `account_`, `expense_`, `settle_`, `analytics_`, `settings_`; `:core:design` keys such as `common_`, `editor_`, `ledger_`, `balance_`, `update_`, `snackbar_` carry no feature prefix). Every label quoted here has a key in `strings.csv`. `directions/3-kalas/index.html` is the U1 record the token values were copied from; it is not maintained.
**Decisions this file builds on.** UX-1 … UX-8 (`proposed-decisions.md`) are recorded in SPEC v0.8 as D-047 … D-054 (UX-1 D-047, UX-2 D-048, UX-3 D-049, UX-4 D-050, UX-5 D-051, UX-6 D-052, UX-7 D-053, UX-8 D-054). Still open: the §9.7 hex values of D-051 are `TODO(U2)` in SPEC v0.8 and are the values of §2.3 here; UX-9 is not yet in the spec. Labels used in this file:
| Label | What this file assumes | Where it matters here |
|---|---|---|
| UX-1 | Direction Kalas, muted palette; Roboto | all of §2 and §3 |
| UX-3 | Category suggested from the description (CS-1 … CS-7), `ExpenseRepository.suggestCategory` | §3.7 `CategoryQuickRow`, §4 S-7, §5 |
| UX-4 | S-1 has a required "Ditt namn" field on a first pairing | §4 S-1 |
| UX-5 | §9.7 colour hex values replaced by the muted values of §2.3; two new tokens `slate` and `amber` (`ColorToken.SLATE`, `ColorToken.AMBER`) | §2.3, S-15 colour swatches |
| UX-6 | No "Ångra" after saving an expense; FAB hidden on an archived account; per-account settle-up keeps the direction swap and an editable amount; avatar colours are two tokens chosen by user id | §2.5, S-5, S-7, S-9 |
| UX-8 | 14 default categories (new `housing`, `insurance`, `children`, `fees`), 33 icon tokens (new `key`, `shield`, `account_balance`), `DefaultCategoryKey` with 14 values, `CategoryIcons.ALL` with 33 | §2.13, S-7, S-11, S-15; the D-018 empty fake scenario then has 14 defaults, not 10 |
| UX-9 | Contract additions: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable(id)`, with fallbacks until they exist | §3.7 `CategoryQuickRow`, S-15 editor, §6 |
UX-2 (three tabs) changes nothing; UX-7 (import mapping) has no UI.
**Conventions used throughout.**
* Sizes in dp, text in sp. "Gutter" = 16 dp screen side padding. Touch targets are at least 48 × 48 dp everywhere (`minimumInteractiveComponentSize`); a control drawn smaller (36 dp chip, 40 dp button) keeps a 48 dp target.
* **"Kräver anslutning"** (`common_needs_connection`): an online-only action (§9.9) while offline is shown disabled **with** that text next to it (supporting line, menu-item second line or banner), never silently disabled. Online state comes from `ConnectivityRepository.online`.
* **"Kunde inte nå servern"** (`common_could_not_reach_server`): an online call returned `OnlineResult.Failed("network")`. Snackbar on the screen that made the call, with "Försök igen" when the call can be repeated; nothing changes locally (§9.9).
* Other `OnlineResult.Failed(code)` values map to inline text when a field is at fault (`name_taken` under the name) and to a snackbar otherwise, text from `strings.csv` `error_*`.
* **Pending marker**: `cloud_upload` icon in `pending` colour, TalkBack "Väntar på synk" (`common_pending_sync`), on every row or detail whose `pending == true`.
* **Amounts**: tabular figures always; formatted by `MoneyFormatter` (§9.8: `1 234,50 kr` with U+00A0, never a minus sign); spoken by `MoneyFormatter.spoken` ("1 234,50 kronor"). Amounts never ellipsize; at large font scale they move to their own line or wrap the unit.
* **Direction** of money (owes / owed) is always words plus colour, never colour alone (§13.1).
* "Viewer" = the person holding the phone, always "du" / "Du"; the partner by display name. Wherever two people are shown side by side, the viewer is on the left.
* State names: **loaded, empty, loading, offline, error, long** (long Swedish names, 7-digit amounts, 200 % font scale) and named variants. PNGs exist for every state in §4: `screens/<frame-id>-light.png` and `-dark.png`; 200 % and English frames are light only.
---
## 2. Tokens
### 2.1 Material 3 colour roles
Light and dark. Contrast is WCAG 2 against the background named; text needs ≥ 4.5:1, icons and graphics ≥ 3:1. `card` (§2.2) is the fill of most content cards.
| Role | Light | Dark | Contrast L / D |
|---|---|---|---|
| primary | #9C5238 | #E6B5A2 | 5.3 / 9.6 on surface; 5.7 / 8.4 on card |
| on-primary | #FFFFFF | #4A1F10 | 5.7 / 7.7 on primary |
| primary-container | #F2DED4 | #6A3826 | — |
| on-primary-container | #3A1609 | #F6DCD0 | 12.5 / 7.3 |
| secondary | #5A6D52 | #BCCBB1 | 5.2 / 10.3 on surface |
| on-secondary | #FFFFFF | #27341F | 5.6 / 7.7 |
| secondary-container | #DDE6D3 | #3D4A35 | — |
| on-secondary-container | #172410 | #D9E6CD | 12.6 / 7.2 |
| tertiary | #76612F | #DCC690 | 5.6 / 10.5 on surface |
| on-tertiary | #FFFFFF | #3A2E0B | 6.0 / 7.9 |
| tertiary-container | #EEE3C6 | #55432E | — |
| on-tertiary-container | #261C04 | #F1E3C4 | 13.1 / 7.4 |
| error | #B3261E | #F2B8B1 | 6.1 / 10.2 on surface; 6.5 / 9.0 on card |
| on-error | #FFFFFF | #5F1410 | 6.5 / 7.7 |
| error-container | #F9DEDA | #73302A | — |
| on-error-container | #410E0B | #F9DEDA | 12.8 / 7.5 |
| surface | #FAF7F2 | #1B1917 | — |
| on-surface | #22201D | #ECE6DF | 15.2 / 14.1 |
| surface-variant | #E8E2D9 | #4A4540 | — |
| on-surface-variant | #4E4842 | #CDC5BC | 8.4 / 10.3 on surface; 9.0 / 9.0 on card |
| surface-container-lowest | #FFFFFF | #151311 | — |
| surface-container-low | #F5F1EB | #221F1D | — |
| surface-container | #F0EBE4 | #272422 | — |
| surface-container-high | #EAE5DD | #312E2B | — |
| surface-container-highest | #E4DED6 | #3C3835 | — |
| surface-dim | #DCD6CE | #1B1917 | — |
| surface-bright | #FAF7F2 | #423E3A | — |
| inverse-surface | #36322E | #ECE6DF | — |
| inverse-on-surface | #F6F1EA | #312E2B | 11.3 / 10.9 |
| inverse-primary | #E4B39F | #9C5238 | 6.8 / 4.6 on inverse-surface |
| outline | #7E766E | #978E86 | 4.2 / 5.5 (graphics only) |
| outline-variant | #D2CAC1 | #4A4540 | decorative only |
| scrim | #000000 at 38 % | #000000 at 38 % | — |
Disabled content and containers follow Material 3: content `on-surface` at 38 %, container `on-surface` at 12 %.
### 2.2 Kvitt semantic roles (`KvittColors`)
| Role | Light | Dark | Use | Contrast L / D |
|---|---|---|---|---|
| owed | #3D6A4C | #A2CCB0 | text and graphics: the partner owes the viewer; "Du lånade ut …" | 5.8 / 9.8 on surface; 6.2 / 8.7 on card; 5.0 / 7.6 on surface-container-high |
| on-owed | #FFFFFF | #10331F | on owed | 6.2 / 7.8 |
| owed-container | #D6E7D9 | #2E4A37 | sync status tile | — |
| on-owed-container | #0F2416 | #CBE7D3 | | 12.7 / 7.4 |
| owing | #78497A | #D8B5D6 | text and graphics: the viewer owes the partner; "Du lånade …" | 6.5 / 9.6 on surface; 6.9 / 8.4 on card; 5.5 / 7.4 on surface-container-high |
| on-owing | #FFFFFF | #3C1F3C | | 6.9 / 7.9 |
| owing-container | #EEDDEC | #553A55 | | — |
| on-owing-container | #2D1430 | #F1DDEF | | 12.9 / 7.7 |
| settled | #6A5A2B | #E0CA94 | "Kvitt", seesaw plank when settled | 6.3 / 10.9 on surface; 5.3 / 5.9 on settled-container |
| settled-container | #EEE3C6 | #55432E | settled hero card | — |
| on-settled-container | #261C04 | #F1E3C4 | | 13.1 / 7.4 |
| warning | #8A5522 | #EDBE8C | over-settle, remaining-to-allocate, offline banners | 5.8 / 10.3 on surface |
| on-warning | #FFFFFF | #452B0E | | 6.2 / 7.7 |
| warning-container | #F5E1CA | #5B3F24 | warning strips and banners | — |
| on-warning-container | #2C1800 | #F6DFC6 | | 13.3 / 7.5 |
| pending | #3C6680 | #A3C7DE | pending marker, pending timeline line | 5.8 / 9.8 on surface; 6.2 / 8.7 on card |
| avatar-1 | #8A5A33 | #DDB693 | initials background, slot 1 (§2.5) | — |
| on-avatar-1 | #FFFFFF | #3A230F | | 5.8 / 7.8 |
| avatar-2 | #4A6587 | #ADC3DD | initials background, slot 2 | — |
| on-avatar-2 | #FFFFFF | #1C2F45 | | 6.0 / 7.5 |
| card | #FFFFFF | #272422 | fill of content cards, segmented list items, account cards | — |
| on-cat | #FFFFFF | #1B1917 | icon or letter on a solid category or account tile | ≥ 3.9 / ≥ 4.7 on every category colour; ≥ 4.8 / ≥ 8.2 on every chart colour |
| shadow | #3C2819 at 8 % | #000000 at 25 % | card shadow colour (§2.10) | — |
Derived tints (fixed values, computed as the first colour mixed over the second):
| Tint | Rule | Light | Dark |
|---|---|---|---|
| settlement-row | primary-container 28 % over card | #FBF6F3 | #3A2A23 |
| settlement-hero | primary-container 50 % over card | #F8EEEA | #482E24 |
| sync-pill-pending | pending 13 % over surface | #E1E4E3 | #2D3031 |
| pending-badge | pending 14 % over card | #E4EAED | #383B3C |
| account-header-n | chart-cat-n 12 % over card | n = 1 … 8: #F4EBE8, #ECEFEA, #F1EDE6, #EAEEF3, #EFEBF1, #E8EFEE, #F4EBEE, #EDEEEE | #3E342F, #373830, #3C362D, #353638, #3A3437, #333734, #3E3434, #383635 |
`on-surface` on sync-pill-pending: 12.7 / 10.7; `pending` on pending-badge: 5.1 / 6.3.
### 2.3 Category colours (§9.7 tokens; UX-5)
Fourteen tokens. Light and dark values; the **container** is the colour mixed over `surface` at **18 %** (light) and **24 %** (dark). Category colours are never used for text.
| Token | Light | Light container | L: vs surface / vs card / vs container / on-cat on it | Dark | Dark container | D: vs surface / vs card / vs container / on-cat on it |
|---|---|---|---|---|---|---|
| `green` | #4E7A55 | #DBE1D6 | 4.6 / 5.0 / 3.7 / 5.0 | #8FB896 | #373F35 | 7.9 / 7.0 / 4.9 / 7.9 |
| `orange` | #B26A3E | #EDDED2 | 3.9 / 4.2 / 3.2 / 4.2 | #E8AA7E | #4C3C30 | 8.7 / 7.7 / 5.3 / 8.7 |
| `brown` | #6B5244 | #E0D9D3 | 6.7 / 7.2 / 5.2 / 7.2 | #B8A090 | #413934 | 7.1 / 6.2 / 4.6 / 7.1 |
| `yellow` | #9C7C2A | #E9E1CE | 3.7 / 3.9 / 3.0 / 3.9 | #E6C67A | #4C432F | 10.6 / 9.3 / 5.9 / 10.6 |
| `blue` | #4A78A8 | #DAE0E5 | 4.3 / 4.6 / 3.5 / 4.6 | #98C0E6 | #394149 | 9.2 / 8.1 / 5.4 / 9.2 |
| `teal` | #367F82 | #D7E1DE | 4.4 / 4.7 / 3.5 / 4.7 | #8CCAC8 | #364341 | 9.5 / 8.4 / 5.6 / 9.5 |
| `purple` | #865891 | #E5DAE1 | 5.1 / 5.5 / 4.0 / 5.5 | #C59CDA | #443846 | 7.6 / 6.7 / 4.8 / 7.6 |
| `red` | #A84B45 | #EBD8D3 | 5.2 / 5.6 / 4.1 / 5.6 | #DC8F86 | #493532 | 6.9 / 6.1 / 4.5 / 6.9 |
| `pink` | #AE5D7C | #ECDBDD | 4.2 / 4.5 / 3.4 / 4.5 | #EFB0BE | #4E3D3F | 9.7 / 8.5 / 5.6 / 9.7 |
| `grey` | #6C7177 | #E0DFDC | 4.6 / 4.9 / 3.7 / 4.9 | #B0B2B3 | #3F3E3C | 8.2 / 7.2 / 5.0 / 8.2 |
| `indigo` | #45508D | #D9D9E0 | 7.1 / 7.5 / 5.4 / 7.5 | #969DD8 | #393945 | 6.8 / 6.0 / 4.4 / 6.8 |
| `lime` | #73822F | #E2E2CF | 4.0 / 4.2 / 3.2 / 4.2 | #B9CC86 | #414432 | 10.1 / 8.8 / 5.7 / 10.1 |
| `slate` (new, UX-5) | #5E2D4E | #DED3D4 | 10.0 / 10.7 / 7.3 / 10.7 | #B0719A | #3F2E36 | 4.7 / 4.2 / 3.4 / 4.7 |
| `amber` (new, UX-5) | #937D63 | #E7E1D8 | 3.7 / 3.9 / 3.0 / 3.9 | #DFCFC0 | #4A4540 | 11.5 / 10.2 / 6.2 / 11.5 |
All values are graphics ratios (≥ 3:1 required, all pass; the tightest are light `yellow` 3.03 and `amber` 3.02 on their containers, used only for unselected picker tiles where the name is printed beside the icon).
Use:
| Where | Fill | Icon |
|---|---|---|
| Solid tile (ledger rows, detail hero tile, selected picker tile, chart segments, breakdown bars, legend swatch) | `cat-<token>` | `on-cat` |
| Container tile (unselected picker tile in `CategoryQuickRow` and `CategoryGridSheet`) | `cat-<token>-container` | `cat-<token>` |
| Expense detail hero card, drill-down header card | `cat-<token>-container` | — |
| Settings row tiles (§3.11 `SettingsRow`) | `cat-<token>-container` | `cat-<token>` |
### 2.4 Chart and account colours
Accounts get `chart-cat-n` by their position in `AccountRepository.all()` (created_at ascending, archived included), n = position mod 8 + 1. The same colour is the account's tile (§3.4) on Home, the tint of its header on S-5 and its series in S-11. Categories in charts always use their own category colour.
| Token | Light | Dark | vs card L / D (graphics) | `on-cat` on it L / D (text) |
|---|---|---|---|---|
| chart-cat-1 | #A75B40 | #E3A68E | 5.0 / 7.4 | 5.0 / 8.4 |
| chart-cat-2 | #5E7A4E | #ABC79A | 4.8 / 8.3 | 4.8 / 9.5 |
| chart-cat-3 | #8B6A2C | #D8BD7E | 5.0 / 8.4 | 5.0 / 9.6 |
| chart-cat-4 | #4C6E98 | #9EB9DC | 5.3 / 7.7 | 5.3 / 8.7 |
| chart-cat-5 | #7C5A88 | #C8A6D2 | 5.7 / 7.2 | 5.7 / 8.2 |
| chart-cat-6 | #3F7774 | #8FC2BC | 5.1 / 7.8 | 5.1 / 8.9 |
| chart-cat-7 | #A2566F | #E3A6BA | 5.1 / 7.7 | 5.1 / 8.7 |
| chart-cat-8 | #6A6F74 | #B5B9BD | 5.1 / 7.8 | 5.1 / 8.9 |
| chart-seq-1 | #F2DFD6 | #3E2C25 | 1.3 / 1.2 (fill behind a label only) | — |
| chart-seq-2 | #E2B7A5 | #6A4234 | 1.8 / 1.8 (fill behind a label only) | — |
| chart-seq-3 | #C08068 | #9A6049 | 3.2 / 3.0 | — |
| chart-seq-4 | #A75B40 | #CC8A70 | 5.0 / 5.5 | — |
| chart-seq-5 | #763A26 | #E6B5A2 | 8.7 / 8.4 | — |
| chart-grid | #E6E0D8 | #36322F | decorative | — |
| chart-axis-label | #4E4842 | #CDC5BC | 9.0 / 9.0 (text) | — |
Stacked segments are separated by a 2 dp gap so neighbouring colours never touch. Sequential tokens are reserved (no v1 screen uses them).
### 2.5 Avatar colours (UX-6, AC-4.5)
Initials (first letter of the first two words of the display name, uppercase, AC-4.5) on `avatar-1` / `on-avatar-1` or `avatar-2` / `on-avatar-2`. **Slot rule:** of the household's users, the one whose id sorts first (plain string comparison of the lowercase UUID) is slot 1, the other slot 2; a household with one user has only slot 1. User ids are UUIDv7, so in practice the admin (created first) is slot 1 on both phones, and the two people never share a colour. The split bar (§3.7) uses the same two colours for the two people.
### 2.6 Dynamic colour
Not offered. Category, account, owed/owing and settled colours carry meaning and a wallpaper scheme cannot recolour them coherently (UX-1). The theme follows the system light/dark setting only.
### 2.7 Typography
Typeface: the Material 3 default (`FontFamily.Default`, Roboto on Android 16), no bundled font (D-020). Every amount style sets `fontFeatureSettings = "tnum"`; Roboto figures are already tabular, the feature is set so a different system font stays aligned.
| Style | Size / line height (sp) | Weight | Letter spacing (sp) | Used for |
|---|---|---|---|---|
| display-large | 57 / 64 | 500 | −0.25 | unused |
| display-medium | 45 / 52 | 500 | 0 | unused |
| display-small | 36 / 44 | 600 | 0 | "Kvitt" on S-1 |
| headline-large | 32 / 40 | 600 | 0 | unused |
| headline-medium | 28 / 36 | 600 | 0 | "Ni är kvitt", S-18 title, S-22 version |
| headline-small | 24 / 32 | 600 | 0 | empty-state titles, dialog titles, "kr" after the editor amount |
| title-large | 22 / 28 | 600 | 0 | top app bar titles, section headers ("Konton") |
| title-medium | 16 / 24 | 600 | 0.15 | row titles, balance wording in the hero, card titles |
| title-small | 14 / 20 | 600 | 0.1 | month headers, group headers, banner titles |
| body-large | 16 / 24 | 400 | 0.5 | field values, detail fact values, empty-state body |
| body-medium | 14 / 20 | 400 | 0.25 | effect line, secondary row text, banner body |
| body-small | 12 / 16 | 400 | 0.4 | dates in rows, captions, row meta |
| label-large | 14 / 20 | 600 | 0.1 | buttons, "Totalt" |
| label-medium | 12 / 16 | 500 | 0.5 | field labels, account-card wording, nav labels |
| label-small | 11 / 16 | 500 | 0.5 | badges, category tile labels (11 / 14) |
| amount-hero | 44 / 52 | 700 | −0.5 | Home balance, editor amount |
| amount-large | 28 / 36 | 700 | −0.25 | detail amounts, S-5 balance, analytics total, S-9 amount field |
| amount-medium | 18 / 24 | 700 | 0 | account-card amount, allocation fields, compact editor row |
| amount-small | 15 / 20 | 600 | 0 | ledger row amounts, breakdown amounts |
Units next to large amounts: the hero's "kr" is 24 / 30 sp weight 700 in the amount's colour; the editor's "kr" is headline-small in `on-surface-variant`. English puts "SEK" before the number (§9.8) with the same unit style.
**Font scale.** Compose's default non-linear font scaling (Android 14+) applies: text of 24 sp and larger grows less than the system scale (about 1.5× at 200 %), smaller text grows fully. Two explicit caps, implemented by converting a dp-based size (`with(LocalDensity) { (12.dp * min(fontScale, 1.6f)).toSp() }`): bottom-navigation labels at 1.6×, the seesaw name labels at 1.15×; the account tile letter at 1.3×. Nothing else is capped. Check on the S26 in M6 that `FontFamily.Default` resolves to Roboto with tabular figures under every One UI font style (open point 4).
### 2.8 Spacing
Scale (`KvittSpacing`): `s1` 4, `s2` 8, `s3` 12, `s4` 16, `s5` 20, `s6` 24, `s8` 32, `s10` 40, `s12` 48 dp.
| Measure | dp |
|---|---|
| Screen gutter | 16 |
| Gap between cards and sections in a scrolling column | 12 (S-4, S-7), 10 (detail screens, S-9, S-11, settings sub-screens) |
| Gap between items of a segmented group | 3 |
| Card padding | 16–20 (per component) |
| Row padding | 10 vertical, 16 horizontal; min height 56 (settings, detail facts) or 72 (ledger) |
| Gap between leading tile and text | 14 (rows), 12 (cards) |
| Bottom padding of a scrolling column under a FAB | 120 |
| Top app bar height | 64 |
| Bottom navigation | 64 + system navigation inset |
### 2.9 Shapes
M3 shape scale: extraSmall 4, small 8, medium 12, large 16, largeIncreased 20, extraLarge 28, extraLargeIncreased 32, extraExtraLarge 48, full.
| Component | Shape |
|---|---|
| Buttons, chips, keypad keys, sync pill, badges, nav indicator (56 × 32, radius 16), connected-button outer ends | full |
| Connected-button inner corners | small 8 |
| Text fields (filled, no indicator line) | large 16, all four corners |
| Menus, info boxes inside cards | large 16 |
| Warning strips inside cards, menu items | medium 12 |
| FAB (56 dp) | largeIncreased 20 |
| Cards, balance hero, dialogs, keypad sheet top corners, flat sections | extraLarge 28 |
| Account cards, segmented-group outer corners, banners | 24 |
| Segmented-group inner corners | small 8 |
| Allocation amount field | 14 |
| Icon grid cells (S-15) | large 16 |
| Chart bars | top 10, bottom 4 |
| Category tile | `MaterialShapes.Cookie4Sided` → fallback `RoundedCornerShape(30%)` |
| Account tile | `MaterialShapes.Clover4Leaf` → fallback `RoundedCornerShape(16.dp)` |
| Large avatars (≥ 40 dp outside rows: hero, settings card, S-2, S-9, S-10, S-14), app mark, dialog icon tile, sync status tile | `MaterialShapes.Sunny` → fallback `CircleShape` |
| Empty and removed illustrations (S-18), loading indicator container | `MaterialShapes.Cookie9Sided` → fallback `CircleShape` |
| Avatars inside rows and chips (20–32 dp), colour swatches, settlement tile | `CircleShape` |
MaterialShapes are `RoundedPolygon`s turned into a `Shape` with `toShape()`; all are wrapped in `KvittShapes` so the fallback swaps in one place.
### 2.10 Elevation
Tonal first. Light: content cards (`card` #FFFFFF on surface #FAF7F2) carry a soft shadow, two layers (y 1 blur 2, y 2 blur 8) in `shadow` (#3C2819 at 8 %); in Compose `Modifier.shadow(elevation = 2.dp, shape, ambientColor = shadow, spotColor = shadow)`. Dark: no visible shadow; `card` is surface-container on surface. FAB, menus and dialogs: level 3 (6 dp, Material default shadow) in both themes; the dialog scrim is #000000 at 38 %. Segmented list items and flat sections (`surface-container-low`) have no shadow.
### 2.11 Motion
`MaterialTheme(motionScheme = MotionScheme.expressive())`; fallback `MotionScheme.standard()` (§3.1 rule on Expressive APIs).
| Spring | Damping ratio | Stiffness | Use |
|---|---|---|---|
| spatial fast | 0.6 | 800 | chip and tile selection, check badge pop (scale 0 → 1), keypad key press |
| spatial default | 0.8 | 380 | expanding the split section, collapsible sections, sheet open, FAB extend/collapse |
| spatial slow | 0.8 | 200 | screen-level layout changes |
| effects fast | 1.0 | 3800 | state-layer and colour of pressed controls |
| effects default | 1.0 | 1600 | colour and opacity changes (tile container → solid, card cross-fades) |
| effects slow | 1.0 | 800 | settled card cross-fade to oat |
Named motions:
* **Seesaw re-tilt** when the global balance changes: spring damping 0.55, stiffness 300; the hero amount counts from the old to the new value over 400 ms, `EmphasizedDecelerate`.
* **Settling to zero**: the plank levels (re-tilt spring), then the bunting drops one pennant at a time, 40 ms stagger, each on spatial fast; the card cross-fades to `settled-container` (effects slow).
* **Category selection**: the chosen tile cross-fades container → solid (effects default) and the check badge pops (spatial fast); a suggestion change (CS-7) uses the same motion without haptics.
* **Saved expense**: the editor closes; the new row is in its month group on return (no extra animation required).
* **Navigation**: pushed screens enter with a horizontal shared-axis transition (30 dp slide + fade, spatial default); tab switches fade through (effects default); S-7 and S-9 enter from the bottom (slide up 10 % + fade).
* **Reduced motion** (system "Ta bort animationer", `Settings.Global.ANIMATOR_DURATION_SCALE == 0`): all springs become critically damped and immediate, the bunting appears at once, the hero amount does not count.
### 2.12 Icons
Material Symbols **Rounded**, optical size 24, grade 0, shipped as vector drawables generated from the font (Apache 2.0). Two variants:
| Variant | FILL | Weight | Used for |
|---|---|---|---|
| outline | 0 | 500 | app-bar actions, row leading icons in detail facts, chevrons, field icons |
| filled | 1 | 600 | category icons, status icons (cloud_done, warning, error), selected nav icon, banner icons, handshake, check badge (weight 700, size 14) |
Icons are dp-sized (24 default; 18 in chips; 15–16 in row meta; 20 in buttons) and never scale with font size.
**Category icon tokens (33, §9.7 + UX-8)** in `CategoryIcons.ALL` order: `shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`, `key`, `shield`, `account_balance`. Each has a Swedish and English TalkBack name in `strings.csv` (`icon_<token>`).
**UI icons** (outline unless marked filled):
| Icon | Where |
|---|---|
| `home`, `bar_chart`, `settings` | bottom navigation (filled when selected) |
| `add` | FAB, "Nytt konto", "Lägg till konto", "Ny kategori", "Ny återkommande" |
| `close` | editor top bars (S-6, S-7, S-9, S-15 editor, S-19 editor) |
| `arrow_back` | pushed screens |
| `more_vert` | S-5 overflow |
| `edit`, `delete` | S-8, S-10 actions; menu items |
| `check` | Spara in editors, check badge, selected chips |
| `swap_horiz` | S-9 direction swap; S-7 payer chip trailing |
| `handshake` (filled) | "Gör upp", settlement tile, confirm dialog |
| `cloud_upload` | pending marker |
| `cloud_off` | offline sync pill, "Kräver anslutning" hints and banners |
| `cloud_done` (filled) | synced pill and S-16 status |
| `sync`, `sync_problem` | "Synka nu"; S-16 status when the last attempt failed |
| `warning` (filled), `error` (filled), `info`, `check_circle` (filled) | warnings, errors, info lines, settled and "Kvar att fördela 0,00 kr" |
| `chevron_right`, `expand_more`, `expand_less` | rows, dropdown chips, collapsible sections |
| `inventory_2`, `unarchive` | archive badge and actions |
| `event`, `call_split`, `notes`, `person`, `apps`, `backspace`, `calculate`, `date_range` | editor and detail facts, "Alla", keypad delete, S-6 preview, period chip |
| `content_paste`, `link`, `dns`, `person_add`, `add_a_photo`, `photo_library` | S-1, S-2, S-3, S-14 |
| `receipt_long` (filled) | app mark, notification small icon |
| `schedule`, `pause`, `event_repeat`, `drag_indicator`, `restart_alt`, `lock` | S-3, S-19, S-8, S-15 |
| `system_update`, `download`, `downloading`, `refresh`, `install_mobile` | S-17 |
| `phonelink_erase`, `smartphone`, `block`, `devices`, `add_link`, `key`, `share`, `content_copy` | S-18, S-20, S-21, S-13 |
| `memory`, `description`, `open_in_new` | S-22, S-16 |
| `arrow_forward` | S-9, S-10 direction |
### 2.13 Default categories (UX-8)
Seeded by the server (SPEC AC-7.2, D-049 to D-054, SPEC v0.8; ids 11 to 14 added by server migration 0002). The localised name shows until a user renames the category.
| Sort | `default_key` | Fixed id (SPEC §14.1) | Swedish | English | Icon | Colour |
|---|---|---|---|---|---|---|
| 10 | `groceries` | `01920000-0000-7000-8000-000000000001` | Mat & hushåll | Groceries | `shopping_cart` | `green` |
| 20 | `restaurants` | `01920000-0000-7000-8000-000000000002` | Restaurang & café | Restaurants | `restaurant` | `orange` |
| 25 | `housing` | `01920000-0000-7000-8000-000000000011` | Boende | Housing | `key` | `indigo` |
| 30 | `home` | `01920000-0000-7000-8000-000000000003` | Hem | Home | `home` | `brown` |
| 40 | `utilities` | `01920000-0000-7000-8000-000000000004` | Räkningar | Utilities | `bolt` | `yellow` |
| 45 | `insurance` | `01920000-0000-7000-8000-000000000012` | Försäkringar | Insurance | `shield` | `slate` |
| 50 | `transport` | `01920000-0000-7000-8000-000000000005` | Transport | Transport | `directions_car` | `blue` |
| 60 | `travel` | `01920000-0000-7000-8000-000000000006` | Resor | Travel | `flight` | `teal` |
| 70 | `entertainment` | `01920000-0000-7000-8000-000000000007` | Nöje | Entertainment | `movie` | `purple` |
| 80 | `health` | `01920000-0000-7000-8000-000000000008` | Hälsa | Health | `medical_services` | `red` |
| 85 | `children` | `01920000-0000-7000-8000-000000000013` | Barn | Children | `child_care` | `lime` |
| 90 | `shopping` | `01920000-0000-7000-8000-000000000009` | Shopping | Shopping | `shopping_bag` | `pink` |
| 95 | `fees` | `01920000-0000-7000-8000-000000000014` | Avgifter & skatter | Fees & taxes | `account_balance` | `amber` |
| 100 | `other` | `01920000-0000-7000-8000-00000000000a` | Övrigt | Other | `category` | `grey` |
Every default category has its own colour; `teal` is shared with the archived example custom category "Pant" (`water_drop`) in the frames only.
### 2.14 Compose mapping (`:core:design`)
| Token group | Compose |
|---|---|
| §2.1 | `lightColorScheme(...)` / `darkColorScheme(...)`, role names 1:1 (`on-primary-container` → `onPrimaryContainer`) |
| §2.2–§2.5 | `@Immutable data class KvittColors(...)` with a light and a dark instance in `LocalKvittColors`; functions `category(token: ColorToken)`, `categoryContainer(token)`, `account(index: Int)` (chart-cat, 0-based position mod 8), `avatar(slot: Int)` / `onAvatar(slot)`; properties for every role of §2.2 and the derived tints |
| §2.7 | `Typography(...)` for the 15 M3 styles (default font family, weights and letter spacing above) plus `KvittTypography(amountHero, amountLarge, amountMedium, amountSmall)` in `LocalKvittTypography` |
| §2.8 | `object KvittSpacing` (or `LocalKvittSpacing`) with `s1` … `s12`, `gutter`, `rowMin`, `ledgerRowMin` |
| §2.9 | `Shapes(extraSmall … extraLarge)` plus `KvittShapes(categoryTile, accountTile, avatarLarge, illustration, card, accountCard, groupOuter, groupInner, field, fab)` in `LocalKvittShapes` |
| §2.11 | `MotionScheme` on `MaterialTheme`; named motions as constants in `KvittMotion` |
| Entry point | `KvittTheme(darkTheme: Boolean = isSystemInDarkTheme(), content)`, accessors `KvittTheme.colors`, `.typography`, `.shapes`, `.spacing` |
`ColorToken` gains `SLATE` and `AMBER` (UX-5) and `DefaultCategoryKey` gains `HOUSING`, `INSURANCE`, `CHILDREN`, `FEES` (UX-8); `KvittColors.category` is exhaustive over the enum.
---
## 3. Components (`:core:design`)
### 3.1 Rules for every component
* Colours, type, shapes and spacing only from `KvittTheme` (§2.14); no literal values in feature modules.
* Strings from resources. Shared strings live in `:core:design`; components take resolved `String`s for feature-specific words.
* Every component takes a `Modifier`, has light/dark, Swedish/English and 100 %/200 % font-scale screenshot tests in M6 (SPEC M6), and keeps 48 dp touch targets.
* **Expressive APIs** (`@ExperimentalMaterial3ExpressiveApi`) are used only behind a `:core:design` wrapper so the stable fallback named in the component swaps in at one place. The implementing agent confirms each API's status and signature in the official documentation at implementation time (CLAUDE.md › Fast-moving APIs). Summary:
| Expressive | Where | Stable fallback |
|---|---|---|
| `MaterialShapes` (Cookie4Sided, Clover4Leaf, Sunny, Cookie9Sided) via `toShape()` | category, account, avatar and illustration tiles | `RoundedCornerShape` / `CircleShape` (§2.9) |
| `ShortNavigationBar` (64 dp) | bottom navigation | `NavigationBar` (80 dp) |
| `ButtonGroup` (connected) | analytics toggles, ratio presets, full-split person picker | `SingleChoiceSegmentedButtonRow` |
| Expressive slider look (thick track, thin handle) | `SplitSlider` | `Slider` with custom track and thumb |
| `LoadingIndicator` (shape morph) | first sync on Home | `CircularProgressIndicator` |
| `LinearWavyProgressIndicator` | update download | `LinearProgressIndicator` |
| Medium extended FAB (56 dp, radius 20) | "Lägg till utgift", "Ny återkommande" | `ExtendedFloatingActionButton` |
| Button sizes small (40) and medium (56) | all buttons | `Button` with `heightIn` |
| `MotionScheme.expressive()` | theme | `MotionScheme.standard()` |
| Segmented list items (grouped shapes) | ledger, settings, devices, facts | `Surface` per item with the §2.9 group shapes |
* **Semantics.** Rows merge their children into one node (`Modifier.semantics(mergeDescendants = true)`) with the default click action (no custom click labels); icon-only controls always have a `contentDescription`; decorative tiles, the seesaw, split bars next to their numbers and illustrations are cleared (`clearAndSetSemantics {}` or `contentDescription = null`). TalkBack text is Swedish or English per app language and reads amounts with `MoneyFormatter.spoken`.
### 3.2 Money and words
**`MoneyText(ore: Long, style: AmountStyle, tone: AmountTone, unit: Boolean = true)`**
* `AmountStyle`: `Hero`, `Large`, `Medium`, `Small` → the four amount styles of §2.7. `AmountTone`: `Neutral` (on-surface), `Owed`, `Owing`, `Muted` (on-surface-variant), `Settled`.
* Never a sign. Never ellipsized: one line; when it does not fit, `Hero` and `Large` put the unit on a second line; others wrap at the unit.
* Semantics: `MoneyFormatter.spoken(ore)`.
**`EffectLine(effect: ExpenseEffect)`** (§9.2), body-medium:
* `Lent(ore)` "Du lånade ut 206,00 kr" in `owed` (`effect_lent`) · `Borrowed(ore)` "Du lånade 623,80 kr" in `owing` (`effect_borrowed`) · `NotInvolved` "Inte inblandad" · `PaidForYourself` "Betalade för dig själv" in `on-surface-variant`.
**`BalanceWording(netOre: Long, partner: User, scope: Global | Account)`** (§9.3): "Anna är skyldig dig" (`owed`), "Du är skyldig Anna" (`owing`), "Ni är kvitt" (global, `settled`) / "Kvitt" (account, `settled`). Used by `BalanceHero`, `AccountCard`, `AccountBalanceHeader`.
### 3.3 People
**`PersonAvatar(user: User, size: Dp, shape: AvatarShape = Circle)`**
* Sizes in use: 20, 24, 28, 32, 40, 46, 52, 72, 112, 128.
* Picture via Coil keyed by `user.id` + `pictureVersion` (AC-4.6), cropped to the shape; else initials on the slot colour (§2.5), initials size 44 % of the avatar (capped at 1.3× font scale). `Sunny` shape for the large standalone uses of §2.9, else circle.
* States: picture, initials (also while the picture loads), **uploading** (a 4 dp `CircularProgressIndicator` in `primary` drawn around the avatar, picture or initials at 60 % opacity).
* Semantics: the display name; decorative (`null`) when the name is printed beside it.
**`AvatarPlaceholder(size)`**: the empty partner seat of S-3: circle, 2 dp dashed `outline` border, `surface-container-high` fill, `person_add` icon at 50 % of the size in `on-surface-variant`. TalkBack "Partner saknas".
### 3.4 Categories and accounts
**`CategoryTile(category: Category, size: Dp, style: Solid | Container, archived: Boolean = false)`**
* Shape Cookie4Sided (fallback 30 % rounded). Sizes: 32 (breakdown), 36 (S-15 list, S-16, compact editor row), 40 (ledger rows), 52 (editor quick row and sheet), 56 (S-12 header), 60 (S-8 hero), 64 (S-15 editor preview).
* Icon: filled Material Symbol at 52 % of the tile (`iconRatio`), colours per §2.3.
* `archived`: saturation 25 %, opacity 75 %.
* Semantics: decorative when the category name is printed next to it (always, except in the quick row where the label is the tile's own text).
**`AccountTile(name: String, colorIndex: Int, size: Dp = 44, muted: Boolean = false)`**
* Clover4Leaf (fallback 16 dp rounded) filled with `chart-cat-n` (§2.4); the first grapheme of the name, uppercase, title 18 sp weight 700 in `on-cat` (capped 1.3×). `muted` (archived): opacity 70 %. Sizes 32, 36, 40, 44. Always decorative.
**`AccountCard(summary: AccountSummary, colorIndex: Int, onClick)`** (S-4)
* Card, radius 24, `card` fill and shadow, padding 14 vertical / 16 horizontal, min height 76, gap 14.
* Left: `AccountTile` 44. Middle: name (title-medium, 1 line, ellipsis) with an "Arkiverat" badge (`inventory_2` 14) for archived accounts; below it body-small `on-surface-variant` "11 utgifter i oktober" (`home_account_expenses_month`, plural; current month name) or "Inga utgifter i oktober". Right column, right-aligned: wording in label-medium and `MoneyText(Medium)` both in `owed`/`owing`; when `netOre == 0`: `check_circle` (filled, 20) + "Kvitt" in title-medium `settled`, no amount.
* Archived card: card opacity 92 %, tile muted.
* Long and 200 %: the right column wraps to its own line under the name, right-aligned (flex-wrap); the name keeps one line with ellipsis at 100 % and two lines at ≥ 1.5×.
* Semantics: one node "Vardag, Anna är skyldig dig 1 184,50 kronor, 11 utgifter i oktober" (archived: "…, arkiverat"), role button.
**`CategoryQuickRow`, `CategoryGridSheet`**: see §3.7 (editor fields).
**`CategoryIconPicker(selected: String, color: ColorToken)`** (S-15): grid of the 33 icon tokens, 7 columns, gap 5, cells 48 dp high, radius 16. Unselected: `surface-container-high` fill, outline icon in `on-surface-variant`. Selected: `cat-<color>` fill, filled icon in `on-cat`. Single-select radio group; each cell TalkBack = the icon's name (`icon_<token>`), state "vald".
**`ColorSwatchPicker(selected: ColorToken)`** (S-15): 14 circles of 44 dp in a 7-column grid, gap 8, each the solid category colour. Selected: `check` icon 22 in `on-cat` plus a 3 dp `surface` ring and a 2 dp `on-surface` ring outside it (selection never by colour alone). TalkBack: colour name ("Grön", "Plommon", "Sand", … `color_<token>`), state "vald".
### 3.5 Ledger (D-019; used by S-5, S-12, S-16)
**`SegmentedGroup`**: vertical group of items 3 dp apart; outer corners 24, inner 8; item fill `card` (default) or `surface-container-low` (`soft`). Light: the group casts the card shadow once.
**`LedgerMonthHeader(period: String, householdSpendOre: Long)`**: title-small "Oktober · 7 457,90 kr" (`ledger_month_total`), padding 12 top / 8 sides / 2 bottom; year appended when not the current year ("September 2025 · …"). Sticky (`stickyHeader`) on S-5 with a `surface` background. Semantics heading.
**`ExpenseRow(row: LedgerRow.ExpenseRow, onClick)`**
* Item of a `SegmentedGroup`, min height 72, padding 10 / 16, gap 14.
* `CategoryTile` 40 solid · centre: description (title-medium, 1 line, ellipsis), `EffectLine` below · right column: `MoneyText(Small)`, below it body-small `on-surface-variant` date ("16 okt.", §9.8) preceded by the pending marker (15 dp) when `pending`.
* 200 %: the right column moves under the centre column, left-aligned with the text, so the description gets the full width (still 1 line, ellipsis) and the amount is never cut.
* Semantics: one node "ICA Maxi Lindhagen, 1 247,60 kronor, 16 oktober, du lånade 623,80 kronor, väntar på synk" (category name added after the description: "…, Mat & hushåll, …").
**`SettlementRow(row: LedgerRow.SettlementRow, onClick)`**
* Same item geometry; fill `settlement-row` tint (§2.2).
* Tile: 40 dp circle `primary-container` with filled `handshake` in `on-primary-container`. Centre: "Anna betalade dig 800,00 kr" / "Du betalade Anna 800,00 kr" (title-medium, `ledger_settlement_*`), plus " · 2 konton" when `accountCount > 1`; second line "Betalning" body-medium muted. Right: date with the pending marker.
* Semantics: "Anna betalade dig 800,00 kronor, 2 konton, 6 oktober".
**`SyncPendingMarker(size = 15.dp)`**: `cloud_upload` in `pending`, `contentDescription` "Väntar på synk" when standalone; merged into the row sentence otherwise.
### 3.6 Balance, sync and banners
**`BalanceHero(state: HeroState, onSettle)`** (S-4; seesaw)
* Card, radius 28, padding 16 top / 20 sides / 20 bottom, content centred.
* **Seesaw** (decorative), 250 × 92 dp: plank 220 × 8 (radius 4) pivoting at its centre on a fulcrum triangle 36 × 22 in `outline-variant`; the viewer's avatar (46, Sunny) on the left end, the partner's on the right end, 88 dp from the centre; name labels "Du" and "Anna" (12 sp weight 600, `on-surface-variant`, capped 1.15×) outside each avatar. **The side of the person who owes sits lower**: tilt 9°; plank colour `owing` (viewer owes), `owed` (partner owes), `settled` (level, 0°), `outline` (waiting, 12° down on the viewer's side).
* Below: wording (title-medium, `owed`/`owing`), amount (`amount-hero` + "kr" at 24 sp 700, same colour), then "Gör upp" (filled, small 40 dp, filled `handshake` icon; `home_settle_up`) 14 dp below.
* States:
| State | Content |
|---|---|
| `Owed(net, partner)` | "Anna är skyldig dig", amount in `owed`, plank tilted toward the partner, "Gör upp" |
| `Owing(net, partner)` | "Du är skyldig Anna", amount in `owing`, plank tilted toward the viewer, "Gör upp" |
| `Settled` | card fill `settled-container`, text `on-settled-container`, bunting along the top (30 dp high: a curved `outline` line with 14 pennants in category colours green, orange, yellow, blue, teal, purple, red, pink, lime, orange, green, blue, yellow, pink), top padding 28, level plank, "Ni är kvitt" (headline-medium), "Ingen är skyldig något just nu." (body-medium); no amount, no button |
| `Waiting` (S-3) | partner seat = `AvatarPlaceholder`, label "Partner", plank `outline`; content per S-3 |
| `Loading` (S-4 first sync) | no seesaw: `LoadingIndicator` 56 dp in a Cookie9 `primary-container` tile, "Hämtar era utgifter…" (title-medium), "Första synken hämtar allt som finns på servern. Det tar bara en stund." (body-medium muted); padding 28 / 20 |
* Long: a 7-digit amount ("1 234 567,89 kr") fits one line at 100 %; at 200 % the hero amount grows about 1.5× and the unit wraps to its own line; wording wraps, never ellipsizes.
* Semantics: the card is one node, `contentDescription` = "Du är skyldig Anna 3 250,00 kronor" / "Anna är skyldig dig …" / "Ni är kvitt"; the button is a separate node "Gör upp".
**`AccountBalanceHeader(detail: AccountDetailState, colorIndex: Int, onSettle, onUnarchive)`** (S-5)
* Card, radius 28, padding 18 / 20 / 20, gap 10, fill `account-header-n` (§2.2).
* Row: `AccountTile` 40 · "50 / 50 · Jacob och Anna" (title-medium; default shares in percent, viewer first, `account_default_split`) with a thin `SplitBar` (8 dp) below · "Arkiverat" badge when archived.
* Balance: wording (title-medium) above `MoneyText(Large)`, both in `owed`/`owing`; zero: `check_circle` 28 + "Kvitt" in headline-small `settled`.
* Actions, left-aligned: "Gör upp det här kontot" (filled, `handshake`) when `netOre != 0` (archived accounts too: they can still be settled, AC-6.4); for an archived account also "Återställ från arkiv" (tonal, `unarchive`), placed first.
* Semantics: one node "Vardag, standarddelning 50 procent du, 50 procent Anna, Anna är skyldig dig 1 184,50 kronor"; button separate.
**`SyncStatusPill(summary: SyncSummary, online: Boolean, onClick)`** (S-4)
* Centred pill, min height 40 (48 target), padding 6 / 10 / 6 / 14, radius full, body 13 sp weight 500; leading icon 18, trailing `chevron_right` 18.
* Variants: synced: `surface-container-high` fill, `cloud_done` (filled) in `owed`, "Synkad för 3 min sedan" (`sync_synced_relative`, §9.8 relative time, refreshed every minute) · running: `sync` icon, "Synkar…" · pending: suffix " · 2 ändringar väntar på synk" (`sync_pending`, plural) · offline: `sync-pill-pending` fill, `cloud_off` in `pending`, "Offline · 2 ändringar väntar på synk" (or "Offline" alone) · warning: `warning-container` fill, `warning` icon, "1 ändring synkades inte" (`sync_rejected`, plural) or "Telefonens klocka går fel" (`sync_clock_wrong_short`) · failed: "Synk misslyckades · försök igen om en stund" in the warning style.
* Never a blocking banner. Whole pill is one button: TalkBack "Synkstatus: <visible text>".
**`Banner(kind: Lantern | Error | Warning | Neutral, icon, title, body, actions)`**: radius 24, padding 14 / 16 / 10, gap 6; icon 24 (filled) + title (title-small) + body (body-medium); actions right-aligned, text buttons in the banner's content colour. Lantern = `tertiary-container`, Error = `error-container`, Warning = `warning-container`, Neutral = `surface-container-high`. `role` status (alert for Error).
**`UpdateBanner(state: UpdateState, onUpdate, onLater, onRetry)`** (S-4, S-22): `Available`: Lantern, `system_update`, "Version 1.2 finns", "Nyheter och rättningar · 8,4 MB" (size from `UpdateInfo.sizeBytes`), actions "Senare" (text) and "Uppdatera" (tertiary filled, `download`); offline: "Uppdatera" disabled and the body reads "Kräver anslutning". `Downloading`: Lantern, `downloading`, "Laddar ner… 64 %", `LinearWavyProgressIndicator` in `tertiary` (determinate, track `tertiary` at 28 %), no actions; TalkBack progress "Laddar ner version 1.2, 64 procent". `Failed`: Error, `error`, "Nedladdningen misslyckades. Försök igen." with "Senare" and "Försök igen" (error filled, `refresh`). `NeedsInstallPermission`: Available look; "Uppdatera" opens the S-17 dialog.
**`NeedsConnectionBanner(body: String)`**: Warning banner, `cloud_off`, title "Kräver anslutning", screen-specific body. **`NeedsConnectionHint`**: supporting line under a control: `cloud_off` 16 + "Kräver anslutning" in body-small weight 600 `on-surface-variant`, padding 4 / 16.
### 3.7 Expense editor fields (D-019; used by S-7 and S-19)
**`AmountDisplay(text: String, focused: Boolean, error: String?)`**
* Centred column, padding 10 top / 6 bottom: label "Belopp" (label-medium muted; `error` colour on error) above the amount (`amount-hero`, tabular) with a 3 dp `primary` caret when focused, then "kr" (headline-small muted) on the same baseline; wraps the unit under the number when it does not fit.
* Empty shows "0,00" in `on-surface-variant` as a hint, not a value.
* The text is what the user typed, grouped with U+00A0 thousands separators while typing ("1 060", "1 060,5"); the decimal separator is the locale's (`,` sv, `.` en).
* Semantics: "Belopp, 412 kronor" (empty: "Belopp, 0,00", the hint `editor_amount_hint`); error appended. Tapping it focuses it and shows the keypad.
**`AmountKeypad(onKey, onDelete, onClear, compact: Boolean)`**
* Sheet at the bottom of S-7 and S-19: `surface-container`, top corners 28, padding 12 / 12 and bottom = 8 + navigation-bar inset; 3-column grid, gap 8.
* Keys `1`–`9`, decimal key, `0`, delete. Each key: min height 52 (46 in `compact`, used while the split section is open), radius full, `surface-container-highest`, label 24 sp weight 600 (capped 1.5×). Delete key: `primary-container` fill, `backspace` 26 in `on-primary-container`.
* Decimal key shows `,` (sv) or `.` (en); TalkBack "Decimaltecken". Delete: TalkBack "Radera" (`editor_keypad_delete`); long-press or the TalkBack custom action "Rensa" clears the field.
* Input rules per §9.6, enforced key by key: digits; one decimal separator (a second is ignored); at most two decimals (a third digit is ignored); no sign; a value above 10 000 000,00 kr is not accepted (the key is ignored with a reject haptic); a leading `0` followed by a digit replaces the `0`; decimal first gives "0,". Parsing to öre is string-exact (never floating point).
* Each key gives `HapticFeedbackType.KeyboardTap`. The keypad drives the editor's money and percent fields: the amount, the exact-share field and the two ratio percent fields (percent: max 100, two decimals).
* Visibility: shown while a keypad field has focus; hidden while the description or notes field has focus (the system keyboard shows instead) or nothing has focus. Back with the keypad visible clears focus and hides it (it does not close the editor).
* 200 %: keys grow with their labels; the keypad keeps its four rows and the content above scrolls.
**`DescriptionField`**: filled text field (radius 16, `surface-container-highest`, no indicator, 2 dp `primary` inset outline when focused, 2 dp `error` on error), label "Beskrivning", single line, 1–100 code points, capitalisation `Sentences`, IME action `Done` (§4 S-7). Error supporting text with filled `error` icon.
**`CategoryQuickRow(options, selected: CategoryId, onPick, onAll)`** (UX-3)
* A 6-column grid, gap 4: five category items and "Alla". Item: `CategoryTile` 52 (Container style; Solid when selected) above a label (11 / 14 sp weight 500 `on-surface-variant`, up to 2 lines, centred, no ellipsis; selected: `on-surface` weight 700).
* Selected tile: solid, plus a check badge: 20 dp circle in `on-surface` with `check` (14, filled, weight 700) in `surface`, offset to the tile's top-right (−6 / −8 dp), with a 2 dp `surface` ring.
* "Alla": tile in `surface-container-highest` with `apps` 26 in `on-surface-variant`, label "Alla"; opens `CategoryGridSheet`. TalkBack "Alla kategorier".
* **Which five**: the five active categories used most often in the selected account (count of non-deleted expenses on the phone, ties by `sort_order`), filled up from `sort_order` when fewer than five have been used; shown in that order. If the selected category (suggested, default or picked in the sheet) is not among them, it takes the fifth place. The order does not change while the editor is open except for that fifth place. The usage list is `ExpenseEditorOptions.frequentCategoryIds` (UX-9, pending); until it exists the five are the selected category followed by the first active categories in `sort_order`.
* 200 % (font scale ≥ 1.5): 3 columns, two rows, row gap 12.
* Semantics: radio group "Kategori"; each item "Nöje, vald" / "Mat & hushåll"; picking sets the editor's "picked by hand" flag (CS-2).
**`CategoryGridSheet`**: modal bottom sheet "Välj kategori": all active categories in `sort_order`, 4 columns (3 at ≥ 1.5×), items as in the quick row; tapping picks and closes the sheet.
**`EditorChip`** (the chips row under the category row): `FlowRow`, gap 6. Chip: min height 36 (48 target), radius full, `surface-container-high`, padding 4 / 10 / 4 / 8, label 14 / 18 sp weight 500 with an optional key prefix in `on-surface-variant` weight 600 ("Betalt av ", "Delning "), leading 18 dp element, trailing 18 dp icon. Selected (the split section is open): `secondary-container`. Chips:
| Chip | Leading | Label | Trailing | Tap |
|---|---|---|---|---|
| Konto | 12 dp dot in the account's chart colour | "Vardag" | `expand_more` | opens `AccountPickerSheet` |
| Datum (S-7 only) | `event` | "Idag" / "Igår" / "14 okt." (§9.8 list format) | `expand_more` | Material `DatePickerDialog`, range per §9.6 |
| Betalt av | payer avatar 24 | "Betalt av Du" / "Betalt av Anna" | `swap_horiz` | toggles between the two users (AC-8.3), no picker |
| Delning | `call_split` | "Delning Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna" | `expand_more` / `expand_less` | opens or closes `SplitSection` in place and scrolls it into view |
TalkBack: "Konto: Vardag. Dubbeltryck för att byta konto"; "Datum: idag"; "Betalt av: du. Dubbeltryck för att byta till Anna" (`editor_paid_by_toggle`); "Delning: kontots standard 50/50".
**`AccountPickerSheet`**: modal bottom sheet "Välj konto": active accounts in created order (AC-6.4: archived not offered), each row `AccountTile` 40 + name + the account's wording and amount (body-small, coloured), check on the selected one; tap picks and closes.
**`SplitBar(viewerPercent: Float, thin: Boolean, error: Boolean, knob: Boolean)`**: two rounded segments (radius full) separated by a 3 dp gap (6 dp with a knob): left = viewer in the viewer's avatar colour, right = partner in the partner's avatar colour, widths proportional; height 16 (8 thin); `error`: left segment `error`. Decorative next to printed numbers; otherwise `contentDescription` "Du 60 procent, Anna 40 procent".
**`SplitSlider`** (S-6, ratio mode): `SplitBar` 16 dp with a knob (4 × 44 dp, radius 2, `on-surface`) at the split point; drag in steps of 1 percentage point with a haptic tick at 50; semantics slider labelled with the left person's share ("Jacobs andel", `editor_ratio_slider`) and value text "Jacob 60 procent, Anna 40 procent" (`editor_ratio_spoken`) and ± 1 adjust actions.
**`SplitPreview(shares: List<Share>?)`**: a 72 dp thin `SplitBar` then body-medium "Du **206,00 kr** · Anna **206,00 kr**" (amounts weight 700, tabular). Data only from `ExpenseRepository.preview` (the UI never computes shares); hidden while it returns null. Semantics "Du 206,00 kronor, Anna 206,00 kronor".
**`SplitSection(mode, …)`**: flat section (`surface-container-low`, radius 28, padding 16, gap 12):
* Header row: "Delning" (title-small) + `expand_less` icon button (TalkBack "Fäll ihop delning").
* Mode chips: four single-select filter chips, wrapping: "Kontots standard 50/50" (live ratio of the selected account, `editor_split_mode_default`), "Egen fördelning", "Exakta belopp", "Allt på en person"; selected = `secondary-container` with `check`. Semantics radio group "Delning".
* `default`: `SplitBar` 16 + `SplitPreview` text.
* `ratio` (`RatioEditor`): the two names with avatars 32 at either end, `SplitSlider`, two percent fields side by side ("Jacob" / "Anna", suffix "%", keypad-driven, two decimals, typing one sets the other to 100 minus it), presets as a `ButtonGroup` "40/60 · 50/50 · 60/40" (viewer first), then `SplitPreview`.
* `exact` (`ExactSplitEditor`): one row per person: avatar 40 + money field (label = name, suffix "kr", value `amount-medium`). The viewer's field is typed by default; the other is read-only (transparent fill, 1 dp `outline-variant` border) showing "beloppet minus …" as the computed value, or "—" while it cannot be computed, with trailing caption "räknas ut". Tapping the read-only field makes it the typed one (AC-8.7: one typed, the other derived). Typed share > amount: field in error with "Andelen är större än beloppet" (`error_exact_share_too_large`) and an `error-container` box "Jacob 1 200,00 kr av 1 060,00 kr" / "140,00 kr för mycket" (title-small); Spara disabled.
* `full` (`FullSplitPicker`): label "Vem står för hela beloppet?" (label-large muted), a two-button `ButtonGroup` (min height 52; avatar 28 + name each), `SplitBar` 16 fully in the bearer's colour, `SplitPreview`.
**`NotesField`**: collapsed as a text button "Lägg till anteckning" (`notes` icon); expanded: filled multi-line field "Anteckningar", 3–8 visible lines, 0–1000 code points, newlines allowed, counter "120 / 1000" as supporting text.
**`CompactEditorSummary`**: when the `AmountDisplay` has scrolled out of view, a row pinned under the top app bar: `CategoryTile` 36 + description (title-medium, ellipsis) over the category name (body-small muted) + `MoneyText(Medium)`; padding 6 / 20 / 10; 1 dp `outline-variant` bottom border. Tap scrolls to the top and focuses the amount.
### 3.8 Settle up components (S-9, S-10)
**`DirectionCard(payer, recipient, onSwap)`**: card, padding 14 / 14 / 14 / 18, gap 12: payer avatar 40 (Sunny) → `arrow_forward` (on-surface-variant) → recipient avatar 40; label "Riktning" (label-medium muted) over "Du betalar Anna" / "Anna betalar dig" (title-medium); trailing tonal icon button `swap_horiz` (TalkBack "Byt riktning", `settle_swap`). Semantics: card text "Riktning: du betalar Anna" + the button.
**`AllocationRow(line: AllocationLine, colorIndex, fixed: Boolean, onAmount, onRemove)`**: `SegmentedGroup` item, padding 12 / 14, gap 8:
* Line 1: `AccountTile` 40 · name (title-medium, ellipsis) over the payer's position in body-small muted: "Skuld 3 084,50 kr" (the payer owes there, `settle_row_debt`); otherwise the account balance wording from the payer's side, "Anna är skyldig dig 1 184,50 kr" (`balance_*_amount`), or "Kvitt" (`balance_account_settled`) · amount field (148 dp wide, min height 48, radius 14, `surface-container-highest`, right-aligned `amount-medium` + "kr"; system decimal keyboard) · remove icon button `close` (TalkBack "Ta bort Huset från fördelningen", `settle_row_remove`). `fixed`: the amount is plain `MoneyText(Medium)` (it follows the settlement amount) and there is no remove button.
* Over-settle (`overSettles`): field border 2 dp `warning`; under line 1 a strip (`warning-container`, radius 12, padding 8 / 12): filled `warning` 20 + "Det här reglerar mer än som är skyldigt i Japan 2027" (`settle_over_settle_warning`).
* 200 %: name and position on line 1, the amount field full width on line 2, the remove button at the end of line 1.
**`RemainingIndicator(remainingOre)`**: row, radius 16, padding 10 / 14, gap 10: zero → `surface-container-high`, filled `check_circle` in `owed`, "Kvar att fördela" + "0,00 kr"; positive → `warning-container`, filled `error`, "Kvar att fördela" + "250,00 kr"; negative → `warning-container`, filled `error`, "Kvar att fördela" + "250,00 kr för mycket" (`common_too_much`). Words always say which; semantics "Kvar att fördela 250,00 kronor". Live region polite.
### 3.9 Timeline (S-8, S-10)
**`Timeline(lines: List<TimelineLine>)`**: header "Historik" (title-small, `timeline_title`) then a card (padding 16 / 16 / 16 / 12) with items oldest first, newest last (AC-12.4):
* Item: a 24 dp gutter with a 12 dp dot in `secondary` and a 2 dp `outline-variant` connector to the next item; text body-medium. Sentence per `FieldChange`: "Anna skapade den här · 16 okt. 14:02"; "Du ändrade beloppet från 1 147,60 kr till 1 247,60 kr · 16 okt. 19:40"; "Anna ändrade kategorin från Övrigt till Mat & hushåll · 16 okt. 20:05"; "Anna tog bort den här · 17 okt. 09:12"; recurring source "Skapad automatiskt (återkommande) · 25 sep. 06:00"; import source "Importerad från Splitwise · …". Several changes in one entry are one line each with the same time. Values formatted with `MoneyFormatter`/`DateFormatter`; field names from `timeline_field_*`.
* `pendingLocal`: hollow dot (2 dp `pending` ring), text in `pending` weight 600 with a 16 dp `cloud_upload` before it: "Din ändring väntar på synk" (AC-12.5); always last.
* Semantics: each item one node; the header is a heading.
### 3.10 Charts (S-11; Vico or the Canvas fallback, §6.2)
**`ChartCard(title, content)`**: card, padding 18 / 16 / 16, title title-medium; an invisible summary sentence as `contentDescription` of the chart area.
**`StackedBarChart(buckets, groups, onBucket)`**
* Plot 170 dp high, y-axis gutter 44 dp on the left: gridlines (1 dp `chart-grid`) at 3–4 round values with labels in whole kronor (10 / 12 sp, `chart-axis-label`, sv "15 000", en "15,000"); x labels under each bar (11 / 14 sp: "maj", "jun" or the week number "41" with the caption "Vecka" under the axis).
* One bar per bucket (every bucket of the range, empty ones as zero, AC-14.7), gap 10, top corners 10, bottom 4; segments stacked in legend order with 2 dp gaps; segment colour per group (category colour or account chart colour).
* More buckets than fit at a minimum bar width of 16 dp: horizontal scroll, scrolled to the newest bucket on first show. At 200 %, x labels are drawn for every second bucket when they would overlap.
* Tap a bar → `onBucket(key)` (S-12). Each bar is a focusable semantics node: "September, 41 484 kronor, Resor största kategori".
* Chart summary (TalkBack): "Utgifter över tid, <range>: högst i september med 41 484 kronor, lägst i maj med 24 710 kronor" (`analytics_summary_over_time`).
**`ChartLegend(groups)`**: wrapping row, gap 4 / 12; 10 dp swatch (radius 3) + label (12 / 16 sp weight 500 `on-surface-variant`). Only groups with data. Decorative for TalkBack (the bars speak their totals).
**`BreakdownList(rows, total, onRow)`** (AC-14.6 charts 2 and 3): rows sorted by amount descending, padding 8 / 0, gap 6: tile 32 (`CategoryTile` or `AccountTile`) + name (title-small, 1 line, ellipsis; "Arkiverat" badge for archived accounts) + right column `MoneyText(Small)` over the percent (body-small muted, one decimal, half-up, "34,8 %", AC-14.10); below, a 10 dp bar track (`surface-container-highest`, radius full) indented 44 dp, filled to amount / largest amount in the group colour. Row is one node "Boende, 62 000 kronor, 34,8 procent"; click → `onRow(id)`.
**`AnalyticsFilterBar(filter, onChange)`**: row 1: period chip (`date_range`, label "Senaste 6 mån" / "Anpassad · 1–30 nov.", `expand_more`; TalkBack "Period: senaste 6 månaderna") + `ButtonGroup` "Månad | Vecka" (min width 170, fills the rest); row 2: `ButtonGroup` "Hushållet | Min del" and `ButtonGroup` "Kategori | Konto", half width each. Connected buttons: min height 40, label-large weight 600, unselected `surface-container-high` / `on-surface-variant`, selected `secondary` / `on-secondary` with full radius. 200 %: one control per row. Semantics: each group a radio group named "Indelning", "Mått", "Gruppera efter".
**`TotalCard(totalOre, caption)`**: card, padding 16 / 20: "Totalt" (label-large muted; "Totalt · Min del" for my share) over `MoneyText(Large)` over the range caption (body-small muted, "maj–okt 2026 · Hushållet").
### 3.11 Scaffolding and common controls
**`KvittScaffold`**: edge-to-edge; top app bar slot, content, optional FAB, optional bottom bar, snackbar host above the FAB and navigation bar; status-bar inset handled by the app bar.
**`KvittTopAppBar(title, navigation: Back | Close | None, actions)`**: 64 dp, `surface` (scrolled: `surface-container`), navigation icon button 48 (`arrow_back` TalkBack "Tillbaka" / `close` TalkBack "Avbryt"), title title-large 1 line ellipsis (pad 16 when there is no navigation icon), optional subtitle body-small muted. Top destinations (S-4) show the wordmark instead of a title: a 22 dp Sunny `primary` dot with a filled `receipt_long` (15) in `on-primary`, then "Kvitt" 24 sp weight 700 `primary` (capped 1.5×); semantics heading "Kvitt".
**`KvittNavigationBar(selected)`**: Expressive `ShortNavigationBar` (fallback `NavigationBar`), `surface-container`, items 64 dp high: indicator pill 56 × 32 (`primary-container` when selected) with the icon (filled when selected), label label-medium (selected: `on-surface` weight 700) capped at 1.6×. Items: "Översikt" `home`, "Statistik" `bar_chart`, "Inställningar" `settings` (`nav_*`). Labels always visible.
**`AddExpenseFab(expanded: Boolean)`**: medium extended FAB, 56 dp, radius 20, `primary` / `on-primary`, `add` 24 + "Lägg till utgift" (16 / 22 sp weight 600), 16 dp from the right edge and above the navigation bar; collapses to the 56 dp icon-only FAB while the list scrolls down and at font scale ≥ 1.5; TalkBack "Lägg till utgift" in both forms. Same component with "Ny återkommande" on S-19.
**Buttons**: radius full, label-large weight 600, icon 20. Small 40 dp (padding 8 / 20), medium 56 dp (padding 12 / 26, 16 sp, icon 24). Kinds: filled (`primary`), tonal (`secondary-container`), tertiary (`tertiary`), outlined (1 dp `outline-variant`, `on-surface-variant`), text (`primary`), danger text (`error`), error filled (`error` / `on-error`).
**Badge**: radius full, padding 2 / 10, label-small weight 600; neutral (`surface-container-highest` / `on-surface-variant`), lantern (`tertiary-container`), pending (`pending-badge` / `pending`). Icon 14.
**`SectionHeader(title, action)`**: min height 48, padding 8 / 4 / 0 / 8, title title-large (heading semantics), optional trailing text button.
**`CollapsibleRow(title, count, expanded)`**: min height 48, padding 6 / 8, `inventory_2` + "Arkiverade (1)" (title-small) + `expand_more`/`expand_less`; semantics button with state "utfälld"/"hopfälld".
**`FactRow(icon, label, value)`** (detail facts in S-8, S-10, S-22): `SegmentedGroup` item, min height 56, padding 10 / 16, gap 14: outline icon in `on-surface-variant`, label (label-medium muted) over value (body-large). Merged node "Datum: fredag 16 oktober 2026".
**`SettingsRow(icon, color: ColorToken, title, subtitle, enabled)`**: `SegmentedGroup` item, min height 56: tile 40 (Cookie4, `cat-<color>-container` fill, icon 22 in `cat-<color>`), title (title-medium) over subtitle (body-small muted), trailing `chevron_right`. Merged node, role button.
**`EmptyState(illustration, title, body, action)`**: flat section (`surface-container-low`, radius 28, padding 28 / 24), centred, gap 10: decorative illustration (three rotated category tiles, ~170 × 110 dp), title headline-small, body body-large muted, optional medium button.
**`ConfirmDialog(icon, title, body, confirm, dismiss, destructive)`**: `surface-container-high`, radius 28, padding 24, gap 14, 24 dp from the screen sides; centred 52–56 dp Sunny icon tile (destructive: `error-container` / `on-error-container`; otherwise `secondary-container` or `tertiary-container`); title headline-small centred; body body-medium muted; buttons right-aligned (dismiss text, confirm filled or error filled). Scrim 38 %.
**`KvittMenu`**: `surface-container`, radius 16, padding 6, min width 244, max 300, level 3. Items min height 48, padding 12, radius 12, icon + body-large; a disabled item shows its reason as a second line (body-small) at 50 % opacity and is still announced with that reason.
**`Snackbar`**: `inverse-surface`, text `inverse-on-surface`, action `inverse-primary`, radius 16; 4 s without an action, 10 s with one.
**`InviteCodeDisplay(code)`**: three groups of four on `surface` chips (radius 14, padding 6 / 10), 24 / 32 sp weight 700, letter spacing 1.5, wrapping centred; selectable. TalkBack spells it per group: "7 K 3 M, Q X 9 D, 2 H P A".
**`InviteCodeField`**: filled field "Inbjudningskod", value title-large, uppercase, inserts the hyphens and normalises per §8.3 while typing (`O` → `0`, `I`/`L` → `1`), keyboard `Ascii` with `Characters` capitalisation; trailing "Klistra in" text button (`content_paste`), which also accepts a full invite link and fills the server from it.
**`Skeleton`**: `surface-container-highest` blocks, radius 12 (pills full), no shimmer under reduced motion; only for the first-sync and online-fetch loading states.
---
## 4. Screens
Each screen: layout top to bottom, the **binding states** (exactly the frames of the screen catalogue; PNG `screens/<frame-id>-light.png` and `-dark.png`, "light only" where noted), interactions, TalkBack, and 200 %. Modules and navigation are in `ia.md`.
### S-1 Pairing (`:feature:onboarding`)
**Layout** (no app bar, no navigation; scrolling column, gutter 16, gap 14; bottom bar with the button):
1. Brand block, centred, padding 28 / 0 / 12: app mark 88 dp (Sunny, `primary`, filled `receipt_long` 44 in `on-primary`), "Kvitt" (display-small, `primary`), "Delade utgifter för två" (title-medium muted, `common_tagline`).
2. `InviteCodeField` with "Klistra in".
3. "Ditt namn" field (UX-4; 1–40, §9.6; capitalisation `Words`), required on a first pairing.
4. Help line: `info` 20 + body-medium muted "Koden får du av Jacob: från en länk eller från Inställningar › Bjud in partner." (`onboarding_code_help`; the partner's own name is unknown before pairing, so the generic `onboarding_code_help_generic` is used when the app was not opened from a link).
5. Server row, collapsed (`surface-container-low`, radius 16, min height 56): `dns` + "Serveradress" (label-medium muted) over the URL (body-medium, wraps anywhere) + `expand_more`; tap expands it into an editable field (keyboard `Uri`). It opens expanded when a link carried a server different from the release default.
6. Bottom bar, padding 12 / 16: "Para ihop" (filled medium 56, full width, `link` icon).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-1-pairing` | loaded | opened from Anna's invite link: code "7K3M-QX9D-2HPA" and server filled, "Ditt namn" = "Anna", nothing focused, "Para ihop" enabled |
| `S-1-pairing-loading` | loading | button shows a 20 dp progress indicator in `on-primary` and "Parar ihop…" (`onboarding_pairing`) and is not clickable; all fields read-only (transparent fill, 1 dp `outline-variant` border); "Klistra in" hidden |
| `S-1-pairing-error` | error | `invite_invalid`: the code field in error with "Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny." (`onboarding_error_invite_invalid`) under it; button enabled again |
| `S-1-pairing-again` | variant Unrecognised (§14.4.8; viewer Jacob) | the tagline is replaced by "Para ihop telefonen igen" (headline-small) and "Dina utgifter finns kvar på telefonen." (body-large muted, `onboarding_again_*`); no "Ditt namn" field; generic help line |
**Interactions**
* Code typing: uppercase, hyphens inserted after 4 and 8, normalised per §8.3; "Para ihop" enabled when the code has 12 characters, the name is non-blank (first pairing) and the server is a valid URL. IME `Next` from code to name, `Done` on the name runs "Para ihop".
* "Klistra in": reads the clipboard; a 12-character code or an invite link (`kvitt://invite?…` or `https://…/i/<CODE>`) fills code (and server).
* Other errors, all inline under the field at fault, button enabled again: network "Kunde inte nå servern. Kontrollera adressen och anslutningen." (under the server row, which expands); `rate_limited` "För många försök. Vänta en stund och försök igen." (under the code); `http://` in release (AC-2.8) "Adressen måste börja med https://" (server). Offline: the button stays enabled and gives the network error (pairing never queues).
* Success: `pair(serverUrl, code, displayName, deviceName = Build.MODEL)` → partner pairing goes to S-2 (AC-2.10), otherwise S-4 in its loading state; after the first full sync the system notification-permission prompt (AC-2.9), preceded by nothing (the rationale string `home_notification_rationale` is used only if Android asks for a rationale).
**TalkBack**: the paste button reads its text "Klistra in" (`onboarding_code_paste`); the code field reads the value group by group; the server row "Serveradress, https://icehack.asuscomm.com:8443, dubbeltryck för att ändra"; errors are announced (live region assertive).
**200 %**: single column scrolls; the code value wraps after a group; the brand block's title grows ~1.5×; the bottom button stays pinned.
### S-2 Partner profile step (`:feature:onboarding`)
**Layout** (app bar without title or navigation; column gap 12; bottom bar):
1. Centred intro, padding 24 / 4 / 4: Jacob's avatar 72 (Sunny), "Du går med i Jacobs hushåll" (headline-small, `onboarding_profile_joining`), "Så här ser Jacob dig i Kvitt. Du kan ändra det senare under Inställningar › Profil." (body-large muted).
2. Card, padding 22 / 20, centred, gap 14: own avatar 112 (Sunny; initials on the own slot colour until a picture exists) with a 40 dp `primary` circle badge holding `add_a_photo` 20 at its bottom-right (3 dp `card` ring); "Lägg till bild" tonal button (`photo_library`; "Byt bild" when a picture exists, plus a text button "Ta bort bild"); "Ditt namn" field prefilled from the pairing call.
3. Bottom bar: "Fortsätt" (filled medium, full width).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-2-profile-step` | loaded | Anna's initials, name "Anna" focused |
| `S-2-profile-step-loading` | loading | a picture was picked and is uploading: the avatar shows it at 60 % with the progress ring (§3.3); "Byt bild" and "Fortsätt" disabled until the upload ends |
| `S-2-profile-step-error` | error | upload refused or network: snackbar "Bilden kunde inte användas" (`profile_picture_failed`, with the reason for `payload_too_large` / `unsupported_media_type` / `image_invalid`, or "Kunde inte nå servern"); avatar back to initials; "Fortsätt" enabled |
**Interactions**: "Lägg till bild" opens the Android photo picker (images only, AC-4.3); upload starts immediately (`setPicture`). "Fortsätt" saves the name if changed (`setDisplayName`; failure → snackbar, stays) and goes to S-4; blank name → inline "Skriv ett namn" and "Fortsätt" disabled. Back is not offered (the pairing is done).
**TalkBack**: the avatar badge is part of the button "Lägg till bild"; avatar while uploading "Laddar upp bilden".
**200 %**: column scrolls, avatar stays 112.
### S-3 Waiting for partner (`:feature:onboarding`, shown in the Home destination)
**Layout** (wordmark app bar; bottom navigation with Översikt selected; no FAB):
1. Hero card in the `Waiting` state (§3.6), padding 28 / 22 / 24: seesaw with the empty seat, "Väntar på din partner" (headline-small), "Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk." (body-large muted), "Bjud in partner" (filled medium, `person_add`).
2. When an invite is pending: a one-item group: `schedule` + "Inbjudan skickad" (title-small) over "Inbjudan gäller till mån 19 okt. 21:40" (`onboarding_waiting_invite_expires`).
3. Body-medium muted line: "Konton, utgifter och betalningar blir tillgängliga när din partner har gått med."
**States**
| Frame | State | What differs |
|---|---|---|
| `S-3-waiting` | loaded | pending invite row shown |
| `S-3-waiting-offline` | offline | "Bjud in partner" disabled with `NeedsConnectionHint` "Kräver anslutning" under it; the pending invite row is not shown (invites are online only) |
**Interactions**: "Bjud in partner" → S-21 (it creates a `partner` invite there). When the partner joins, the next sync replaces this content with S-4 (empty). Statistik shows its empty state; Inställningar works.
**TalkBack**: hero one node "Väntar på din partner. Kvitt behöver er båda innan ni kan börja."; the seesaw is decorative.
### S-4 Home (`:feature:home`)
**Layout** (wordmark app bar; column gap 12, bottom padding 120; bottom navigation; FAB):
1. `UpdateBanner` when `HomeState.update` / `UpdateState` is not `None` (S-17).
2. `BalanceHero` from `HomeState.global`.
3. `SyncStatusPill` from `HomeState.sync` and connectivity.
4. `SectionHeader` "Konton" with "Nytt konto" (text button, `add`).
5. `AccountCard` per active account, created order (AC-6.6).
6. `CollapsibleRow` "Arkiverade (n)" when archived accounts exist; expanded in place to their `AccountCard`s (AC-6.4); collapsed by default, state kept for the session.
7. `AddExpenseFab`.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-4-home` | loaded | "Du är skyldig Anna 3 250,00 kr"; Vardag, Huset, Japan 2027; "Arkiverade (1)" collapsed |
| `S-4-home-settled` | variant settled + archived expanded | hero `Settled` with bunting, accounts "Kvitt", archived Gotland 2026 visible with badge; no "Gör upp" |
| `S-4-home-empty` | empty | partner joined, no accounts: a card with both avatars (44, overlapping by 10) "Du och Anna är med" / "Hushållet är klart. Nu behövs ett konto."; then `EmptyState` (pile of three solid tiles: groceries, travel, restaurants) "Skapa ert första konto", "Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.", button "Nytt konto" (medium, `add`); no sync pill, no FAB (AC-8.6 needs an account) |
| `S-4-home-loading` | loading (first sync only) | hero `Loading`, a 40 × 200 dp pill skeleton, "Konton" header without action, three skeleton account cards; no FAB |
| `S-4-home-offline` | offline + update banner | pill "Offline · 2 ändringar väntar på synk"; "Nytt konto" disabled with "Kräver anslutning" as its supporting text; banner `Available` with "Uppdatera" disabled ("Kräver anslutning") |
| `S-4-home-200` | long, 200 %, light only | app bar `surface-container` (scrolled 90 dp), hero amount ~1.5× with "kr" on its own line, account amounts under the names, FAB icon-only |
| `S-4-home-en` | English, light only | "You owe Anna", "SEK 3,250.00" (unit before the number), "Synced 3 min ago", "Accounts", "New account", "Add expense" |
**Interactions**
* FAB → S-7 new (account per AC-8.6). Hidden while no active account exists (only archived accounts: hidden too, UX-6).
* "Gör upp" → S-9 global. Account card → S-5. "Nytt konto" → S-6 (offline: disabled). Pill → S-16. Banner per S-17.
* Returning from S-7 shows the S-7 snackbar here; from S-9 "Betalningen sparades" (`snackbar_payment_saved`).
* After the first full sync after pairing, the notification permission is asked once (AC-2.9).
* Pull-to-refresh is not offered (sync runs on its own triggers, AC-15.3).
**TalkBack**: hero as §3.6; pill as §3.6; account cards as §3.4; "Arkiverade (1), hopfälld"; FAB "Lägg till utgift"; navigation items "Översikt, flik 1 av 3, vald".
**200 %**: see `S-4-home-200`; every element reflows, nothing truncates except account names (two lines then ellipsis).
### S-5 Account detail (`:feature:account`)
**Layout** (app bar: back, account name, `more_vert`; column gap 12; FAB):
1. `AccountBalanceHeader`.
2. Ledger: per month a `LedgerMonthHeader` and a `SegmentedGroup` of `ExpenseRow` / `SettlementRow` in AC-8.10 order; settlement amounts are the allocation to this account (AC-11.7).
3. `AddExpenseFab` (account preset to this one); hidden for archived accounts (UX-6).
Overflow menu (`KvittMenu`): "Ändra konto" (`edit`) → S-6; "Arkivera" (`inventory_2`) or "Återställ från arkiv" (`unarchive`); "Ta bort konto" (`delete`) enabled only when `deletable`, otherwise disabled with "Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter" (`account_delete_blocked`).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-5-account` | loaded | Vardag: "Anna är skyldig dig 1 184,50 kr", October and September groups, the SL-kort row pending |
| `S-5-account-menu` | variant: overflow menu open | menu over the content; "Ta bort konto" disabled with its reason |
| `S-5-account-archived` | variant archived | Gotland 2026: "Arkiverat" badge, "Kvitt", "Återställ från arkiv" (tonal) in the header, no FAB |
| `S-5-account-empty` | empty | new account "Sommarstugan": header "Kvitt" without a settle button; `EmptyState` "Inga utgifter än" / "Lägg till den första utgiften i Sommarstugan." (no illustration, no button); FAB shown |
| `S-5-account-offline` | offline | menu open: all three items disabled, each with "Kräver anslutning" as its second line; rows, "Gör upp det här kontot" and FAB work |
| `S-5-account-error` | error | after "Arkivera" while the server is unreachable: snackbar "Kunde inte nå servern" with "Försök igen"; account unchanged |
| `S-5-account-long` | long | "Sommarstugan i Österåker 2027" ellipsized in the app bar; rows with long descriptions ellipsized at one line; an amount of 1 234 567,89 kr and a month total of 7 digits shown in full |
**Interactions**
* Row → S-8 / S-10. "Gör upp det här kontot" → S-9 per account. FAB → S-7 with this account.
* "Arkivera" / "Återställ från arkiv": no confirmation (reversible); success → back to S-4 with snackbar "Vardag arkiverades" + "Ångra" (`account_archived_snackbar`; "Ångra" unarchives) / stays with "Vardag återställdes"; network failure → `S-5-account-error`.
* "Ta bort konto": `ConfirmDialog` (destructive) "Ta bort kontot Vardag?" / "Det går inte att ångra." → "Ta bort"; success → S-4; `account_not_empty` → snackbar "Kontot har fortfarande utgifter, betalningar eller återkommande utgifter".
**TalkBack**: overflow "Fler alternativ"; header one node (§3.6); month headers are headings; rows §3.5; disabled menu items read with their reason.
**200 %**: header wording and amount wrap; rows per §3.5 (amount under the text).
### S-6 Account editor (`:feature:account`)
**Layout** (app bar: close, "Nytt konto" / "Ändra konto", "Spara" small filled; column gap 14):
1. Name field "Namn" (1–60, focused on open for a new account; capitalisation `Sentences`).
2. Card "Standarddelning", padding 18, gap 14: subtitle "Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning." (`account_editor_split_hint`; this line also carries AC-6.3 when editing); the two people (avatar 32 + name, viewer left); `SplitSlider`; two percent fields ("Jacob", "Anna", suffix "%", system decimal keyboard, two decimals, the other follows as 100 minus); `ButtonGroup` presets "40/60 · 50/50 · 60/40"; preview box (`surface-container-high`, radius 16, padding 12 / 14, `calculate` icon): "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" (`account_editor_preview`).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-6-account-editor` | loaded | new "Sommarstugan", 60 / 40 |
| `S-6-account-editor-offline` | offline | `NeedsConnectionBanner` "Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot." on top; field, card (45 % opacity) and "Spara" disabled |
| `S-6-account-editor-error` | error | `name_taken`: name field in error with "Det finns redan ett konto som heter Vardag" (`account_editor_name_taken`); "Spara" enabled again |
**Interactions**: "Spara" enabled when the name is non-blank and the percentages sum to 100; shows a progress indicator inside the button while saving; success → back (new: to S-5 of the new account; edit: to S-5); network → snackbar "Kunde inte nå servern" + "Försök igen". Close with changes → `ConfirmDialog` "Slänga ändringarna?" "Släng" / "Fortsätt redigera".
**TalkBack**: slider "Jacobs andel, Jacob 60 procent, Anna 40 procent" (`editor_ratio_slider`, `editor_ratio_spoken`); preset buttons "60 procent du, 40 procent Anna".
**200 %**: percent fields stack vertically; presets wrap.
### S-7 Expense editor (`:feature:expense`)
Full screen (§13.2), works offline (AC-8.2). The flow and the category rule are in §5.
**Layout** (app bar: close, "Ny utgift" / "Ändra utgift", "Spara" small filled with `check`; scrolling column gap 12, padding 16 bottom; keypad at the bottom while a keypad field has focus):
1. `AmountDisplay` (focused on open for a new expense).
2. `DescriptionField` "Beskrivning".
3. `CategoryQuickRow` (five tiles + "Alla").
4. Chips row: Konto · Datum · Betalt av · Delning (§3.7).
5. `SplitPreview` (when the split section is closed).
6. `SplitSection` (when open: after a tap on Delning, or always when the mode is not `default` in edit mode).
7. `NotesField` ("Lägg till anteckning", or the field when notes exist).
8. Edit mode: "Ta bort utgift" (danger text button, `delete`) at the end.
9. `AmountKeypad`; `CompactEditorSummary` pinned under the app bar when the amount has scrolled away.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-7-editor` | loaded (new, ten-second flow) | "412", "Systembolaget", category Nöje selected by the suggestion (no caption, CS-7), Vardag, Idag, Betalt av Du, Kontots standard 50/50, preview "Du 206,00 kr · Anna 206,00 kr", keypad up |
| `S-7-editor-split` | variant: exact split with error | compact summary row (Ullared, Shopping, 1 060,00 kr); Delning chip selected; split section open with "Exakta belopp"; Jacob's field 1 200,00 kr in error, Anna's read-only "—"; error box "140,00 kr för mycket"; compact keypad; "Spara" disabled |
| `S-7-editor-full` | variant: full split + notes (edit mode) | "Ändra utgift", SL-kort 30 dagar, Anna, Transport, 14 okt., "Allt på en person" with Anna as bearer, notes expanded, "Ta bort utgift" |
| `S-7-editor-edit` | variant edit mode | "Ändra utgift" for ICA Maxi Lindhagen (1 247,60 kr, Mat & hushåll, Vardag, 16 okt., Betalt av Anna); nothing focused, keypad hidden; "Ta bort utgift" visible at the end |
| `S-7-editor-200` | long, 200 %, light only | amount and "Spara" visible, quick row in 3 columns, the middle scrolls, keypad keys grow |
**Interactions**
* Defaults (new): account per AC-8.6; category per §5 (UX-3: suggestion, falling back to AC-8.6); date today; payer the viewer (AC-8.3); mode `default`. From S-5 the account is that account.
* Changing the account: mode `default` re-copies the account's ratio (AC-8.8); the suggestion re-runs (CS-3) unless the category was picked by hand.
* "Spara" enabled when the amount is > 0, the description is non-blank and `preview` is not null; tap → `save`. `SaveResult.Invalid` maps each `FieldError` inline: amount `required` "Skriv ett belopp", out of range "Beloppet måste vara mellan 0,01 kr och 10 000 000,00 kr"; description `required` "Skriv en beskrivning", too long; date out of range "Datumet ligger för långt fram"; `exact_sum_mismatch` / exact share > amount "Andelen är större än beloppet". Focus moves to the first field in error.
* `Saved` (new) → back to the opener with snackbar "Utgiften sparades" (online) / "Sparad. Synkas när du är online." (offline), no "Ångra" (UX-6). `Saved` (edit) → back to S-8, no snackbar (the timeline shows the pending line).
* Description IME `Done`: saves when valid; otherwise hides the keyboard and focuses the first invalid field.
* Close or back with changes: `ConfirmDialog` "Slänga utgiften?" (new) / "Slänga ändringarna?" (edit) with "Släng" / "Fortsätt redigera"; without changes it closes directly.
* "Ta bort utgift" (edit): `ConfirmDialog` (destructive, `delete`) "Ta bort utgiften?" / "Den försvinner för er båda." → "Ta bort"; then back past S-8 to its opener with snackbar "Utgiften togs bort".
* Editing an expense the other person created: same editor, no warning.
* Not reachable without an active account (no FAB).
**TalkBack**: close "Avbryt"; "Spara"; amount, keypad, quick row, chips, split section per §3.7; the compact row "Ullared, Shopping, 1 060,00 kronor, dubbeltryck för att ändra beloppet". Errors announced.
**200 %**: `S-7-editor-200`; "Spara" always in the app bar; chips wrap to several rows; split section scrolls.
### S-8 Expense detail (`:feature:expense`)
**Layout** (app bar: back, no title, `edit` "Ändra" and `delete` "Ta bort" icon buttons; column gap 10):
1. Hero card (fill `cat-<token>-container`, padding 20, gap 12): `CategoryTile` 60 + description (title-large, wraps) over "Mat & hushåll · Vardag" (body-medium muted); amount `MoneyText(Large)` with the pending marker (20) after it when pending; badge "Återkommande: Hyra" (lantern, `event_repeat`) when `recurringTemplateName != null` (AC-8.11).
2. Facts (`FactRow`s): Datum (`event`, "fredag 16 oktober 2026"); Betalt av (`person`, avatar 24 + name, "Du" for the viewer); Delning (`call_split`, mode label "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna", a thin `SplitBar`, "Du 623,80 kr · Anna 623,80 kr"); Anteckning (`notes`, full text) only when notes exist.
3. `Timeline`.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-8-expense` | loaded + pending | ICA Maxi Lindhagen, notes, timeline with create, two changes and "Din ändring väntar på synk" |
| `S-8-expense-recurring` | variant recurring | Hyra september, Boende · Huset, badge "Återkommande: Hyra", timeline "Skapad automatiskt (återkommande) · 25 sep. 06:00" |
| `S-8-expense-deleted` | variant deleted | Neutral banner on top: filled `delete` in `error` + "Anna tog bort den här utgiften · 17 okt. 09:12" + "Den räknas inte längre i saldon eller statistik."; hero and facts at 62 % opacity, tile desaturated; no "Ändra" / "Ta bort" |
| `S-8-expense-missing` | empty | `ExpenseRepository.detail` emits null: `EmptyState` (Cookie9 tile, `sync_problem`) "Utgiften finns inte på telefonen än" / "Den kan finnas på servern. Synka för att hämta den." with "Synka nu" (tonal, `sync`); no actions in the app bar |
**Interactions**: "Ändra" → S-7 edit. "Ta bort" → the delete dialog of S-7, then back with snackbar "Utgiften togs bort". "Synka nu" → `SyncRepository.syncNow()`; the screen fills in when the row arrives. Opened from a deletion notification → deleted state (AC-13.6 opens Home for deletions; the deleted state also shows when the row is deleted while open).
**TalkBack**: icon buttons "Ändra utgift" (`expense_editor_edit`), "Ta bort utgift" (`expense_editor_delete`); hero one node "ICA Maxi Lindhagen, Mat & hushåll, Vardag, 1 247,60 kronor, väntar på synk"; facts per `FactRow`; timeline items one node each.
**200 %**: facts wrap; hero description wraps.
### S-9 Settle up (`:feature:settle`)
**Layout** (app bar: close, "Gör upp" / "Gör upp Huset" (per account) / "Ändra betalning" (editing); column gap 10, padding bottom 20; bottom bar):
1. `DirectionCard`.
2. Amount field "Belopp" (filled, value `amount-large`, suffix "kr" title-large muted; system decimal keyboard; §9.6 input rules).
3. Header "Fördelning" (title-small) and a `SegmentedGroup` of `AllocationRow`s (`SettleState.lines`).
4. `RemainingIndicator` (global variant only).
5. "Lägg till konto" (text, `add`) and, after a row was edited, "Föreslå igen" (text, `refresh`).
6. Info line (global variant, when an account where the payer is owed is not in the rows, §9.4): `info` 18 + body-small muted "Vardag lämnas som det är, eftersom Anna är skyldig dig där." (`settle_untouched_partner_owes`; "… eftersom du är skyldig Anna där." is `settle_untouched_you_owe`).
7. Chip "Datum Idag" (date picker) and field "Anteckning" (0–200, placeholder "Till exempel Swish").
8. Bottom bar (`surface`, 1 dp `outline-variant` top line, padding 10 / 16): "Bekräfta" (filled medium, full width, `check`), enabled only when `canConfirm`.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-9-settle` | loaded (proposal) | "Du betalar Anna", 3 250,00 kr; Huset 3 084,50, Japan 2027 165,50; "Kvar att fördela 0,00 kr"; info line about Vardag |
| `S-9-settle-adjusted` | variant over-settle | Huset 1 750,00, Japan 2027 1 500,00 with the warning border and strip; remaining 0,00 |
| `S-9-settle-remaining` | variant sum mismatch | Huset 2 000,00, Japan 2027 1 000,00; remaining "250,00 kr" in warning style; "Bekräfta" disabled |
| `S-9-settle-account` | variant per account | "Gör upp Huset": one fixed row (no remove, amount follows), no remaining, no "Lägg till konto", no info line; direction swap and amount editable (UX-6) |
| `S-9-confirm-dialog` | variant confirm dialog | over `S-9-settle-adjusted`: dialog "Bekräfta betalningen", "Du betalar Anna 3 250,00 kr idag, lördag 17 oktober 2026.", the allocations (name + amount per line), the warning strip per warned row, "Avbryt" / "Bekräfta" |
| `S-9-settle-empty` | empty | global balance zero: amount empty (hint "0,00"), no rows; in place of the rows a body-medium muted line "Skriv ett belopp så föreslår Kvitt en fördelning."; no remaining indicator; "Bekräfta" disabled |
| `S-9-settle-200` | long, 200 %, light only | each allocation row stacks name and debt over a full-width amount field; direction card wraps its text; bottom button stays pinned |
**Interactions**
* Defaults from `SettlementRepository.start` (AC-11.3, AC-11.4; editing: AC-11.6).
* Editing the amount calls `recompute` with `allocations = null` (a fresh proposal) until the user edits a row; afterwards it keeps the rows and only updates the remaining amount; "Föreslå igen" re-proposes. Swapping the direction re-proposes. Per account: the single row follows the amount.
* "Lägg till konto" → bottom sheet of all non-deleted accounts not yet listed (archived with badge, AC-11.3); picking adds a row with an empty amount, focused.
* Remove → the row disappears and the remaining amount updates.
* "Bekräfta": with any `overSettles` row → the confirm dialog (AC-11.5) → "Bekräfta" saves; without warnings it saves directly. `Saved` → back to S-4 or S-5 with snackbar "Betalningen sparades". `Invalid` → inline under the amount or the rows (`settle_error_allocation_sum`, `settle_error_allocation_positive`, …).
* Close with changes → `ConfirmDialog` "Slänga ändringarna?" (`common_discard_title`) "Släng" / "Fortsätt redigera".
* Works offline unchanged.
**TalkBack**: swap "Byt riktning"; remove "Ta bort Huset från fördelningen"; row fields labelled "Belopp för Huset"; the remaining indicator is a polite live region; dialog title announced.
**200 %**: `S-9-settle-200`.
### S-10 Settlement detail (`:feature:settle`)
**Layout** (app bar: back, "Betalning", `edit` "Ändra", `delete` "Ta bort"; column gap 10):
1. Hero card (fill `settlement-hero`, padding 22 / 20, centred, gap 8): payer avatar 52 (Sunny) → `arrow_forward` 28 → recipient avatar 52; "Anna betalade dig" (title-medium); `MoneyText(Large)` with the pending marker when pending.
2. Facts: Datum ("tisdag 6 oktober 2026"), Anteckning (when present).
3. "Fördelning" header and a group: one row per allocation (`AccountTile` 36, name title-medium, `MoneyText(Small)`).
4. `Timeline`.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-10-settlement` | loaded | Anna → Jacob 800,00 kr, Vardag, "Swish" |
| `S-10-settlement-deleted` | variant deleted | Neutral banner "Anna tog bort den här betalningen · 17 okt. 09:12" + "Den räknas inte längre i saldona."; content at 62 % opacity; no "Ändra" / "Ta bort"; timeline ends with "Anna tog bort den här · 17 okt. 09:12" |
**Interactions**: "Ändra" → S-9 editing. "Ta bort" → `ConfirmDialog` (destructive) "Ta bort betalningen?" / "Saldona räknas om för er båda." → back with snackbar "Betalningen togs bort". A missing settlement uses the S-8 missing layout ("Betalningen finns inte på telefonen än"); no frame.
**TalkBack**: hero one node "Anna betalade dig 800,00 kronor"; icon buttons "Ändra betalning", "Ta bort betalning".
### S-11 Analytics (`:feature:analytics`)
**Layout** (app bar "Statistik" without navigation icon; column gap 10; bottom navigation with Statistik selected; no FAB):
1. `AnalyticsFilterBar` (defaults AC-14.2–AC-14.5: Senaste 6 mån, Månad, Hushållet, Kategori).
2. `TotalCard`.
3. `ChartCard` "Utgifter över tid": `StackedBarChart` grouped by the group-by toggle, `ChartLegend`.
4. `ChartCard` "Per kategori": `BreakdownList` of categories.
5. `ChartCard` "Per konto": `BreakdownList` of accounts (archived included with badge, AC-14.9).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-11-analytics` | loaded | maj–okt 2026, household, by category; legend of the categories with data; "Per kategori" begins |
| `S-11-analytics-scrolled` | loaded, scrolled | app bar `surface-container`; the end of "Per kategori" and the whole "Per konto" |
| `S-11-analytics-week` | variant week / my share / by account | "Anpassad · 24 aug.–17 okt.", Vecka, Min del, Konto; eight weekly bars by account with "Vecka" caption; "Per konto" |
| `S-11-analytics-empty` | empty | "Anpassad · 1–30 nov."; total 0,00 kr; `EmptyState` (three `other` container tiles) "Inga utgifter i den här perioden" / "Välj en annan period, eller lägg till en utgift med ett datum i perioden." + "Byt period" (tonal, `date_range`); charts hidden, filters stay |
| `S-11-analytics-200` | long, 200 %, light only | filter controls one per row; total amount ~1.5×; chart labels thinned; breakdown names ellipsize, amounts and percents whole |
**Interactions**
* Period chip → menu of the presets (AC-14.2: "Denna månad", "Senaste 3 mån", "Senaste 6 mån", "Senaste 12 mån", "I år", "All tid", "Anpassad…"); "Anpassad…" opens the Material date range picker; "Använd" is enabled only with both dates (AnalyticsState note in §14.2.4).
* Toggles re-query immediately; the filter survives tab switches within the session.
* Bar → S-12 for that bucket (all groups); breakdown row → S-12 for that category or account over the range (AC-14.8).
* Settlements are never counted (AC-14.6). Works offline.
**TalkBack**: chart summary sentences as §3.10; each bar and row a node; toggles are radio groups.
**200 %**: `S-11-analytics-200`.
### S-12 Analytics drill-down (`:feature:analytics`)
**Layout** (app bar: back, title formatted from the drill-down arguments: "Mat & hushåll, oktober 2026" / "Vardag, v. 41" / "Oktober 2026" / "Mat & hushåll, maj–okt 2026"; one line, ellipsis; column gap 10):
1. Header card (fill `cat-<token>-container` for a category, `account-header-n` for an account, `primary-container` for a whole bucket; padding 18 / 20): tile 56 (`CategoryTile`, `AccountTile`, or a Cookie4 `primary` tile with `bar_chart` for a bucket) + "Totalt · Hushållet" / "Totalt · Min del" (label-large muted) over `MoneyText(Large)` over "4 utgifter" (body-small muted, plural).
2. `SegmentedGroup` of `ExpenseRow`s, newest first.
**States**
| Frame | State |
|---|---|
| `S-12-drilldown` | loaded (Mat & hushåll, oktober 2026) |
**Interactions**: row → S-8. If the data changed and nothing is left: the header shows 0,00 kr and the line "Inga utgifter längre" replaces the list (no frame).
**TalkBack**: header one node "Mat & hushåll, oktober 2026, totalt 3 910,00 kronor, 4 utgifter".
### S-13 Settings hub (`:feature:settings`)
**Layout** (app bar "Inställningar"; column gap 12; bottom navigation with Inställningar selected):
1. Household card (padding 16 / 18, gap 12; role lines only, no device name: `SessionState` carries none): viewer row: avatar 52 (Sunny) + name (title-medium) over "Du · Administratör" (`settings_role_admin`) or "Du" (`settings_role_member`) in body-small muted; 1 dp divider; partner row: avatar 52 + name over "Partner" (`common_partner`) or "Admin" (`settings_role_admin_other`).
2. Group 1: `SettingsRow`s "Profil" (`person`, orange, "Namn och bild"), "Kategorier" (`category`, green, "14 aktiva"), "Återkommande utgifter" (`event_repeat`, brown, "3 aktiva, 1 pausad" / "Inga").
3. Group 2: "Enheter" (`devices`, blue, no subtitle: the list is online only); admin only: "Bjud in partner" (`person_add`, teal) while the partner has not joined, else "Para ihop en enhet" (`add_link`, teal); "Synkstatus" (`sync`, purple, the pill's text, e.g. "Synkad för 3 min sedan" / "2 väntar på synk"); "Om Kvitt" (`info`, grey, "Version 1.1" / "Uppdatering finns").
**States**
| Frame | State | What differs |
|---|---|---|
| `S-13-settings` | loaded (admin) | Jacob "Du · Administratör", Anna "Partner"; "Para ihop en enhet" shown |
| `S-13-settings-member` | variant member | Anna's phone: Anna "Du" first, Jacob "Admin"; no invite or pairing row (AC-2.2) |
**Interactions**: each row pushes its screen (S-14, S-15, S-19, S-20, S-21, S-16, S-22). Rows to online-only screens stay enabled; those screens show their offline state.
**TalkBack**: household card two nodes ("Jacob, du, administratör"; "Anna, partner"); rows "Kategorier, 14 aktiva, knapp".
### S-14 Profile (`:feature:settings`)
**Layout** (app bar: back, "Profil", "Spara" small filled; column gap 14):
1. Centred, padding 20 / 0 / 10: own avatar 128 (Sunny); buttons "Byt bild" (tonal, `photo_library`; "Lägg till bild" without a picture) and "Ta bort bild" (text, `delete`; only with a picture).
2. Field "Namn" (1–40) with supporting text "Så här ser Anna dig i Kvitt." (partner's name).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-14-profile` | loaded | Jacob, picture, name focused |
| `S-14-profile-loading` | loading | a new picture uploading: progress ring on the avatar, both picture buttons disabled |
| `S-14-profile-offline` | offline | Warning banner "Kräver anslutning. Profilen kan bara ändras när telefonen är online." (`settings_profile_offline_banner`); buttons, field and "Spara" disabled |
**Interactions**: picture changes apply on success without "Spara" (AC-4.3); "Ta bort bild" asks nothing (reversible by adding again). "Spara" enabled when the name changed and is valid; saves with `setDisplayName`; success → back (no snackbar). Failures: snackbar per S-2 (picture) or "Kunde inte nå servern" + "Försök igen".
**TalkBack**: avatar = the viewer's name; while uploading "Laddar upp bilden".
### S-15 Categories (`:feature:settings`)
**List layout** (app bar: back, "Kategorier", `add` icon button "Ny kategori"; column gap 4):
1. `SegmentedGroup` of active categories in `sort_order`: row min height 56, padding 4 / 12 / 4 / 8, gap 12: drag handle (`drag_indicator`, 32 × 48 target), `CategoryTile` 36 solid, name (title-medium; custom name or localised default), `chevron_right`.
2. `CollapsibleRow` "Arkiverade (n)" and, expanded, a group of archived categories (no drag handle, 32 dp spacer, muted tile).
**Editor layout** (pushed; app bar: close, "Ändra kategori" / "Ny kategori", "Spara"; column gap 10):
1. Row: live preview `CategoryTile` 64 + name field "Namn" (1–40; for a default category an empty field means "use the default name"); for a default category under the field "Standardnamn: Mat & hushåll" (body-small muted) and, when a custom name exists, "Återställ namn" (text, `restart_alt`, AC-7.4).
2. "Ikon" (title-small) + `CategoryIconPicker` (33).
3. "Färg" + `ColorSwatchPicker` (14).
4. Footer row: "Arkivera" (outlined, `inventory_2`) or "Återställ från arkiv" (outlined, `unarchive`); delete: default category → `lock` 16 + "Kan inte tas bort: standardkategori" (body-small muted, `settings_category_delete_blocked_default_short`); custom unused → "Ta bort kategori" (danger text, `delete`); custom in use → disabled "Ta bort kategori" with "Kategorin används av utgifter eller återkommande utgifter. Arkivera den i stället." (`settings_category_delete_blocked_in_use`); the state comes from `CategoryRepository.deletable(id)` (UX-9, pending), see open point 2 for the behaviour until then.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-15-categories` | loaded | the 14 defaults in sort order |
| `S-15-categories-scrolled` | loaded, scrolled | the end of the list, "Arkiverade (1)" expanded with Pant |
| `S-15-categories-offline` | offline | no drag handles; a `NeedsConnectionHint` line "Kräver anslutning för att ändra" above the list; "Ny kategori" disabled; rows not clickable, no chevrons |
| `S-15-category-editor` | variant editor (default renamed) | "Matvaror" over "Standardnamn: Mat & hushåll" with "Återställ namn"; `shopping_cart`, green; "Arkivera"; "Kan inte tas bort: standardkategori" |
| `S-15-category-editor-custom` | variant editor, custom archived | "Pant", `water_drop`, teal; no default-name line; "Återställ från arkiv"; "Ta bort kategori" disabled with "Kategorin används av utgifter eller återkommande utgifter och kan inte tas bort." (`settings_category_delete_blocked_in_use_archived`; an active category uses `settings_category_delete_blocked_in_use`, which adds "Arkivera den i stället.") |
**Interactions**
* Reorder: long-press a row or drag its handle; on drop `reorder(ids)`; failure → snackbar "Kunde inte nå servern" and the old order returns. TalkBack custom actions "Flytta upp" / "Flytta ned" on each active row.
* Row → editor. "Spara" (online) → `update` / `create`; success → back. "Återställ namn" sets the field empty and the preview name to the default (saved with "Spara", `name = null`).
* "Arkivera" / "Återställ från arkiv" act immediately (online) and return to the list with a snackbar ("Pant arkiverades"). "Ta bort kategori" → `ConfirmDialog` "Ta bort kategorin Pant?" → `delete`; `category_in_use` → the disabled reason inline.
**TalkBack**: drag handle "Flytta Mat & hushåll" (`settings_categories_drag_handle`); add "Ny kategori"; icon and colour pickers per §3.4; preview tile decorative.
**200 %**: icon grid stays 7 columns (icons do not scale); colour grid 7 columns; the footer wraps.
### S-16 Sync status (`:feature:home`)
**Layout** (app bar: back, "Synkstatus"; column gap 10):
1. When `clockSkew`: Error banner (`schedule`) "Telefonens klocka går fel. Rätta den och synka igen." / "Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess." with "Datum och tid" (text, `open_in_new`, opens the system date settings).
2. Status card (padding 20, gap 14): 52 dp Sunny tile (`owed-container` + filled `cloud_done`; `surface-container-highest` + `sync_problem` when the last attempt failed or was refused) + "Synkad för 3 min sedan" / "Synkar…" / "Offline" (title-medium) over "Senast lyckade synk 13:59" (body-small muted).
3. "Synka nu" (filled medium, full width, `sync`); disabled with a progress indicator while running; offline disabled with "Kräver anslutning".
4. "Väntande ändringar (2)" header + group of pending rows: `CategoryTile` 36 (settlements: the settlement tile), description (title-medium, ellipsis), "Väntar på synk" with `cloud_upload` 15 in `pending` (body-small), `MoneyText(Small)`; tap → S-8 / S-10. Rows carry no change-kind tag ("Ny" / "Ändrad" / "Borttagen" of U1): `SyncStatusState.pending` is a list of `LedgerRow`s without one, and the tag is not needed for v1.
5. "Ändringar som inte synkades" header + one outlined card per rejection (1 dp `outline-variant`, padding 16, gap 10): 36 dp tile (`receipt_long` for an expense, `handshake` for a settlement, on `surface-container-highest`) + summary (title-medium) + reason text per §14.4.6 with the time ("Din ändring ersattes av en nyare ändring från Anna · 17 okt. 08:31", body-medium) + a body-small muted consequence line ("Annas version gäller. Öppna utgiften om du vill göra din ändring igen."); actions "Öppna" (text) and "Ta bort från listan" (tonal).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-16-sync` | loaded | synced 3 min ago, two pending rows, one `stale` rejection (Babette) |
| `S-16-sync-clock` | variant clock skew | clock banner first; status "Synkad för 2 h sedan" / "Senaste försöket nekades: klockan" with `sync_problem`; pending rows; no rejection list |
| `S-16-sync-empty` | empty | status card "Allt är synkat" (`home_sync_status_all_synced`) over "Senast lyckade synk 13:59"; "Synka nu"; no pending or rejection sections |
**Interactions**: "Öppna" opens the entity's current version (S-8 / S-10; deleted or missing states apply); "Ta bort från listan" → `dismiss` (works offline). The rows update live while a sync runs.
**TalkBack**: status card one node; rejection card one node plus its two buttons, read with their visible text ("Öppna", "Ta bort från listan", `home_sync_status_dismiss`).
### S-17 Update available (`:feature:home`)
Not a screen: the `UpdateBanner` on S-4 (and S-22) and one dialog.
**Dialog "Tillåt installation"** (shown once, before the first install, when `UpdateState.NeedsInstallPermission`): `ConfirmDialog` with a 56 dp Sunny `tertiary-container` tile (`install_mobile`), title "Tillåt installation", body "Kvitt uppdateras direkt från er server, inte från Play Butik. Därför frågar Android en gång om Kvitt får **Installera okända appar**." and "Slå på **Tillåt från den här källan** och gå sedan tillbaka till Kvitt. Uppdateringen fortsätter av sig själv.", buttons "Senare" / "Fortsätt" (`arrow_forward`, opens the system setting for Kvitt).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-17-update` | variant first-time dialog | the dialog over S-4 with the `Available` banner |
| `S-17-update-progress` | loading (downloading) | banner `Downloading` 64 % |
| `S-17-update-failed` | error | banner `Failed` |
**Interactions**: "Uppdatera" → `install()`; after the system setting, returning to the app continues; verified download → system installer (out of the app's hands). "Senare" hides the banner until the next cold start. Offline: "Uppdatera" disabled with "Kräver anslutning". Debug builds never show it (AC-16.5).
**TalkBack**: progress announced as a percentage every 10 %; dialog title read on open.
### S-18 Device removed (`:feature:onboarding`)
**Layout** (no app bar, no navigation): centred column, padding top 80, gap 14: Cookie9 tile 112 (`error-container`, `phonelink_erase` 52 in `on-error-container`); "Den här telefonen har tagits bort" (headline-medium); "Åtkomsten togs bort och allt Kvitt-data på telefonen har raderats." (body-large muted); "Vill du använda Kvitt igen behöver du en ny inbjudningskod." (body-medium muted). Bottom bar: "Para ihop igen" (filled medium, full width, `link`).
**States**
| Frame | State |
|---|---|
| `S-18-removed` | loaded |
**Interactions**: "Para ihop igen" → `acknowledgeRevoked()` → S-1 (first pairing: with the name field). Back closes the app.
### S-19 Recurring expenses (`:feature:settings`)
**List layout** (app bar: back, "Återkommande utgifter"; column gap 10; FAB "Ny återkommande"):
* `SegmentedGroup` of templates (row min 72, top-aligned, padding 14 / 16): `CategoryTile` 40 (muted when paused) · description (title-medium, ellipsis) over "Huset · Anna betalar" (account · payer, body-medium muted) over "Varje månad den 25:e" and "Nästa 25 okt." (body-small muted, two lines; `settings_recurring_every_month_on`, `settings_recurring_next`) or, when paused, a "Pausad" lantern badge (`pause`) + "Varje månad den 12:e" · `MoneyText(Small)` right (70 % opacity when paused).
**Editor layout** (pushed; app bar: close, "Ny återkommande utgift" / "Återkommande utgift", "Spara"; column gap 12): `AmountDisplay` (+ keypad when focused; focused on open for a new template), `DescriptionField`, `CategoryQuickRow` (no suggestion, open point 3), chips Konto · Betalt av · Delning (no Datum), `SplitSection` when open; then a flat section "Schema" (padding 16, gap 10): "Dag i månaden" field (1–31, number keyboard) and "Första månad" (dropdown; a month picker dialog titled with the field label, with a year row and 12 month chips; months before the allowed start disabled, AC-9.8 default) side by side (1 : 1.5); "Sista månad (valfritt)" (placeholder "Ingen slutmånad"); a switch row "Pausad" with "Skapas kl. 06:00 den 25:e varje månad" (body-small muted). Day 29–31: hint "I kortare månader används sista dagen". Editing: hint "Ändringen gäller kommande utgifter." (AC-9.4); after un-pausing: "Månader som passerat under pausen skapas inte i efterhand." (§9.5). Edit mode: "Ta bort" (danger text) at the end.
**States**
| Frame | State | What differs |
|---|---|---|
| `S-19-recurring` | loaded | Hyra, Bahnhof bredband, Netflix, Spotify Family (paused) |
| `S-19-recurring-empty` | empty | `EmptyState` "Inga återkommande utgifter" / "Hyra, el och streaming kan läggas in automatiskt varje månad." + "Ny återkommande utgift" (medium, `add`); no FAB |
| `S-19-recurring-offline` | offline | Warning banner "Kräver anslutning. Återkommande utgifter kan bara ändras när telefonen är online." (`settings_recurring_offline_banner`) above the list; FAB disabled (`on-surface` 12 % fill, 38 % content); rows not clickable, list readable |
| `S-19-recurring-editor` | variant editor | Hyra 12 400,00 kr, Boende, Huset, Betalt av Anna, Kontots standard 60/40, day 25, from september 2026 |
**Interactions**: FAB or empty-state button → editor (new). Row → editor. "Spara" (online) → `create` / `update`; success → back; network → snackbar. "Ta bort" → `ConfirmDialog` "Ta bort Hyra?" / "Utgifter som redan skapats finns kvar." → `delete`. Close with changes → discard dialog.
**TalkBack**: row one node "Hyra, 12 400,00 kronor, Huset, Anna betalar, varje månad den 25:e, nästa 25 oktober" (paused: "pausad"); FAB "Ny återkommande utgift".
### S-20 Devices (`:feature:settings`)
**Layout** (app bar: back, "Enheter"; column gap 10): `SegmentedGroup` of devices (row min 72, top-aligned, padding 16): tile 40 (Cookie4, `cat-blue` container, `smartphone` in `cat-blue`) · name (title-medium) + "Den här enheten" lantern badge for the current one · avatar 20 + owner name (body-medium muted) · "Parad 3 okt. · Senast sedd nyss · Kvitt 1.1" (body-small muted) · for revocable devices "Ta bort åtkomst" (danger text, `block`). Revoked devices are not listed. Footer: "Du kan inte ta bort åtkomsten för den telefon du använder just nu." (body-small muted). Admin sees both users' devices, a member their own (AC-3.1).
**States**
| Frame | State | What differs |
|---|---|---|
| `S-20-devices` | loaded | Galaxy S26 (Jacob, this device), Galaxy S26+ (Anna) |
| `S-20-devices-revoke` | variant confirm dialog | `ConfirmDialog` (destructive, `phonelink_erase`) "Ta bort åtkomst för Galaxy S26+?" / "Annas telefon loggas ut och allt Kvitt-data på den raderas. Anna kan para ihop den igen med en ny inbjudningskod." / "Avbryt", "Ta bort åtkomst" |
| `S-20-devices-loading` | loading | two skeleton rows while `list()` runs |
| `S-20-devices-offline` | offline | no list; centred `cloud_off` 48 + "Kräver anslutning för att visa enheter" (title-medium) |
**Interactions**: revoke → dialog → `revoke(id)` → the row updates; failure → snackbar. Network failure on open → the offline layout with "Kunde inte nå servern" and "Försök igen".
### S-21 Invites (`:feature:settings`, admin only)
**Layout** (app bar: back, "Bjud in partner" while the partner has not joined, else "Para ihop en enhet"; column gap 10):
1. Before an invite is created in this visit: card with a body-medium explanation and the create buttons: "Bjud in partner" (filled, `person_add`, only while one user exists, AC-2.1) or, after the partner joined, "För mig" and "För Anna" (tonal, `add_link`; AC-2.2).
2. After creation, in its place: a `tertiary-container` card (padding 20, gap 12): `key` (filled) + "Ny enhet för Jacob" (`settings_invites_new_device_for`) / "Inbjudan till din partner" (`settings_invites_new_partner`) (title-medium); "Inbjudningskod" (label-medium); `InviteCodeDisplay`; the link (body-small, centred, wraps anywhere); `schedule` + "Gäller till tis 20 okt. 14:02"; "Dela" (tertiary, `share`; Android share sheet with `settings_invites_share_message`) and "Kopiera kod" (outlined in `on-tertiary-container`, `content_copy`; snackbar "Koden kopierades"). Under the card: "Öppna länken på den nya telefonen, eller skriv in koden i Kvitt. Koden fungerar en gång." (body-medium muted).
3. "Väntande inbjudningar" header + group: tile (`key`, `cat-yellow` container) + "Enhet · Jacob" / "Partner" (title-medium) over "Skapad idag 14:02 · Gäller till tis 20 okt. 14:02" (body-small muted) + "Avbryt" (text). Rows created earlier have no code ("Koden visas bara när den skapas").
**States**
| Frame | State | What differs |
|---|---|---|
| `S-21-invites` | loaded | created device invite for Jacob; one pending row |
| `S-21-invites-offline` | offline | Warning banner "Kräver anslutning. Inbjudningar kan bara skapas och visas när telefonen är online." (`settings_invites_offline_banner`); create buttons disabled; no pending list |
| `S-21-invites-error` | error | after a create: snackbar "Kunde inte nå servern" with "Försök igen"; no card |
**Interactions**: create → `create(kind, targetUser)`; `household_full` → snackbar "Din partner har redan gått med" (`error_household_full`). "Avbryt" on a pending row → `ConfirmDialog` "Avbryta inbjudan?" → `cancel(id)`. Leaving the screen discards the shown code (it cannot be shown again).
**TalkBack**: code spelled per group (§3.11); "Dela" (`settings_invites_share`), "Kopiera kod" (`settings_invites_copy_code`); pending row "Enhet för Jacob, gäller till tisdag 20 oktober 14:02" + "Avbryt inbjudan".
### S-22 About (`:feature:settings`)
**Layout** (app bar: back, "Om Kvitt"; column gap 10): centred app mark 96 (Sunny, `primary`, `receipt_long` 48), "Kvitt 1.1 (11)" (headline-medium), "Delade utgifter för två" (body-medium muted); `UpdateBanner` when an update is available; group of `FactRow`s: Server (`dns`, URL wraps), Serverversion (`memory`, "1.1.0" or "Okänd" when `/health` failed), "Licenser för öppen källkod" (`description`, chevron → the licences screen); "Sök efter uppdatering" (tonal medium, `system_update`, centred; hidden in debug builds, AC-16.5).
**States**
| Frame | State |
|---|---|
| `S-22-about` | loaded |
**Interactions**: "Sök efter uppdatering" → `check()` with progress in the button; result: the banner appears, or snackbar "Du har den senaste versionen"; network → "Kunde inte nå servern".
### Notifications (`:app` builds them, copy in `:core:design`)
Built locally after the sync per AC-13.5; channel "Aktivitet" (`notif_channel_activity`). Small icon: the monochrome `receipt_long` app mark; accent colour `primary`; large icon: the actor's avatar when cached (else none). Title and body per §13.3 in the phone's language (`notif_*`), e.g. "Anna lade till en utgift" / "ICA Maxi Lindhagen · 1 247,60 kr · Vardag"; "Anna registrerade en betalning" / "Anna betalade dig 800,00 kr". Generic fallback without amounts when the sync fails. Tap per AC-13.6.
| Frame | State |
|---|---|
| `N-notifications` | the notification shade with two notifications |
---
## 5. The add-expense flow (S-7) in detail
The most important flow (§13.1). It must need at most: open app, tap add, type amount, type description, tap save; account and category are defaulted (AC-8.6, and UX-3 for the category).
### 5.1 The ten-second path (binding)
| Step | User | Screen |
|---|---|---|
| 1 | Taps "Lägg till utgift" on S-4 (or S-5) | S-7 slides up: amount focused, keypad up, all defaults set (Konto per AC-8.6 or the S-5 account; category = the step-4 default of CS-5; Datum "Idag"; Betalt av "Du"; Delning "Kontots standard …"). "Spara" disabled |
| 2 | Types `4` `1` `2` on the keypad | "412" in the amount; `preview` computed; "Spara" still disabled (no description) |
| 3 | Taps "Beskrivning", types "Systembolaget" | keypad slides out, the Swedish system keyboard in; on every change the suggestion re-runs (§5.3) and the quick row selects Nöje; "Spara" enabled |
| 4 | Taps "Spara" (app bar) or the keyboard's Done | `save` → `Saved`; the editor closes; S-4 shows "Utgiften sparades" (offline: "Sparad. Synkas när du är online."); balances already include it (AC-10.4); sync after 2 s (AC-15.3) |
One tap each, never more, for the common overrides from step 3 on: payer (tap "Betalt av" toggles), category (tap a tile), split (tap "Delning" opens the section; one more tap picks a mode). Nothing else needs touching.
### 5.2 Keypad and keyboard
* The keypad (§3.7) owns the amount, the exact-share field and the ratio percent fields; the system keyboard owns the description and the notes. Only one of the two is visible.
* Focus order: amount → description → (notes when expanded). Tapping the description while the keypad is up swaps keypad for keyboard in one motion (keypad slides down 150 ms while the IME animates in; the content keeps the description visible above the IME with `imePadding`).
* Tapping outside any field hides both.
* The keypad never inserts invalid input (§9.6); the amount needs no separate validation message for format, only for "required" and the range.
### 5.3 Category suggestion (UX-3, CS-1 … CS-7)
* **Scope (CS-1):** new expenses only. In edit mode (`S-7-editor-edit`, `S-7-editor-full`) the saved category stays and nothing is suggested.
* **Manual flag (CS-2):** `categoryPickedByHand = false` on open. Tapping any tile in `CategoryQuickRow` or `CategoryGridSheet` sets it true, also when the tile was already selected. It is never reset in this editor session.
* **Runs (CS-3)** while the flag is false, on every description change and every account change, by calling `ExpenseRepository.suggestCategory(accountId, description)` (UX-3; local, no network); debounce 150 ms so typing stays smooth. A non-null result becomes the selected category; null (no suggestion possible) leaves the step-4 default (CS-5 step 4; for an empty description, CS-7).
* **Order (CS-5):** history exact → history first token → keyword table → default; "most often", ties by most recent. The UI does not know which step answered.
* **What the user sees (CS-7):** the Kategori selection simply moves to the suggested tile (container → solid cross-fade and check badge, §2.11); **no caption, no marker, no snackbar**. If the suggested category is not among the five tiles it takes the fifth place (§3.7). TalkBack does not announce suggestion changes (no live region); the selected tile reads "vald" when focused.
* Clearing the description returns the selection to the step-4 default (CS-7).
* A wrong guess costs one tap: the right tile, or "Alla" and the right category.
### 5.4 Split override
* Closed state: the "Delning" chip shows the current mode and ratio ("Delning Kontots standard 60/40"); `SplitPreview` under the chips shows the shares.
* One tap on the chip opens `SplitSection` in place (spatial default spring) and scrolls it into view; the chip turns `secondary-container` with `expand_less`; the keypad switches to its compact height when a keypad field in the section gets focus.
* Picking a mode is one tap; "Allt på en person" then needs the bearer (one tap; default the partner when the viewer pays, else the viewer — the person who did not pay is the usual reason for "all on one"); "Egen fördelning" starts at the account's ratio; "Exakta belopp" starts with the viewer's field empty and focused.
* Closing the section keeps the chosen mode. Changing the account in mode `default` re-copies its ratio (AC-8.8); in other modes the split is kept.
* The preview is always `ExpenseRepository.preview(draft)`; when it returns null the preview hides and "Spara" is disabled.
### 5.5 Edit mode
Same editor with "Ändra utgift", fields loaded from `load(id)`, nothing focused and the keypad hidden (`S-7-editor-edit`); tapping the amount shows the keypad. The split section is open when the mode is not `default`. Notes are expanded when present. "Ta bort utgift" at the end. Save returns to S-8.
---
## 6. Open points
1. **Quick-row usage data (UX-9).** `CategoryQuickRow` needs `ExpenseEditorOptions.frequentCategoryIds` (proposed in `proposed-decisions.md` UX-9: per active account, up to five active categories by use count in that account, ties by `sort_order`, from the Room copy). Until it lands the row uses the fallback of §3.7.
2. **Category deletability (UX-9).** `S-15-category-editor-custom` needs `CategoryRepository.deletable(id)` (UX-9). Until it lands, "Ta bort kategori" is enabled for custom categories and `Failed("category_in_use")` shows the in-use reason inline after the tap.
3. **Recurring editor suggestion.** CS-1 limits the suggestion to new expenses; S-19's editor uses the AC-8.6-style default (last category of the account, else Övrigt) and no suggestion (category-suggestion.md open point 3).
4. **System font on One UI.** `FontFamily.Default` may resolve to a One UI font style instead of Roboto; M6 checks on the owner's S26 that amounts stay tabular and weights 500/600/700 are distinct (D-020 stands either way).
5. **Expressive API status.** Every Expressive API of §3.1 is checked against the current Material 3 release when M6 pins it; any that is not usable takes its listed fallback without a design change.
Resolved in the U2 alignment pass: the catalogue frames follow this file (payer chip `swap_horiz`, ratio presets 40/60 · 50/50 · 60/40, allocation remove buttons, S-13 "14 aktiva" without an "Enheter" subtitle and without a device name, S-16 rows "Väntar på synk", S-12 count caption), and every label quoted here has a `strings.csv` key (including the split-section collapse label, the keypad's "Rensa" action, the S-10 missing state and the category delete confirmation).
Recorded after U2 in SPEC v0.10: open points 1 and 2 are resolved by M12d (D-059 to D-062) and open point 5 by M6 (D-064); English "Dining" for `restaurants` (D-067, so §2.13 reads "Dining") and `ledger_settlement_label`, the settlement-title wrap and the empty-chart baseline (see D-068) live in SPEC.md and `strings.csv`, which win where this file differs.