- D-097: chart bars top 4 dp, bottom 2 dp (M8b f0575d3); design-spec §2.9, §3.10 - D-100: no SSH from the Mac to the Pis; M2c-pi delivers infra/pi/ for the owner to run; OQ-3 answered by .env placeholders; revises D-023, keeps D-001 - D-101: kvitt-release.jks at the repository root, git-ignored; revises D-024 - M8 end-to-end test result and M8b fixes in §11.2/§11.3; OQ-5 answered; 128 px picture size not needed (§14.2.4) - HANDOFF and prompts/README rewritten for the current state and numbering Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
119 KiB
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 fromConnectivityRepository.online. - "Kunde inte nå servern" (
common_could_not_reach_server): an online call returnedOnlineResult.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_takenunder the name) and to a snackbar otherwise, text fromstrings.csverror_*. - Pending marker:
cloud_uploadicon inpendingcolour, TalkBack "Väntar på synk" (common_pending_sync), on every row or detail whosepending == true. - Amounts: tabular figures always; formatted by
MoneyFormatter(§9.8:1 234,50 krwith U+00A0, never a minus sign); spoken byMoneyFormatter.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.pngand-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 4, bottom 2 |
| 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 RoundedPolygons 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 resolvedStrings 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:designwrapper 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 acontentDescription; decorative tiles, the seesaw, split bars next to their numbers and illustrations are cleared (clearAndSetSemantics {}orcontentDescription = null). TalkBack text is Swedish or English per app language and reads amounts withMoneyFormatter.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,
HeroandLargeput 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" inowed(effect_lent) ·Borrowed(ore)"Du lånade 623,80 kr" inowing(effect_borrowed) ·NotInvolved"Inte inblandad" ·PaidForYourself"Betalade för dig själv" inon-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).Sunnyshape for the large standalone uses of §2.9, else circle. - States: picture, initials (also while the picture loads), uploading (a 4 dp
CircularProgressIndicatorinprimarydrawn 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 inon-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,
cardfill and shadow, padding 14 vertical / 16 horizontal, min height 76, gap 14. - Left:
AccountTile44. Middle: name (title-medium, 1 line, ellipsis) with an "Arkiverat" badge (inventory_214) for archived accounts; below it body-smallon-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 andMoneyText(Medium)both inowed/owing; whennetOre == 0:check_circle(filled, 20) + "Kvitt" in title-mediumsettled, 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. CategoryTile40 solid · centre: description (title-medium, 1 line, ellipsis),EffectLinebelow · right column:MoneyText(Small), below it body-smallon-surface-variantdate ("16 okt.", §9.8) preceded by the pending marker (15 dp) whenpending.- 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-rowtint (§2.2). - Tile: 40 dp circle
primary-containerwith filledhandshakeinon-primary-container. Centre: "Anna betalade dig 800,00 kr" / "Du betalade Anna 800,00 kr" (title-medium,ledger_settlement_*), plus " · 2 konton" whenaccountCount > 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 colourowing(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, filledhandshakeicon;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:
AccountTile40 · "50 / 50 · Jacob och Anna" (title-medium; default shares in percent, viewer first,account_default_split) with a thinSplitBar(8 dp) below · "Arkiverat" badge when archived. - Balance: wording (title-medium) above
MoneyText(Large), both inowed/owing; zero:check_circle28 + "Kvitt" in headline-smallsettled. - Actions, left-aligned: "Gör upp det här kontot" (filled,
handshake) whennetOre != 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_right18. - Variants: synced:
surface-container-highfill,cloud_done(filled) inowed, "Synkad för 3 min sedan" (sync_synced_relative, §9.8 relative time, refreshed every minute) · running:syncicon, "Synkar…" · pending: suffix " · 2 ändringar väntar på synk" (sync_pending, plural) · offline:sync-pill-pendingfill,cloud_offinpending, "Offline · 2 ändringar väntar på synk" (or "Offline" alone) · warning:warning-containerfill,warningicon, "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: ".
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;
errorcolour on error) above the amount (amount-hero, tabular) with a 3 dpprimarycaret 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-variantas 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 incompact, used while the split section is open), radius full,surface-container-highest, label 24 sp weight 600 (capped 1.5×). Delete key:primary-containerfill,backspace26 inon-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
0followed by a digit replaces the0; 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:
CategoryTile52 (Container style; Solid when selected) above a label (11 / 14 sp weight 500on-surface-variant, up to 2 lines, centred, no ellipsis; selected:on-surfaceweight 700). - Selected tile: solid, plus a check badge: 20 dp circle in
on-surfacewithcheck(14, filled, weight 700) insurface, offset to the tile's top-right (−6 / −8 dp), with a 2 dpsurfacering. - "Alla": tile in
surface-container-highestwithapps26 inon-surface-variant, label "Alla"; opensCategoryGridSheet. 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 fromsort_orderwhen 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 isExpenseEditorOptions.frequentCategoryIds(UX-9, pending); until it exists the five are the selected category followed by the first active categories insort_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_lessicon 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-containerwithcheck. Semantics radio group "Delning". default:SplitBar16 +SplitPreviewtext.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 aButtonGroup"40/60 · 50/50 · 60/40" (viewer first), thenSplitPreview.exact(ExactSplitEditor): one row per person: avatar 40 + money field (label = name, suffix "kr", valueamount-medium). The viewer's field is typed by default; the other is read-only (transparent fill, 1 dpoutline-variantborder) 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 anerror-containerbox "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-buttonButtonGroup(min height 52; avatar 28 + name each),SplitBar16 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:
AccountTile40 · 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-alignedamount-medium+ "kr"; system decimal keyboard) · remove icon buttonclose(TalkBack "Ta bort Huset från fördelningen",settle_row_remove).fixed: the amount is plainMoneyText(Medium)(it follows the settlement amount) and there is no remove button. - Over-settle (
overSettles): field border 2 dpwarning; under line 1 a strip (warning-container, radius 12, padding 8 / 12): filledwarning20 + "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
secondaryand a 2 dpoutline-variantconnector to the next item; text body-medium. Sentence perFieldChange: "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 withMoneyFormatter/DateFormatter; field names fromtimeline_field_*. pendingLocal: hollow dot (2 dppendingring), text inpendingweight 600 with a 16 dpcloud_uploadbefore 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 4, bottom 2; 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, : 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):
- Brand block, centred, padding 28 / 0 / 12: app mark 88 dp (Sunny,
primary, filledreceipt_long44 inon-primary), "Kvitt" (display-small,primary), "Delade utgifter för två" (title-medium muted,common_tagline). InviteCodeFieldwith "Klistra in".- "Ditt namn" field (UX-4; 1–40, §9.6; capitalisation
Words), required on a first pairing. - Help line:
info20 + 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 genericonboarding_code_help_genericis used when the app was not opened from a link). - 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 (keyboardUri). It opens expanded when a link carried a server different from the release default. - Bottom bar, padding 12 / 16: "Para ihop" (filled medium 56, full width,
linkicon).
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
Nextfrom code to name,Doneon the name runs "Para ihop". - "Klistra in": reads the clipboard; a 12-character code or an invite link (
kvitt://invite?…orhttps://…/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 stringhome_notification_rationaleis 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):
- 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). - 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
primarycircle badge holdingadd_a_photo20 at its bottom-right (3 dpcardring); "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. - 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):
- Hero card in the
Waitingstate (§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). - 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). - 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):
UpdateBannerwhenHomeState.update/UpdateStateis notNone(S-17).BalanceHerofromHomeState.global.SyncStatusPillfromHomeState.syncand connectivity.SectionHeader"Konton" with "Nytt konto" (text button,add).AccountCardper active account, created order (AC-6.6).CollapsibleRow"Arkiverade (n)" when archived accounts exist; expanded in place to theirAccountCards (AC-6.4); collapsed by default, state kept for the session.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):
AccountBalanceHeader.- Ledger: per month a
LedgerMonthHeaderand aSegmentedGroupofExpenseRow/SettlementRowin AC-8.10 order; settlement amounts are the allocation to this account (AC-11.7). 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):
- Name field "Namn" (1–60, focused on open for a new account; capitalisation
Sentences). - 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);ButtonGrouppresets "40/60 · 50/50 · 60/40"; preview box (surface-container-high, radius 16, padding 12 / 14,calculateicon): "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):
AmountDisplay(focused on open for a new expense).DescriptionField"Beskrivning".CategoryQuickRow(five tiles + "Alla").- Chips row: Konto · Datum · Betalt av · Delning (§3.7).
SplitPreview(when the split section is closed).SplitSection(when open: after a tap on Delning, or always when the mode is notdefaultin edit mode).NotesField("Lägg till anteckning", or the field when notes exist).- Edit mode: "Ta bort utgift" (danger text button,
delete) at the end. AmountKeypad;CompactEditorSummarypinned 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
defaultre-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
previewis not null; tap →save.SaveResult.Invalidmaps eachFieldErrorinline: amountrequired"Skriv ett belopp", out of range "Beloppet måste vara mellan 0,01 kr och 10 000 000,00 kr"; descriptionrequired"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):
- Hero card (fill
cat-<token>-container, padding 20, gap 12):CategoryTile60 + description (title-large, wraps) over "Mat & hushåll · Vardag" (body-medium muted); amountMoneyText(Large)with the pending marker (20) after it when pending; badge "Återkommande: Hyra" (lantern,event_repeat) whenrecurringTemplateName != null(AC-8.11). - Facts (
FactRows): 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 thinSplitBar, "Du 623,80 kr · Anna 623,80 kr"); Anteckning (notes, full text) only when notes exist. 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):
DirectionCard.- Amount field "Belopp" (filled, value
amount-large, suffix "kr" title-large muted; system decimal keyboard; §9.6 input rules). - Header "Fördelning" (title-small) and a
SegmentedGroupofAllocationRows (SettleState.lines). RemainingIndicator(global variant only).- "Lägg till konto" (text,
add) and, after a row was edited, "Föreslå igen" (text,refresh). - Info line (global variant, when an account where the payer is owed is not in the rows, §9.4):
info18 + 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." issettle_untouched_you_owe). - Chip "Datum Idag" (date picker) and field "Anteckning" (0–200, placeholder "Till exempel Swish").
- Bottom bar (
surface, 1 dpoutline-varianttop line, padding 10 / 16): "Bekräfta" (filled medium, full width,check), enabled only whencanConfirm.
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
recomputewithallocations = 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
overSettlesrow → 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):
- Hero card (fill
settlement-hero, padding 22 / 20, centred, gap 8): payer avatar 52 (Sunny) →arrow_forward28 → recipient avatar 52; "Anna betalade dig" (title-medium);MoneyText(Large)with the pending marker when pending. - Facts: Datum ("tisdag 6 oktober 2026"), Anteckning (when present).
- "Fördelning" header and a group: one row per allocation (
AccountTile36, name title-medium,MoneyText(Small)). 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):
AnalyticsFilterBar(defaults AC-14.2–AC-14.5: Senaste 6 mån, Månad, Hushållet, Kategori).TotalCard.ChartCard"Utgifter över tid":StackedBarChartgrouped by the group-by toggle,ChartLegend.ChartCard"Per kategori":BreakdownListof categories.ChartCard"Per konto":BreakdownListof 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):
- Header card (fill
cat-<token>-containerfor a category,account-header-nfor an account,primary-containerfor a whole bucket; padding 18 / 20): tile 56 (CategoryTile,AccountTile, or a Cookie4primarytile withbar_chartfor a bucket) + "Totalt · Hushållet" / "Totalt · Min del" (label-large muted) overMoneyText(Large)over "4 utgifter" (body-small muted, plural). SegmentedGroupofExpenseRows, 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):
- Household card (padding 16 / 18, gap 12; role lines only, no device name:
SessionStatecarries 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). - Group 1:
SettingsRows "Profil" (person, orange, "Namn och bild"), "Kategorier" (category, green, "14 aktiva"), "Återkommande utgifter" (event_repeat, brown, "3 aktiva, 1 pausad" / "Inga"). - 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):
- 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). - 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):
SegmentedGroupof active categories insort_order: row min height 56, padding 4 / 12 / 4 / 8, gap 12: drag handle (drag_indicator, 32 × 48 target),CategoryTile36 solid, name (title-medium; custom name or localised default),chevron_right.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):
- Row: live preview
CategoryTile64 + 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). - "Ikon" (title-small) +
CategoryIconPicker(33). - "Färg" +
ColorSwatchPicker(14). - Footer row: "Arkivera" (outlined,
inventory_2) or "Återställ från arkiv" (outlined,unarchive); delete: default category →lock16 + "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 fromCategoryRepository.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):
- 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). - Status card (padding 20, gap 14): 52 dp Sunny tile (
owed-container+ filledcloud_done;surface-container-highest+sync_problemwhen 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). - "Synka nu" (filled medium, full width,
sync); disabled with a progress indicator while running; offline disabled with "Kräver anslutning". - "Väntande ändringar (2)" header + group of pending rows:
CategoryTile36 (settlements: the settlement tile), description (title-medium, ellipsis), "Väntar på synk" withcloud_upload15 inpending(body-small),MoneyText(Small); tap → S-8 / S-10. Rows carry no change-kind tag ("Ny" / "Ändrad" / "Borttagen" of U1):SyncStatusState.pendingis a list ofLedgerRows without one, and the tag is not needed for v1. - "Ändringar som inte synkades" header + one outlined card per rejection (1 dp
outline-variant, padding 16, gap 10): 36 dp tile (receipt_longfor an expense,handshakefor a settlement, onsurface-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"):
SegmentedGroupof templates (row min 72, top-aligned, padding 14 / 16):CategoryTile40 (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):
- 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). - After creation, in its place: a
tertiary-containercard (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 withsettings_invites_share_message) and "Kopiera kod" (outlined inon-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). - "Väntande inbjudningar" header + group: tile (
key,cat-yellowcontainer) + "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 FactRows: 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 = falseon open. Tapping any tile inCategoryQuickRoworCategoryGridSheetsets 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");
SplitPreviewunder the chips shows the shares. - One tap on the chip opens
SplitSectionin place (spatial default spring) and scrolls it into view; the chip turnssecondary-containerwithexpand_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
defaultre-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
- Quick-row usage data (UX-9).
CategoryQuickRowneedsExpenseEditorOptions.frequentCategoryIds(proposed inproposed-decisions.mdUX-9: per active account, up to five active categories by use count in that account, ties bysort_order, from the Room copy). Until it lands the row uses the fallback of §3.7. - Category deletability (UX-9).
S-15-category-editor-customneedsCategoryRepository.deletable(id)(UX-9). Until it lands, "Ta bort kategori" is enabled for custom categories andFailed("category_in_use")shows the in-use reason inline after the tap. - 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).
- System font on One UI.
FontFamily.Defaultmay 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). - 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.