Files
kvitt/docs/ux/design-spec.md
T
jacobh2 48fe119ae1
Build Kvitt images / build (push) Failing after 1m24s
SPEC v0.18: D-109 to D-117; validation reports; HANDOFF, prompts
- D-109 S-16 lost version read-only (Rejection.lost: RejectedVersion), D-110 S-22 versions from /health
  (AboutState.schemaVersion), D-111 SettlementRepository nullable start/recompute, D-112 E-17 retry,
  D-113 invite-partner and the push sent line, D-114 picture 16 MP / 64 MB and fcm_token 512 limits,
  D-115 rate-limit token acceptance (revises D-032), D-116 private notifications (AC-13.9) and L-6 as
  built (OQ-6), D-117 infra security (Caddy log redaction and headers, non-root Caddy, dev secret,
  digest record, key form).
- §10.5.2 quotes infra/pi/Caddyfile at 7fb86f5; §10.4 invite-partner row; §11.2 M12f row; §11.3 server
  gap pass, server overhaul, infra notes; new §11.4 validation reports; §14.7 server tree; strings.csv
  562 rows; localeFilters, work-testing, harness fix, APK 10.0 MB.
- docs/validation/ac-coverage.md and security-review.md committed verbatim from the reports.
- Commit hashes in SPEC, decisions, HANDOFF and prompts/README remapped to the rewritten history (18c527f).
- design-spec: S-16 lost-version dialog, S-22 "(schema <n>)", the S-1 rationale mention dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:51:42 +02:00

121 KiB
Raw Blame History

Kvitt: design spec (U2)

Status: binding design spec for v1, SPEC §13.4 phase 2, written 2026-10-18 for the owner's pick: direction 3 "Kalas", muted (UX-1), typeface per D-020. It replaces U1's screens.md, components.md and tokens.md.


1. How to use this file

Who reads it. M6 builds :core:design from §2 (tokens) and §3 (components). M7a–M7f build their screens from §4 (one subsection per screen) and §5 (the add-expense flow). Every screenshot test of a feature covers exactly the states listed under "States" for its screen (SPEC §13.3: this list is the binding one).

Sources of truth.

Question Source Wins over
Rules (limits, rounding, sync, who may do what) SPEC.md everything here
Look, layout, sizes, states, interaction, TalkBack structure this file the PNGs
Exact words (sv, en), keys, plurals strings.csv the quoted words here
Pictures of each state screens/index.html and screens/<frame-id>-light.png / -dark.png nothing; a PNG that disagrees with this file is fixed
Category suggestion rule category-suggestion.md (CS-1 … CS-7), keyword table category-keywords.csv —
Navigation, screen inventory, data ownership ia.md; flows flows.md —

Swedish words in quotes are as they appear on screen; strings.csv holds the final wording and the English. Keys cited in parentheses (editor_amount) are the keys of the current strings.csv (D-017 module prefixes: onboarding_, home_, account_, expense_, settle_, analytics_, settings_; :core:design keys such as common_, editor_, ledger_, balance_, update_, snackbar_ carry no feature prefix). Every label quoted here has a key in strings.csv. directions/3-kalas/index.html is the U1 record the token values were copied from; it is not maintained.

Decisions this file builds on. UX-1 … UX-8 (proposed-decisions.md) are recorded in SPEC v0.8 as D-047 … D-054 (UX-1 D-047, UX-2 D-048, UX-3 D-049, UX-4 D-050, UX-5 D-051, UX-6 D-052, UX-7 D-053, UX-8 D-054). Still open: the §9.7 hex values of D-051 are TODO(U2) in SPEC v0.8 and are the values of §2.3 here; UX-9 is not yet in the spec. Labels used in this file:

Label What this file assumes Where it matters here
UX-1 Direction Kalas, muted palette; Roboto all of §2 and §3
UX-3 Category suggested from the description (CS-1 … CS-7), ExpenseRepository.suggestCategory §3.7 CategoryQuickRow, §4 S-7, §5
UX-4 S-1 has a required "Ditt namn" field on a first pairing §4 S-1
UX-5 §9.7 colour hex values replaced by the muted values of §2.3; two new tokens slate and amber (ColorToken.SLATE, ColorToken.AMBER) §2.3, S-15 colour swatches
UX-6 No "Ångra" after saving an expense; FAB hidden on an archived account; per-account settle-up keeps the direction swap and an editable amount; avatar colours are two tokens chosen by user id §2.5, S-5, S-7, S-9
UX-8 14 default categories (new housing, insurance, children, fees), 33 icon tokens (new key, shield, account_balance), DefaultCategoryKey with 14 values, CategoryIcons.ALL with 33 §2.13, S-7, S-11, S-15; the D-018 empty fake scenario then has 14 defaults, not 10
UX-9 Contract additions: ExpenseEditorOptions.frequentCategoryIds and CategoryRepository.deletable(id), with fallbacks until they exist §3.7 CategoryQuickRow, S-15 editor, §6

UX-2 (three tabs) changes nothing; UX-7 (import mapping) has no UI.

Conventions used throughout.

  • Sizes in dp, text in sp. "Gutter" = 16 dp screen side padding. Touch targets are at least 48 × 48 dp everywhere (minimumInteractiveComponentSize); a control drawn smaller (36 dp chip, 40 dp button) keeps a 48 dp target.
  • "Kräver anslutning" (common_needs_connection): an online-only action (§9.9) while offline is shown disabled with that text next to it (supporting line, menu-item second line or banner), never silently disabled. Online state comes from ConnectivityRepository.online.
  • "Kunde inte nå servern" (common_could_not_reach_server): an online call returned OnlineResult.Failed("network"). Snackbar on the screen that made the call, with "Försök igen" when the call can be repeated; nothing changes locally (§9.9).
  • Other OnlineResult.Failed(code) values map to inline text when a field is at fault (name_taken under the name) and to a snackbar otherwise, text from strings.csv error_*.
  • Pending marker: cloud_upload icon in pending colour, TalkBack "Väntar på synk" (common_pending_sync), on every row or detail whose pending == true.
  • Amounts: tabular figures always; formatted by MoneyFormatter (§9.8: 1 234,50 kr with U+00A0, never a minus sign); spoken by MoneyFormatter.spoken ("1 234,50 kronor"). Amounts never ellipsize; at large font scale they move to their own line or wrap the unit.
  • Direction of money (owes / owed) is always words plus colour, never colour alone (§13.1).
  • "Viewer" = the person holding the phone, always "du" / "Du"; the partner by display name. Wherever two people are shown side by side, the viewer is on the left.
  • State names: loaded, empty, loading, offline, error, long (long Swedish names, 7-digit amounts, 200 % font scale) and named variants. PNGs exist for every state in §4: screens/<frame-id>-light.png and -dark.png; 200 % and English frames are light only.

2. Tokens

2.1 Material 3 colour roles

Light and dark. Contrast is WCAG 2 against the background named; text needs ≥ 4.5:1, icons and graphics ≥ 3:1. card (§2.2) is the fill of most content cards.

Role Light Dark Contrast L / D
primary #9C5238 #E6B5A2 5.3 / 9.6 on surface; 5.7 / 8.4 on card
on-primary #FFFFFF #4A1F10 5.7 / 7.7 on primary
primary-container #F2DED4 #6A3826 —
on-primary-container #3A1609 #F6DCD0 12.5 / 7.3
secondary #5A6D52 #BCCBB1 5.2 / 10.3 on surface
on-secondary #FFFFFF #27341F 5.6 / 7.7
secondary-container #DDE6D3 #3D4A35 —
on-secondary-container #172410 #D9E6CD 12.6 / 7.2
tertiary #76612F #DCC690 5.6 / 10.5 on surface
on-tertiary #FFFFFF #3A2E0B 6.0 / 7.9
tertiary-container #EEE3C6 #55432E —
on-tertiary-container #261C04 #F1E3C4 13.1 / 7.4
error #B3261E #F2B8B1 6.1 / 10.2 on surface; 6.5 / 9.0 on card
on-error #FFFFFF #5F1410 6.5 / 7.7
error-container #F9DEDA #73302A —
on-error-container #410E0B #F9DEDA 12.8 / 7.5
surface #FAF7F2 #1B1917 —
on-surface #22201D #ECE6DF 15.2 / 14.1
surface-variant #E8E2D9 #4A4540 —
on-surface-variant #4E4842 #CDC5BC 8.4 / 10.3 on surface; 9.0 / 9.0 on card
surface-container-lowest #FFFFFF #151311 —
surface-container-low #F5F1EB #221F1D —
surface-container #F0EBE4 #272422 —
surface-container-high #EAE5DD #312E2B —
surface-container-highest #E4DED6 #3C3835 —
surface-dim #DCD6CE #1B1917 —
surface-bright #FAF7F2 #423E3A —
inverse-surface #36322E #ECE6DF —
inverse-on-surface #F6F1EA #312E2B 11.3 / 10.9
inverse-primary #E4B39F #9C5238 6.8 / 4.6 on inverse-surface
outline #7E766E #978E86 4.2 / 5.5 (graphics only)
outline-variant #D2CAC1 #4A4540 decorative only
scrim #000000 at 38 % #000000 at 38 % —

Disabled content and containers follow Material 3: content on-surface at 38 %, container on-surface at 12 %.

2.2 Kvitt semantic roles (KvittColors)

Role Light Dark Use Contrast L / D
owed #3D6A4C #A2CCB0 text and graphics: the partner owes the viewer; "Du lånade ut …" 5.8 / 9.8 on surface; 6.2 / 8.7 on card; 5.0 / 7.6 on surface-container-high
on-owed #FFFFFF #10331F on owed 6.2 / 7.8
owed-container #D6E7D9 #2E4A37 sync status tile —
on-owed-container #0F2416 #CBE7D3 12.7 / 7.4
owing #78497A #D8B5D6 text and graphics: the viewer owes the partner; "Du lånade …" 6.5 / 9.6 on surface; 6.9 / 8.4 on card; 5.5 / 7.4 on surface-container-high
on-owing #FFFFFF #3C1F3C 6.9 / 7.9
owing-container #EEDDEC #553A55 —
on-owing-container #2D1430 #F1DDEF 12.9 / 7.7
settled #6A5A2B #E0CA94 "Kvitt", seesaw plank when settled 6.3 / 10.9 on surface; 5.3 / 5.9 on settled-container
settled-container #EEE3C6 #55432E settled hero card —
on-settled-container #261C04 #F1E3C4 13.1 / 7.4
warning #8A5522 #EDBE8C over-settle, remaining-to-allocate, offline banners 5.8 / 10.3 on surface
on-warning #FFFFFF #452B0E 6.2 / 7.7
warning-container #F5E1CA #5B3F24 warning strips and banners —
on-warning-container #2C1800 #F6DFC6 13.3 / 7.5
pending #3C6680 #A3C7DE pending marker, pending timeline line 5.8 / 9.8 on surface; 6.2 / 8.7 on card
avatar-1 #8A5A33 #DDB693 initials background, slot 1 (§2.5) —
on-avatar-1 #FFFFFF #3A230F 5.8 / 7.8
avatar-2 #4A6587 #ADC3DD initials background, slot 2 —
on-avatar-2 #FFFFFF #1C2F45 6.0 / 7.5
card #FFFFFF #272422 fill of content cards, segmented list items, account cards —
on-cat #FFFFFF #1B1917 icon or letter on a solid category or account tile ≥ 3.9 / ≥ 4.7 on every category colour; ≥ 4.8 / ≥ 8.2 on every chart colour
shadow #3C2819 at 8 % #000000 at 25 % card shadow colour (§2.10) —

Derived tints (fixed values, computed as the first colour mixed over the second):

Tint Rule Light Dark
settlement-row primary-container 28 % over card #FBF6F3 #3A2A23
settlement-hero primary-container 50 % over card #F8EEEA #482E24
sync-pill-pending pending 13 % over surface #E1E4E3 #2D3031
pending-badge pending 14 % over card #E4EAED #383B3C
account-header-n chart-cat-n 12 % over card n = 1 … 8: #F4EBE8, #ECEFEA, #F1EDE6, #EAEEF3, #EFEBF1, #E8EFEE, #F4EBEE, #EDEEEE #3E342F, #373830, #3C362D, #353638, #3A3437, #333734, #3E3434, #383635

on-surface on sync-pill-pending: 12.7 / 10.7; pending on pending-badge: 5.1 / 6.3.

2.3 Category colours (§9.7 tokens; UX-5)

Fourteen tokens. Light and dark values; the container is the colour mixed over surface at 18 % (light) and 24 % (dark). Category colours are never used for text.

Token Light Light container L: vs surface / vs card / vs container / on-cat on it Dark Dark container D: vs surface / vs card / vs container / on-cat on it
green #4E7A55 #DBE1D6 4.6 / 5.0 / 3.7 / 5.0 #8FB896 #373F35 7.9 / 7.0 / 4.9 / 7.9
orange #B26A3E #EDDED2 3.9 / 4.2 / 3.2 / 4.2 #E8AA7E #4C3C30 8.7 / 7.7 / 5.3 / 8.7
brown #6B5244 #E0D9D3 6.7 / 7.2 / 5.2 / 7.2 #B8A090 #413934 7.1 / 6.2 / 4.6 / 7.1
yellow #9C7C2A #E9E1CE 3.7 / 3.9 / 3.0 / 3.9 #E6C67A #4C432F 10.6 / 9.3 / 5.9 / 10.6
blue #4A78A8 #DAE0E5 4.3 / 4.6 / 3.5 / 4.6 #98C0E6 #394149 9.2 / 8.1 / 5.4 / 9.2
teal #367F82 #D7E1DE 4.4 / 4.7 / 3.5 / 4.7 #8CCAC8 #364341 9.5 / 8.4 / 5.6 / 9.5
purple #865891 #E5DAE1 5.1 / 5.5 / 4.0 / 5.5 #C59CDA #443846 7.6 / 6.7 / 4.8 / 7.6
red #A84B45 #EBD8D3 5.2 / 5.6 / 4.1 / 5.6 #DC8F86 #493532 6.9 / 6.1 / 4.5 / 6.9
pink #AE5D7C #ECDBDD 4.2 / 4.5 / 3.4 / 4.5 #EFB0BE #4E3D3F 9.7 / 8.5 / 5.6 / 9.7
grey #6C7177 #E0DFDC 4.6 / 4.9 / 3.7 / 4.9 #B0B2B3 #3F3E3C 8.2 / 7.2 / 5.0 / 8.2
indigo #45508D #D9D9E0 7.1 / 7.5 / 5.4 / 7.5 #969DD8 #393945 6.8 / 6.0 / 4.4 / 6.8
lime #73822F #E2E2CF 4.0 / 4.2 / 3.2 / 4.2 #B9CC86 #414432 10.1 / 8.8 / 5.7 / 10.1
slate (new, UX-5) #5E2D4E #DED3D4 10.0 / 10.7 / 7.3 / 10.7 #B0719A #3F2E36 4.7 / 4.2 / 3.4 / 4.7
amber (new, UX-5) #937D63 #E7E1D8 3.7 / 3.9 / 3.0 / 3.9 #DFCFC0 #4A4540 11.5 / 10.2 / 6.2 / 11.5

All values are graphics ratios (≥ 3:1 required, all pass; the tightest are light yellow 3.03 and amber 3.02 on their containers, used only for unselected picker tiles where the name is printed beside the icon).

Use:

Where Fill Icon
Solid tile (ledger rows, detail hero tile, selected picker tile, chart segments, breakdown bars, legend swatch) cat-<token> on-cat
Container tile (unselected picker tile in CategoryQuickRow and CategoryGridSheet) cat-<token>-container cat-<token>
Expense detail hero card, drill-down header card cat-<token>-container —
Settings row tiles (§3.11 SettingsRow) cat-<token>-container cat-<token>

2.4 Chart and account colours

Accounts get chart-cat-n by their position in AccountRepository.all() (created_at ascending, archived included), n = position mod 8 + 1. The same colour is the account's tile (§3.4) on Home, the tint of its header on S-5 and its series in S-11. Categories in charts always use their own category colour.

Token Light Dark vs card L / D (graphics) on-cat on it L / D (text)
chart-cat-1 #A75B40 #E3A68E 5.0 / 7.4 5.0 / 8.4
chart-cat-2 #5E7A4E #ABC79A 4.8 / 8.3 4.8 / 9.5
chart-cat-3 #8B6A2C #D8BD7E 5.0 / 8.4 5.0 / 9.6
chart-cat-4 #4C6E98 #9EB9DC 5.3 / 7.7 5.3 / 8.7
chart-cat-5 #7C5A88 #C8A6D2 5.7 / 7.2 5.7 / 8.2
chart-cat-6 #3F7774 #8FC2BC 5.1 / 7.8 5.1 / 8.9
chart-cat-7 #A2566F #E3A6BA 5.1 / 7.7 5.1 / 8.7
chart-cat-8 #6A6F74 #B5B9BD 5.1 / 7.8 5.1 / 8.9
chart-seq-1 #F2DFD6 #3E2C25 1.3 / 1.2 (fill behind a label only) —
chart-seq-2 #E2B7A5 #6A4234 1.8 / 1.8 (fill behind a label only) —
chart-seq-3 #C08068 #9A6049 3.2 / 3.0 —
chart-seq-4 #A75B40 #CC8A70 5.0 / 5.5 —
chart-seq-5 #763A26 #E6B5A2 8.7 / 8.4 —
chart-grid #E6E0D8 #36322F decorative —
chart-axis-label #4E4842 #CDC5BC 9.0 / 9.0 (text) —

Stacked segments are separated by a 2 dp gap so neighbouring colours never touch. Sequential tokens are reserved (no v1 screen uses them).

2.5 Avatar colours (UX-6, AC-4.5)

Initials (first letter of the first two words of the display name, uppercase, AC-4.5) on avatar-1 / on-avatar-1 or avatar-2 / on-avatar-2. Slot rule: of the household's users, the one whose id sorts first (plain string comparison of the lowercase UUID) is slot 1, the other slot 2; a household with one user has only slot 1. User ids are UUIDv7, so in practice the admin (created first) is slot 1 on both phones, and the two people never share a colour. The split bar (§3.7) uses the same two colours for the two people.

2.6 Dynamic colour

Not offered. Category, account, owed/owing and settled colours carry meaning and a wallpaper scheme cannot recolour them coherently (UX-1). The theme follows the system light/dark setting only.

2.7 Typography

Typeface: the Material 3 default (FontFamily.Default, Roboto on Android 16), no bundled font (D-020). Every amount style sets fontFeatureSettings = "tnum"; Roboto figures are already tabular, the feature is set so a different system font stays aligned.

Style Size / line height (sp) Weight Letter spacing (sp) Used for
display-large 57 / 64 500 −0.25 unused
display-medium 45 / 52 500 0 unused
display-small 36 / 44 600 0 "Kvitt" on S-1
headline-large 32 / 40 600 0 unused
headline-medium 28 / 36 600 0 "Ni är kvitt", S-18 title, S-22 version
headline-small 24 / 32 600 0 empty-state titles, dialog titles, "kr" after the editor amount
title-large 22 / 28 600 0 top app bar titles, section headers ("Konton")
title-medium 16 / 24 600 0.15 row titles, balance wording in the hero, card titles
title-small 14 / 20 600 0.1 month headers, group headers, banner titles
body-large 16 / 24 400 0.5 field values, detail fact values, empty-state body
body-medium 14 / 20 400 0.25 effect line, secondary row text, banner body
body-small 12 / 16 400 0.4 dates in rows, captions, row meta
label-large 14 / 20 600 0.1 buttons, "Totalt"
label-medium 12 / 16 500 0.5 field labels, account-card wording, nav labels
label-small 11 / 16 500 0.5 badges, category tile labels (11 / 14)
amount-hero 44 / 52 700 −0.5 Home balance, editor amount
amount-large 28 / 36 700 −0.25 detail amounts, S-5 balance, analytics total, S-9 amount field
amount-medium 18 / 24 700 0 account-card amount, allocation fields, compact editor row
amount-small 15 / 20 600 0 ledger row amounts, breakdown amounts

Units next to large amounts: the hero's "kr" is 24 / 30 sp weight 700 in the amount's colour; the editor's "kr" is headline-small in on-surface-variant. English puts "SEK" before the number (§9.8) with the same unit style.

Font scale. Compose's default non-linear font scaling (Android 14+) applies: text of 24 sp and larger grows less than the system scale (about 1.5× at 200 %), smaller text grows fully. Two explicit caps, implemented by converting a dp-based size (with(LocalDensity) { (12.dp * min(fontScale, 1.6f)).toSp() }): bottom-navigation labels at 1.6×, the seesaw name labels at 1.15×; the account tile letter at 1.3×. Nothing else is capped. Check on the S26 in M6 that FontFamily.Default resolves to Roboto with tabular figures under every One UI font style (open point 4).

2.8 Spacing

Scale (KvittSpacing): s1 4, s2 8, s3 12, s4 16, s5 20, s6 24, s8 32, s10 40, s12 48 dp.

Measure dp
Screen gutter 16
Gap between cards and sections in a scrolling column 12 (S-4, S-7), 10 (detail screens, S-9, S-11, settings sub-screens)
Gap between items of a segmented group 3
Card padding 16–20 (per component)
Row padding 10 vertical, 16 horizontal; min height 56 (settings, detail facts) or 72 (ledger)
Gap between leading tile and text 14 (rows), 12 (cards)
Bottom padding of a scrolling column under a FAB 120
Top app bar height 64
Bottom navigation 64 + system navigation inset

2.9 Shapes

M3 shape scale: extraSmall 4, small 8, medium 12, large 16, largeIncreased 20, extraLarge 28, extraLargeIncreased 32, extraExtraLarge 48, full.

Component Shape
Buttons, chips, keypad keys, sync pill, badges, nav indicator (56 × 32, radius 16), connected-button outer ends full
Connected-button inner corners small 8
Text fields (filled, no indicator line) large 16, all four corners
Menus, info boxes inside cards large 16
Warning strips inside cards, menu items medium 12
FAB (56 dp) largeIncreased 20
Cards, balance hero, dialogs, keypad sheet top corners, flat sections extraLarge 28
Account cards, segmented-group outer corners, banners 24
Segmented-group inner corners small 8
Allocation amount field 14
Icon grid cells (S-15) large 16
Chart bars top 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: none. Destination changes, tab switches and editors (S-7, S-9) appear at once, with no transition (D-108).
  • Reduced motion (system "Ta bort animationer", Settings.Global.ANIMATOR_DURATION_SCALE == 0): all springs become critically damped and immediate, the bunting appears at once, the hero amount does not count.

2.12 Icons

Material Symbols Rounded, optical size 24, grade 0, shipped as vector drawables generated from the font (Apache 2.0). Two variants:

Variant FILL Weight Used for
outline 0 500 app-bar actions, row leading icons in detail facts, chevrons, field icons
filled 1 600 category icons, status icons (cloud_done, warning, error), selected nav icon, banner icons, handshake, check badge (weight 700, size 14)

Icons are dp-sized (24 default; 18 in chips; 15–16 in row meta; 20 in buttons) and never scale with font size.

Category icon tokens (33, §9.7 + UX-8) in CategoryIcons.ALL order: shopping_cart, restaurant, local_cafe, home, bolt, water_drop, wifi, directions_car, train, local_gas_station, flight, hotel, luggage, movie, music_note, sports_esports, fitness_center, medical_services, medication, shopping_bag, checkroom, pets, child_care, school, card_giftcard, celebration, build, chair, subscriptions, category, key, shield, account_balance. Each has a Swedish and English TalkBack name in strings.csv (icon_<token>).

UI icons (outline unless marked filled):

Icon Where
home, bar_chart, settings bottom navigation (filled when selected)
add FAB, "Nytt konto", "Lägg till konto", "Ny kategori", "Ny återkommande"
close editor top bars (S-6, S-7, S-9, S-15 editor, S-19 editor)
arrow_back pushed screens
more_vert S-5 overflow
edit, delete S-8, S-10 actions; menu items
check Spara in editors, check badge, selected chips
swap_horiz S-9 direction swap; S-7 payer chip trailing
handshake (filled) "Gör upp", settlement tile, confirm dialog
cloud_upload pending marker
cloud_off offline sync pill, "Kräver anslutning" hints and banners
cloud_done (filled) synced pill and S-16 status
sync, sync_problem "Synka nu"; S-16 status when the last attempt failed
warning (filled), error (filled), info, check_circle (filled) warnings, errors, info lines, settled and "Kvar att fördela 0,00 kr"
chevron_right, expand_more, expand_less rows, dropdown chips, collapsible sections
inventory_2, unarchive archive badge and actions
event, call_split, notes, person, apps, backspace, calculate, date_range editor and detail facts, "Alla", keypad delete, S-6 preview, period chip
content_paste, link, dns, person_add, add_a_photo, photo_library S-1, S-2, S-3, S-14
receipt_long (filled) app mark, notification small icon
schedule, pause, event_repeat, drag_indicator, restart_alt, lock S-3, S-19, S-8, S-15
system_update, download, downloading, refresh, install_mobile S-17
phonelink_erase, smartphone, block, devices, add_link, key, share, content_copy S-18, S-20, S-21, S-13
memory, description, open_in_new S-22, S-16
arrow_forward S-9, S-10 direction

2.13 Default categories (UX-8)

Seeded by the server (SPEC AC-7.2, D-049 to D-054, SPEC v0.8; ids 11 to 14 added by server migration 0002). The localised name shows until a user renames the category.

Sort default_key Fixed id (SPEC §14.1) Swedish English Icon Colour
10 groceries 01920000-0000-7000-8000-000000000001 Mat & hushåll Groceries shopping_cart green
20 restaurants 01920000-0000-7000-8000-000000000002 Restaurang & café Restaurants restaurant orange
25 housing 01920000-0000-7000-8000-000000000011 Boende Housing key indigo
30 home 01920000-0000-7000-8000-000000000003 Hem Home home brown
40 utilities 01920000-0000-7000-8000-000000000004 Räkningar Utilities bolt yellow
45 insurance 01920000-0000-7000-8000-000000000012 Försäkringar Insurance shield slate
50 transport 01920000-0000-7000-8000-000000000005 Transport Transport directions_car blue
60 travel 01920000-0000-7000-8000-000000000006 Resor Travel flight teal
70 entertainment 01920000-0000-7000-8000-000000000007 Nöje Entertainment movie purple
80 health 01920000-0000-7000-8000-000000000008 Hälsa Health medical_services red
85 children 01920000-0000-7000-8000-000000000013 Barn Children child_care lime
90 shopping 01920000-0000-7000-8000-000000000009 Shopping Shopping shopping_bag pink
95 fees 01920000-0000-7000-8000-000000000014 Avgifter & skatter Fees & taxes account_balance amber
100 other 01920000-0000-7000-8000-00000000000a Övrigt Other category grey

Every default category has its own colour; teal is shared with the archived example custom category "Pant" (water_drop) in the frames only.

2.14 Compose mapping (:core:design)

Token group Compose
§2.1 lightColorScheme(...) / darkColorScheme(...), role names 1:1 (on-primary-container → onPrimaryContainer)
§2.2–§2.5 @Immutable data class KvittColors(...) with a light and a dark instance in LocalKvittColors; functions category(token: ColorToken), categoryContainer(token), account(index: Int) (chart-cat, 0-based position mod 8), avatar(slot: Int) / onAvatar(slot); properties for every role of §2.2 and the derived tints
§2.7 Typography(...) for the 15 M3 styles (default font family, weights and letter spacing above) plus KvittTypography(amountHero, amountLarge, amountMedium, amountSmall) in LocalKvittTypography
§2.8 object KvittSpacing (or LocalKvittSpacing) with s1 … s12, gutter, rowMin, ledgerRowMin
§2.9 Shapes(extraSmall … extraLarge) plus KvittShapes(categoryTile, accountTile, avatarLarge, illustration, card, accountCard, groupOuter, groupInner, field, fab) in LocalKvittShapes
§2.11 MotionScheme on MaterialTheme; named motions as constants in KvittMotion
Entry point KvittTheme(darkTheme: Boolean = isSystemInDarkTheme(), content), accessors KvittTheme.colors, .typography, .shapes, .spacing

ColorToken gains SLATE and AMBER (UX-5) and DefaultCategoryKey gains HOUSING, INSURANCE, CHILDREN, FEES (UX-8); KvittColors.category is exhaustive over the enum.


3. Components (:core:design)

3.1 Rules for every component

  • Colours, type, shapes and spacing only from KvittTheme (§2.14); no literal values in feature modules.
  • Strings from resources. Shared strings live in :core:design; components take resolved Strings for feature-specific words.
  • Every component takes a Modifier, has light/dark, Swedish/English and 100 %/200 % font-scale screenshot tests in M6 (SPEC M6), and keeps 48 dp touch targets.
  • Expressive APIs (@ExperimentalMaterial3ExpressiveApi) are used only behind a :core:design wrapper so the stable fallback named in the component swaps in at one place. The implementing agent confirms each API's status and signature in the official documentation at implementation time (CLAUDE.md › Fast-moving APIs). Summary:
Expressive Where Stable fallback
MaterialShapes (Cookie4Sided, Clover4Leaf, Sunny, Cookie9Sided) via toShape() category, account, avatar and illustration tiles RoundedCornerShape / CircleShape (§2.9)
ShortNavigationBar (64 dp) bottom navigation NavigationBar (80 dp)
ButtonGroup (connected) analytics toggles, ratio presets, full-split person picker SingleChoiceSegmentedButtonRow
Expressive slider look (thick track, thin handle) SplitSlider Slider with custom track and thumb
LoadingIndicator (shape morph) first sync on Home CircularProgressIndicator
LinearWavyProgressIndicator update download LinearProgressIndicator
Medium extended FAB (56 dp, radius 20) "Lägg till utgift", "Ny återkommande" ExtendedFloatingActionButton
Button sizes small (40) and medium (56) all buttons Button with heightIn
MotionScheme.expressive() theme MotionScheme.standard()
Segmented list items (grouped shapes) ledger, settings, devices, facts Surface per item with the §2.9 group shapes
  • Semantics. Rows merge their children into one node (Modifier.semantics(mergeDescendants = true)) with the default click action (no custom click labels); icon-only controls always have a contentDescription; decorative tiles, the seesaw, split bars next to their numbers and illustrations are cleared (clearAndSetSemantics {} or contentDescription = null). TalkBack text is Swedish or English per app language and reads amounts with MoneyFormatter.spoken.

3.2 Money and words

MoneyText(ore: Long, style: AmountStyle, tone: AmountTone, unit: Boolean = true)

  • AmountStyle: Hero, Large, Medium, Small → the four amount styles of §2.7. AmountTone: Neutral (on-surface), Owed, Owing, Muted (on-surface-variant), Settled.
  • Never a sign. Never ellipsized: one line; when it does not fit, Hero and Large put the unit on a second line; others wrap at the unit.
  • Semantics: MoneyFormatter.spoken(ore).

EffectLine(effect: ExpenseEffect) (§9.2), body-medium:

  • Lent(ore) "Du lånade ut 206,00 kr" in owed (effect_lent) · Borrowed(ore) "Du lånade 623,80 kr" in owing (effect_borrowed) · NotInvolved "Inte inblandad" · PaidForYourself "Betalade för dig själv" in on-surface-variant.

BalanceWording(netOre: Long, partner: User, scope: Global | Account) (§9.3): "Anna är skyldig dig" (owed), "Du är skyldig Anna" (owing), "Ni är kvitt" (global, settled) / "Kvitt" (account, settled). Used by BalanceHero, AccountCard, AccountBalanceHeader.

3.3 People

PersonAvatar(user: User, size: Dp, shape: AvatarShape = Circle)

  • Sizes in use: 20, 24, 28, 32, 40, 46, 52, 72, 112, 128.
  • Picture via Coil keyed by user.id + pictureVersion (AC-4.6), cropped to the shape; else initials on the slot colour (§2.5), initials size 44 % of the avatar (capped at 1.3× font scale). Sunny shape for the large standalone uses of §2.9, else circle.
  • States: picture, initials (also while the picture loads), uploading (a 4 dp CircularProgressIndicator in primary drawn around the avatar, picture or initials at 60 % opacity).
  • Semantics: the display name; decorative (null) when the name is printed beside it.

AvatarPlaceholder(size): the empty partner seat of S-3: circle, 2 dp dashed outline border, surface-container-high fill, person_add icon at 50 % of the size in on-surface-variant. TalkBack "Partner saknas".

3.4 Categories and accounts

CategoryTile(category: Category, size: Dp, style: Solid | Container, archived: Boolean = false)

  • Shape Cookie4Sided (fallback 30 % rounded). Sizes: 32 (breakdown), 36 (S-15 list, S-16, compact editor row), 40 (ledger rows), 52 (editor quick row and sheet), 56 (S-12 header), 60 (S-8 hero), 64 (S-15 editor preview).
  • Icon: filled Material Symbol at 52 % of the tile (iconRatio), colours per §2.3.
  • archived: saturation 25 %, opacity 75 %.
  • Semantics: decorative when the category name is printed next to it (always, except in the quick row where the label is the tile's own text).

AccountTile(name: String, colorIndex: Int, size: Dp = 44, muted: Boolean = false)

  • Clover4Leaf (fallback 16 dp rounded) filled with chart-cat-n (§2.4); the first grapheme of the name, uppercase, title 18 sp weight 700 in on-cat (capped 1.3×). muted (archived): opacity 70 %. Sizes 32, 36, 40, 44. Always decorative.

AccountCard(summary: AccountSummary, colorIndex: Int, onClick) (S-4)

  • Card, radius 24, card fill and shadow, padding 14 vertical / 16 horizontal, min height 76, gap 14.
  • Left: AccountTile 44. Middle: name (title-medium, 1 line, ellipsis) with an "Arkiverat" badge (inventory_2 14) for archived accounts; below it body-small on-surface-variant "11 utgifter i oktober" (home_account_expenses_month, plural; current month name) or "Inga utgifter i oktober". Right column, right-aligned: wording in label-medium and MoneyText(Medium) both in owed/owing; when netOre == 0: check_circle (filled, 20) + "Kvitt" in title-medium settled, no amount.
  • Archived card: card opacity 92 %, tile muted.
  • Long and 200 %: the right column wraps to its own line under the name, right-aligned (flex-wrap); the name keeps one line with ellipsis at 100 % and two lines at ≥ 1.5×.
  • Semantics: one node "Vardag, Anna är skyldig dig 1 184,50 kronor, 11 utgifter i oktober" (archived: "…, arkiverat"), role button.

CategoryQuickRow, CategoryGridSheet: see §3.7 (editor fields).

CategoryIconPicker(selected: String, color: ColorToken) (S-15): grid of the 33 icon tokens, 7 columns, gap 5, cells 48 dp high, radius 16. Unselected: surface-container-high fill, outline icon in on-surface-variant. Selected: cat-<color> fill, filled icon in on-cat. Single-select radio group; each cell TalkBack = the icon's name (icon_<token>), state "vald".

ColorSwatchPicker(selected: ColorToken) (S-15): 14 circles of 44 dp in a 7-column grid, gap 8, each the solid category colour. Selected: check icon 22 in on-cat plus a 3 dp surface ring and a 2 dp on-surface ring outside it (selection never by colour alone). TalkBack: colour name ("Grön", "Plommon", "Sand", … color_<token>), state "vald".

3.5 Ledger (D-019; used by S-5, S-12, S-16)

SegmentedGroup: vertical group of items 3 dp apart; outer corners 24, inner 8; item fill card (default) or surface-container-low (soft). Light: the group casts the card shadow once.

LedgerMonthHeader(period: String, householdSpendOre: Long): title-small "Oktober · 7 457,90 kr" (ledger_month_total), padding 12 top / 8 sides / 2 bottom; year appended when not the current year ("September 2025 · …"). Sticky (stickyHeader) on S-5 with a surface background. Semantics heading.

ExpenseRow(row: LedgerRow.ExpenseRow, onClick)

  • Item of a SegmentedGroup, min height 72, padding 10 / 16, gap 14.
  • CategoryTile 40 solid · centre: description (title-medium, 1 line, ellipsis), EffectLine below · right column: MoneyText(Small), below it body-small on-surface-variant date ("16 okt.", §9.8) preceded by the pending marker (15 dp) when pending.
  • 200 %: the right column moves under the centre column, left-aligned with the text, so the description gets the full width (still 1 line, ellipsis) and the amount is never cut.
  • Semantics: one node "ICA Maxi Lindhagen, 1 247,60 kronor, 16 oktober, du lånade 623,80 kronor, väntar på synk" (category name added after the description: "…, Mat & hushåll, …").

SettlementRow(row: LedgerRow.SettlementRow, onClick)

  • Same item geometry; fill settlement-row tint (§2.2).
  • Tile: 40 dp circle primary-container with filled handshake in on-primary-container. Centre: "Anna betalade dig 800,00 kr" / "Du betalade Anna 800,00 kr" (title-medium, ledger_settlement_*), plus " · 2 konton" when accountCount > 1; second line "Betalning" body-medium muted. Right: date with the pending marker.
  • Semantics: "Anna betalade dig 800,00 kronor, 2 konton, 6 oktober".

SyncPendingMarker(size = 15.dp): cloud_upload in pending, contentDescription "Väntar på synk" when standalone; merged into the row sentence otherwise.

3.6 Balance, sync and banners

BalanceHero(state: HeroState, onSettle) (S-4; seesaw)

  • Card, radius 28, padding 16 top / 20 sides / 20 bottom, content centred.
  • Seesaw (decorative), 250 × 92 dp: plank 220 × 8 (radius 4) pivoting at its centre on a fulcrum triangle 36 × 22 in outline-variant; the viewer's avatar (46, Sunny) on the left end, the partner's on the right end, 88 dp from the centre; name labels "Du" and "Anna" (12 sp weight 600, on-surface-variant, capped 1.15×) outside each avatar. The side of the person who owes sits lower: tilt 9°; plank colour owing (viewer owes), owed (partner owes), settled (level, 0°), outline (waiting, 12° down on the viewer's side).
  • Below: wording (title-medium, owed/owing), amount (amount-hero + "kr" at 24 sp 700, same colour), then "Gör upp" (filled, small 40 dp, filled handshake icon; home_settle_up) 14 dp below.
  • States:
State Content
Owed(net, partner) "Anna är skyldig dig", amount in owed, plank tilted toward the partner, "Gör upp"
Owing(net, partner) "Du är skyldig Anna", amount in owing, plank tilted toward the viewer, "Gör upp"
Settled card fill settled-container, text on-settled-container, bunting along the top (30 dp high: a curved outline line with 14 pennants in category colours green, orange, yellow, blue, teal, purple, red, pink, lime, orange, green, blue, yellow, pink), top padding 28, level plank, "Ni är kvitt" (headline-medium), "Ingen är skyldig något just nu." (body-medium); no amount, no button
Waiting (S-3) partner seat = AvatarPlaceholder, label "Partner", plank outline; content per S-3
Loading (S-4 first sync) no seesaw: LoadingIndicator 56 dp in a Cookie9 primary-container tile, "Hämtar era utgifter…" (title-medium), "Första synken hämtar allt som finns på servern. Det tar bara en stund." (body-medium muted); padding 28 / 20
  • Long: a 7-digit amount ("1 234 567,89 kr") fits one line at 100 %; at 200 % the hero amount grows about 1.5× and the unit wraps to its own line; wording wraps, never ellipsizes.
  • Semantics: the card is one node, contentDescription = "Du är skyldig Anna 3 250,00 kronor" / "Anna är skyldig dig …" / "Ni är kvitt"; the button is a separate node "Gör upp".

AccountBalanceHeader(detail: AccountDetailState, colorIndex: Int, onSettle, onUnarchive) (S-5)

  • Card, radius 28, padding 18 / 20 / 20, gap 10, fill account-header-n (§2.2).
  • Row: AccountTile 40 · "50 / 50 · Jacob och Anna" (title-medium; default shares in percent, viewer first, account_default_split) with a thin SplitBar (8 dp) below · "Arkiverat" badge when archived.
  • Balance: wording (title-medium) above MoneyText(Large), both in owed/owing; zero: check_circle 28 + "Kvitt" in headline-small settled.
  • Actions, left-aligned: "Gör upp det här kontot" (filled, handshake) when netOre != 0 (archived accounts too: they can still be settled, AC-6.4); for an archived account also "Återställ från arkiv" (tonal, unarchive), placed first.
  • Semantics: one node "Vardag, standarddelning 50 procent du, 50 procent Anna, Anna är skyldig dig 1 184,50 kronor"; button separate.

SyncStatusPill(summary: SyncSummary, online: Boolean, onClick) (S-4)

  • Centred pill, min height 40 (48 target), padding 6 / 10 / 6 / 14, radius full, body 13 sp weight 500; leading icon 18, trailing chevron_right 18.
  • Variants: synced: surface-container-high fill, cloud_done (filled) in owed, "Synkad för 3 min sedan" (sync_synced_relative, §9.8 relative time, refreshed every minute) · running: sync icon, "Synkar…" · pending: suffix " · 2 ändringar väntar på synk" (sync_pending, plural) · offline: sync-pill-pending fill, cloud_off in pending, "Offline · 2 ändringar väntar på synk" (or "Offline" alone) · warning: warning-container fill, warning icon, "1 ändring synkades inte" (sync_rejected, plural) or "Telefonens klocka går fel" (sync_clock_wrong_short) · failed: "Synk misslyckades · försök igen om en stund" in the warning style.
  • Never a blocking banner. Whole pill is one button: TalkBack "Synkstatus: ".

Banner(kind: Lantern | Error | Warning | Neutral, icon, title, body, actions): radius 24, padding 14 / 16 / 10, gap 6; icon 24 (filled) + title (title-small) + body (body-medium); actions right-aligned, text buttons in the banner's content colour. Lantern = tertiary-container, Error = error-container, Warning = warning-container, Neutral = surface-container-high. role status (alert for Error).

UpdateBanner(state: UpdateState, onUpdate, onLater, onRetry) (S-4, S-22): Available: Lantern, system_update, "Version 1.2 finns", "Nyheter och rättningar · 8,4 MB" (size from UpdateInfo.sizeBytes), actions "Senare" (text) and "Uppdatera" (tertiary filled, download); offline: "Uppdatera" disabled and the body reads "Kräver anslutning". Downloading: Lantern, downloading, "Laddar ner… 64 %", LinearWavyProgressIndicator in tertiary (determinate, track tertiary at 28 %), no actions; TalkBack progress "Laddar ner version 1.2, 64 procent". Failed: Error, error, "Nedladdningen misslyckades. Försök igen." with "Senare" and "Försök igen" (error filled, refresh). NeedsInstallPermission: Available look; "Uppdatera" opens the S-17 dialog.

NeedsConnectionBanner(body: String): Warning banner, cloud_off, title "Kräver anslutning", screen-specific body. NeedsConnectionHint: supporting line under a control: cloud_off 16 + "Kräver anslutning" in body-small weight 600 on-surface-variant, padding 4 / 16.

3.7 Expense editor fields (D-019; used by S-7 and S-19)

AmountDisplay(text: String, focused: Boolean, error: String?)

  • Centred column, padding 10 top / 6 bottom: label "Belopp" (label-medium muted; error colour on error) above the amount (amount-hero, tabular) with a 3 dp primary caret when focused, then "kr" (headline-small muted) on the same baseline; wraps the unit under the number when it does not fit.
  • Empty shows "0,00" in on-surface-variant as a hint, not a value.
  • The text is what the user typed, grouped with U+00A0 thousands separators while typing ("1 060", "1 060,5"); the decimal separator is the locale's (, sv, . en).
  • Semantics: "Belopp, 412 kronor" (empty: "Belopp, 0,00", the hint editor_amount_hint); error appended. Tapping it focuses it and shows the keypad.

AmountKeypad(onKey, onDelete, onClear, compact: Boolean)

  • Sheet at the bottom of S-7 and S-19: surface-container, top corners 28, padding 12 / 12 and bottom = 8 + navigation-bar inset; 3-column grid, gap 8.
  • Keys 1–9, decimal key, 0, delete. Each key: min height 52 (46 in compact, used while the split section is open), radius full, surface-container-highest, label 24 sp weight 600 (capped 1.5×). Delete key: primary-container fill, backspace 26 in on-primary-container.
  • Decimal key shows , (sv) or . (en); TalkBack "Decimaltecken". Delete: TalkBack "Radera" (editor_keypad_delete); long-press or the TalkBack custom action "Rensa" clears the field.
  • Input rules per §9.6, enforced key by key: digits; one decimal separator (a second is ignored); at most two decimals (a third digit is ignored); no sign; a value above 10 000 000,00 kr is not accepted (the key is ignored with a reject haptic); a leading 0 followed by a digit replaces the 0; decimal first gives "0,". Parsing to öre is string-exact (never floating point).
  • Each key gives HapticFeedbackType.KeyboardTap. The keypad drives the editor's money and percent fields: the amount, the exact-share field and the two ratio percent fields (percent: max 100, two decimals).
  • Visibility: shown while a keypad field has focus; hidden while the description or notes field has focus (the system keyboard shows instead) or nothing has focus. Back with the keypad visible clears focus and hides it (it does not close the editor).
  • 200 %: keys grow with their labels; the keypad keeps its four rows and the content above scrolls.

DescriptionField: filled text field (radius 16, surface-container-highest, no indicator, 2 dp primary inset outline when focused, 2 dp error on error), label "Beskrivning", single line, 1–100 code points, capitalisation Sentences, IME action Done (§4 S-7). Error supporting text with filled error icon.

CategoryQuickRow(options, selected: CategoryId, onPick, onAll) (UX-3)

  • A 6-column grid, gap 4: five category items and "Alla". Item: CategoryTile 52 (Container style; Solid when selected) above a label (11 / 14 sp weight 500 on-surface-variant, up to 2 lines, centred, no ellipsis; selected: on-surface weight 700).
  • Selected tile: solid, plus a check badge: 20 dp circle in on-surface with check (14, filled, weight 700) in surface, offset to the tile's top-right (−6 / −8 dp), with a 2 dp surface ring.
  • "Alla": tile in surface-container-highest with apps 26 in on-surface-variant, label "Alla"; opens CategoryGridSheet. TalkBack "Alla kategorier".
  • Which five: the five active categories used most often in the selected account (count of non-deleted expenses on the phone, ties by sort_order), filled up from sort_order when fewer than five have been used; shown in that order. If the selected category (suggested, default or picked in the sheet) is not among them, it takes the fifth place. The order does not change while the editor is open except for that fifth place. The usage list is ExpenseEditorOptions.frequentCategoryIds (UX-9, pending); until it exists the five are the selected category followed by the first active categories in sort_order.
  • 200 % (font scale ≥ 1.5): 3 columns, two rows, row gap 12.
  • Semantics: radio group "Kategori"; each item "Nöje, vald" / "Mat & hushåll"; picking sets the editor's "picked by hand" flag (CS-2).

CategoryGridSheet: modal bottom sheet "Välj kategori": all active categories in sort_order, 4 columns (3 at ≥ 1.5×), items as in the quick row; tapping picks and closes the sheet.

EditorOptionRows (S-7, D-102): the account, date, payer and split options as four full-width rows in a fixed order, Konto, Datum, Betalt av, Delning, as a segmented group (outer corners 24, inner 8, gap 3), surface-container-high, the Delning row secondary-container while the split section is open. Row: min height 48, padding 8 / 16, gap 12: the leading element of the chip table below in a 24 dp box (icons 24), the label (label-large on-surface-variant; editor_account, expense_editor_date, editor_paid_by, editor_split) in a column as wide as the widest label, the value (body-large, 1 line, ellipsis) and the trailing icon (24) at the right edge; the whole row is one button with the TalkBack sentences below. Rows never move when a value changes. 200 %: the label over the value, the value up to 2 lines, every row keeping the two-line height.

EditorChip (the chips row). Used by S-9 (date chip) only; S-7 (D-102) and S-19 (D-107) use EditorOptionRows. The editor chips row now holds only the date chip, used by S-9 (DateChip in EditorChipRow); the account, payer and split chips were removed after S-7 and S-19 moved to option rows (D-102, D-107). 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-9 only) event "Idag" / "Igår" / "14 okt." (§9.8 list format) expand_more Material DatePickerDialog, range per §9.6
Betalt av payer avatar 24 "Betalt av Du" / "Betalt av Anna" swap_horiz toggles between the two users (AC-8.3), no picker
Delning call_split "Delning Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna" expand_more / expand_less opens or closes SplitSection in place and scrolls it into view

TalkBack: "Konto: Vardag. Dubbeltryck för att byta konto"; "Datum: idag"; "Betalt av: du. Dubbeltryck för att byta till Anna" (editor_paid_by_toggle); "Delning: kontots standard 50/50".

AccountPickerSheet: modal bottom sheet "Välj konto": active accounts in created order (AC-6.4: archived not offered), each row AccountTile 40 + name + the account's wording and amount (body-small, coloured), check on the selected one; tap picks and closes.

SplitBar(viewerPercent: Float, thin: Boolean, error: Boolean, knob: Boolean): two rounded segments (radius full) separated by a 3 dp gap (6 dp with a knob): left = viewer in the viewer's avatar colour, right = partner in the partner's avatar colour, widths proportional; height 16 (8 thin); error: left segment error. Decorative next to printed numbers; otherwise contentDescription "Du 60 procent, Anna 40 procent".

SplitSlider (S-6, ratio mode): SplitBar 16 dp with a knob (4 × 44 dp, radius 2, on-surface) at the split point; drag in steps of 1 percentage point with a haptic tick at 50; semantics slider labelled with the left person's share ("Jacobs andel", editor_ratio_slider) and value text "Jacob 60 procent, Anna 40 procent" (editor_ratio_spoken) and ± 1 adjust actions.

SplitPreview(shares: List<Share>?): a 72 dp thin SplitBar then body-medium "Du 206,00 kr · Anna 206,00 kr" (amounts weight 700, tabular). Data only from ExpenseRepository.preview (the UI never computes shares); hidden while it returns null. Semantics "Du 206,00 kronor, Anna 206,00 kronor".

SplitSection(mode, …): flat section (surface-container-low, radius 28, padding 16, gap 12):

  • Header row: "Delning" (title-small) + expand_less icon button (TalkBack "Fäll ihop delning").
  • Mode chips: four single-select filter chips, wrapping: "Kontots standard 50/50" (live ratio of the selected account, editor_split_mode_default), "Egen fördelning", "Exakta belopp", "Allt på en person"; selected = secondary-container with check. Semantics radio group "Delning".
  • default: SplitBar 16 + SplitPreview text.
  • ratio (RatioEditor): the two names with avatars 32 at either end, SplitSlider, two percent fields side by side ("Jacob" / "Anna", suffix "%", keypad-driven, two decimals, typing one sets the other to 100 minus it), presets as a ButtonGroup "40/60 · 50/50 · 60/40" (viewer first), then SplitPreview.
  • exact (ExactSplitEditor): one row per person: avatar 40 + money field (label = name, suffix "kr", value amount-medium). The viewer's field is typed by default; the other is read-only (transparent fill, 1 dp outline-variant border) showing "beloppet minus …" as the computed value, or "—" while it cannot be computed, with trailing caption "räknas ut". Tapping the read-only field makes it the typed one (AC-8.7: one typed, the other derived). Typed share > amount: field in error with "Andelen är större än beloppet" (error_exact_share_too_large) and an error-container box "Jacob 1 200,00 kr av 1 060,00 kr" / "140,00 kr för mycket" (title-small); Spara disabled.
  • full (FullSplitPicker): label "Vem står för hela beloppet?" (label-large muted), a two-button ButtonGroup (min height 52; avatar 28 + name each), SplitBar 16 fully in the bearer's colour, SplitPreview.

NotesField: collapsed as a text button "Lägg till anteckning" (notes icon); expanded: filled multi-line field "Anteckningar", 3–8 visible lines, 0–1000 code points, newlines allowed, counter "120 / 1000" as supporting text.

CompactEditorSummary: when the AmountDisplay has scrolled out of view, a row pinned under the top app bar: CategoryTile 36 + description (title-medium, ellipsis) over the category name (body-small muted) + MoneyText(Medium); padding 6 / 20 / 10; 1 dp outline-variant bottom border. Tap scrolls to the top and focuses the amount.

3.8 Settle up components (S-9, S-10)

DirectionCard(payer, recipient, onSwap): card, padding 14 / 14 / 14 / 18, gap 12: payer avatar 40 (Sunny) → arrow_forward (on-surface-variant) → recipient avatar 40; label "Riktning" (label-medium muted) over "Du betalar Anna" / "Anna betalar dig" (title-medium); trailing tonal icon button swap_horiz (TalkBack "Byt riktning", settle_swap). Semantics: card text "Riktning: du betalar Anna" + the button.

AllocationRow(line: AllocationLine, colorIndex, fixed: Boolean, onAmount, onRemove): SegmentedGroup item, padding 12 / 14, gap 8:

  • Line 1: AccountTile 40 · name (title-medium, ellipsis) over the payer's position in body-small muted: "Skuld 3 084,50 kr" (the payer owes there, settle_row_debt); otherwise the account balance wording from the payer's side, "Anna är skyldig dig 1 184,50 kr" (balance_*_amount), or "Kvitt" (balance_account_settled) · amount field (148 dp wide, min height 48, radius 14, surface-container-highest, right-aligned amount-medium + "kr"; system decimal keyboard) · remove icon button close (TalkBack "Ta bort Huset från fördelningen", settle_row_remove). fixed: the amount is plain MoneyText(Medium) (it follows the settlement amount) and there is no remove button.
  • Over-settle (overSettles): field border 2 dp warning; under line 1 a strip (warning-container, radius 12, padding 8 / 12): filled warning 20 + "Det här reglerar mer än som är skyldigt i Japan 2027" (settle_over_settle_warning).
  • 200 %: name and position on line 1, the amount field full width on line 2, the remove button at the end of line 1.

RemainingIndicator(remainingOre): row, radius 16, padding 10 / 14, gap 10: zero → surface-container-high, filled check_circle in owed, "Kvar att fördela" + "0,00 kr"; positive → warning-container, filled error, "Kvar att fördela" + "250,00 kr"; negative → warning-container, filled error, "Kvar att fördela" + "250,00 kr för mycket" (common_too_much). Words always say which; semantics "Kvar att fördela 250,00 kronor". Live region polite.

3.9 Timeline (S-8, S-10)

Timeline(lines: List<TimelineLine>): header "Historik" (title-small, timeline_title) then a card (padding 16 / 16 / 16 / 12) with items oldest first, newest last (AC-12.4):

  • Item: a 24 dp gutter with a 12 dp dot in secondary and a 2 dp outline-variant connector to the next item; text body-medium. Sentence per FieldChange: "Anna skapade den här · 16 okt. 14:02"; "Du ändrade beloppet från 1 147,60 kr till 1 247,60 kr · 16 okt. 19:40"; "Anna ändrade kategorin från Övrigt till Mat & hushåll · 16 okt. 20:05"; "Anna tog bort den här · 17 okt. 09:12"; recurring source "Skapad automatiskt (återkommande) · 25 sep. 06:00"; import source "Importerad från Splitwise · …". Several changes in one entry are one line each with the same time. Values formatted with MoneyFormatter/DateFormatter; field names from timeline_field_*.
  • pendingLocal: hollow dot (2 dp pending ring), text in pending weight 600 with a 16 dp cloud_upload before it: "Din ändring väntar på synk" (AC-12.5); always last.
  • Semantics: each item one node; the header is a heading.

3.10 Charts (S-11; Vico or the Canvas fallback, §6.2)

ChartCard(title, content): card, padding 18 / 16 / 16, title title-medium; an invisible summary sentence as contentDescription of the chart area.

StackedBarChart(buckets, groups, onBucket)

  • Plot 170 dp high, y-axis gutter 44 dp on the left: gridlines (1 dp chart-grid) at 3–4 round values with labels in whole kronor (10 / 12 sp, chart-axis-label, sv "15 000", en "15,000"); x labels under each bar (11 / 14 sp: "maj", "jun" or the week number "41" with the caption "Vecka" under the axis).
  • One bar per bucket (every bucket of the range, empty ones as zero, AC-14.7), gap 10, top corners 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; connected buttons: 8 dp side padding (D-102). 200 %: one control per row. Semantics: each group a radio group named "Indelning", "Mått", "Gruppera efter".

TotalCard(totalOre, caption): card, padding 16 / 20: "Totalt" (label-large muted; "Totalt · Min del" for my share) over MoneyText(Large) over the range caption (body-small muted, "maj–okt 2026 · Hushållet").

3.11 Scaffolding and common controls

KvittScaffold: edge-to-edge; top app bar slot, content, optional FAB, optional bottom bar, snackbar host above the FAB and navigation bar; status-bar inset handled by the app bar.

KvittTopAppBar(title, navigation: Back | Close | None, actions): 64 dp, surface (scrolled: surface-container), navigation icon button 48 (arrow_back TalkBack "Tillbaka" / close TalkBack "Avbryt"), title title-large 1 line ellipsis (pad 16 when there is no navigation icon), optional subtitle body-small muted. Top destinations (S-4) show the wordmark instead of a title: a 22 dp Sunny primary dot with a filled receipt_long (15) in on-primary, then "Kvitt" 24 sp weight 700 primary (capped 1.5×); semantics heading "Kvitt".

KvittNavigationBar(selected): Expressive ShortNavigationBar (fallback NavigationBar), surface-container, items 64 dp high: indicator pill 56 × 32 (primary-container when selected) with the icon (filled when selected), label label-medium (selected: on-surface weight 700) capped at 1.6×. Items: "Översikt" home, "Statistik" bar_chart, "Inställningar" settings (nav_*). Labels always visible.

AddExpenseFab(expanded: Boolean): medium extended FAB, 56 dp, radius 20, primary / on-primary, add 24 + "Lägg till utgift" (16 / 22 sp weight 600), 16 dp from the right edge and above the navigation bar; collapses to the 56 dp icon-only FAB while the list scrolls down and at font scale ≥ 1.5; TalkBack "Lägg till utgift" in both forms. Same component with "Ny återkommande" on S-19.

Buttons: radius full, label-large weight 600, icon 20. Small 40 dp (padding 8 / 20), medium 56 dp (padding 12 / 26, 16 sp, icon 24). Kinds: filled (primary), tonal (secondary-container), tertiary (tertiary), outlined (1 dp outline-variant, on-surface-variant), text (primary), danger text (error), error filled (error / on-error).

Badge: radius full, padding 2 / 10, label-small weight 600; neutral (surface-container-highest / on-surface-variant), lantern (tertiary-container), pending (pending-badge / pending). Icon 14.

SectionHeader(title, action): min height 48, padding 8 / 4 / 0 / 8, title title-large (heading semantics), optional trailing text button.

CollapsibleRow(title, count, expanded): min height 48, padding 6 / 8, inventory_2 + "Arkiverade (1)" (title-small) + expand_more/expand_less; semantics button with state "utfälld"/"hopfälld".

FactRow(icon, label, value) (detail facts in S-8, S-10, S-22): SegmentedGroup item, min height 56, padding 10 / 16, gap 14: outline icon in on-surface-variant, label (label-medium muted) over value (body-large). Merged node "Datum: fredag 16 oktober 2026".

SettingsRow(icon, color: ColorToken, title, subtitle, enabled): SegmentedGroup item, min height 56: tile 40 (Cookie4, cat-<color>-container fill, icon 22 in cat-<color>), title (title-medium) over subtitle (body-small muted), trailing chevron_right. Merged node, role button.

EmptyState(illustration, title, body, action): flat section (surface-container-low, radius 28, padding 28 / 24), centred, gap 10: decorative illustration (three rotated category tiles, ~170 × 110 dp), title headline-small, body body-large muted, optional medium button.

ConfirmDialog(icon, title, body, confirm, dismiss, destructive): surface-container-high, radius 28, padding 24, gap 14, 24 dp from the screen sides; centred 52–56 dp Sunny icon tile (destructive: error-container / on-error-container; otherwise secondary-container or tertiary-container); title headline-small centred; body body-medium muted; buttons right-aligned (dismiss text, confirm filled or error filled). Scrim 38 %.

KvittMenu: surface-container, radius 16, padding 6, min width 244, max 300, level 3. Items min height 48, padding 12, radius 12, icon + body-large; a disabled item shows its reason as a second line (body-small) at 50 % opacity and is still announced with that reason.

Snackbar: inverse-surface, text inverse-on-surface, action inverse-primary, radius 16; 4 s without an action, 10 s with one.

InviteCodeDisplay(code): three groups of four on surface chips (radius 14, padding 6 / 10), 24 / 32 sp weight 700, letter spacing 1.5, wrapping centred; selectable. TalkBack spells it per group: "7 K 3 M, Q X 9 D, 2 H P A".

InviteCodeField: filled field "Inbjudningskod", value title-large, uppercase, inserts the hyphens and normalises per §8.3 while typing (O → 0, I/L → 1), keyboard Ascii with Characters capitalisation; trailing "Klistra in" text button (content_paste), which also accepts a full invite link and fills the server from it.

Skeleton: surface-container-highest blocks, radius 12 (pills full), no shimmer under reduced motion; only for the first-sync and online-fetch loading states.


4. Screens

Each screen: layout top to bottom, the binding states (exactly the frames of the screen catalogue; PNG screens/<frame-id>-light.png and -dark.png, "light only" where noted), interactions, TalkBack, and 200 %. Modules and navigation are in ia.md.

S-1 Pairing (:feature:onboarding)

Layout (no app bar, no navigation; scrolling column, gutter 16, gap 14; bottom bar with the button):

  1. Brand block, centred, padding 28 / 0 / 12: app mark 88 dp (Sunny, primary, filled receipt_long 44 in on-primary), "Kvitt" (display-small, primary), "Delade utgifter för två" (title-medium muted, common_tagline).
  2. InviteCodeField with "Klistra in".
  3. "Ditt namn" field (UX-4; 1–40, §9.6; capitalisation Words), required on a first pairing.
  4. Help line: info 20 + body-medium muted "Koden får du av Jacob: från en länk eller från Inställningar › Bjud in partner." (onboarding_code_help; the partner's own name is unknown before pairing, so the generic onboarding_code_help_generic is used when the app was not opened from a link).
  5. Server row, collapsed (surface-container-low, radius 16, min height 56): dns + "Serveradress" (label-medium muted) over the URL (body-medium, wraps anywhere) + expand_more; tap expands it into an editable field (keyboard Uri). It opens expanded when a link carried a server different from the release default.
  6. Bottom bar, padding 12 / 16: "Para ihop" (filled medium 56, full width, link icon).

States

Frame State What differs
S-1-pairing loaded opened from Anna's invite link: code "7K3M-QX9D-2HPA" and server filled, "Ditt namn" = "Anna", nothing focused, "Para ihop" enabled
S-1-pairing-loading loading button shows a 20 dp progress indicator in on-primary and "Parar ihop…" (onboarding_pairing) and is not clickable; all fields read-only (transparent fill, 1 dp outline-variant border); "Klistra in" hidden
S-1-pairing-error error invite_invalid: the code field in error with "Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny." (onboarding_error_invite_invalid) under it; button enabled again
S-1-pairing-again variant Unrecognised (§14.4.8; viewer Jacob) the tagline is replaced by "Para ihop telefonen igen" (headline-small) and "Dina utgifter finns kvar på telefonen." (body-large muted, onboarding_again_*); no "Ditt namn" field; generic help line

Interactions

  • Code typing: uppercase, hyphens inserted after 4 and 8, normalised per §8.3; "Para ihop" enabled when the code has 12 characters, the name is non-blank (first pairing) and the server is a valid URL. IME Next from code to name, Done on the name runs "Para ihop".
  • "Klistra in": reads the clipboard; a 12-character code or an invite link (kvitt://invite?… or https://…/i/<CODE>) fills code (and server).
  • Other errors, all inline under the field at fault, button enabled again: network "Kunde inte nå servern. Kontrollera adressen och anslutningen." (under the server row, which expands); rate_limited "För många försök. Vänta en stund och försök igen." (under the code); http:// in release (AC-2.8) "Adressen måste börja med https://" (server). Offline: the button stays enabled and gives the network error (pairing never queues).
  • Success: pair(serverUrl, code, displayName, deviceName = Build.MODEL) → partner pairing goes to S-2 (AC-2.10), otherwise S-4 in its loading state; after the first full sync the system notification-permission prompt (AC-2.9), preceded by nothing (no rationale screen).

TalkBack: the paste button reads its text "Klistra in" (onboarding_code_paste); the code field reads the value group by group; the server row "Serveradress, https://icehack.asuscomm.com:8443, dubbeltryck för att ändra"; errors are announced (live region assertive).

200 %: single column scrolls; the code value wraps after a group; the brand block's title grows ~1.5×; the bottom button stays pinned.

S-2 Partner profile step (:feature:onboarding)

Layout (app bar without title or navigation; column gap 12; bottom bar):

  1. Centred intro, padding 24 / 4 / 4: Jacob's avatar 72 (Sunny), "Du går med i Jacobs hushåll" (headline-small, onboarding_profile_joining), "Så här ser Jacob dig i Kvitt. Du kan ändra det senare under Inställningar › Profil." (body-large muted).
  2. Card, padding 22 / 20, centred, gap 14: own avatar 112 (Sunny; initials on the own slot colour until a picture exists) with a 40 dp primary circle badge holding add_a_photo 20 at its bottom-right (3 dp card ring); "Lägg till bild" tonal button (photo_library; "Byt bild" when a picture exists, plus a text button "Ta bort bild"); "Ditt namn" field prefilled from the pairing call.
  3. Bottom bar: "Fortsätt" (filled medium, full width).

States

Frame State What differs
S-2-profile-step loaded Anna's initials, name "Anna" focused
S-2-profile-step-loading loading a picture was picked and is uploading: the avatar shows it at 60 % with the progress ring (§3.3); "Byt bild" and "Fortsätt" disabled until the upload ends
S-2-profile-step-error error upload refused or network: snackbar "Bilden kunde inte användas" (profile_picture_failed, with the reason for payload_too_large / unsupported_media_type / image_invalid, or "Kunde inte nå servern"); avatar back to initials; "Fortsätt" enabled

Interactions: "Lägg till bild" opens the Android photo picker (images only, AC-4.3); upload starts immediately (setPicture). "Fortsätt" saves the name if changed (setDisplayName; failure → snackbar, stays) and goes to S-4; blank name → inline "Skriv ett namn" and "Fortsätt" disabled. Back is not offered (the pairing is done).

TalkBack: the avatar badge is part of the button "Lägg till bild"; avatar while uploading "Laddar upp bilden".

200 %: column scrolls, avatar stays 112.

S-3 Waiting for partner (:feature:onboarding, shown in the Home destination)

Layout (wordmark app bar; bottom navigation with Översikt selected; no FAB):

  1. Hero card in the Waiting state (§3.6), padding 28 / 22 / 24: seesaw with the empty seat, "Väntar på din partner" (headline-small), "Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk." (body-large muted), "Bjud in partner" (filled medium, person_add).
  2. When an invite is pending: a one-item group: schedule + "Inbjudan skickad" (title-small) over "Inbjudan gäller till mån 19 okt. 21:40" (onboarding_waiting_invite_expires).
  3. Body-medium muted line: "Konton, utgifter och betalningar blir tillgängliga när din partner har gått med."

States

Frame State What differs
S-3-waiting loaded pending invite row shown
S-3-waiting-offline offline "Bjud in partner" disabled with NeedsConnectionHint "Kräver anslutning" under it; the pending invite row is not shown (invites are online only)

Interactions: "Bjud in partner" → S-21 (it creates a partner invite there). When the partner joins, the next sync replaces this content with S-4 (empty). Statistik shows its empty state; Inställningar works.

TalkBack: hero one node "Väntar på din partner. Kvitt behöver er båda innan ni kan börja."; the seesaw is decorative.

S-4 Home (:feature:home)

Layout (wordmark app bar; column gap 12, bottom padding 120; bottom navigation; FAB):

  1. UpdateBanner when HomeState.update / UpdateState is not None (S-17).
  2. BalanceHero from HomeState.global.
  3. SyncStatusPill from HomeState.sync and connectivity.
  4. SectionHeader "Konton" with "Nytt konto" (text button, add).
  5. AccountCard per active account, created order (AC-6.6).
  6. CollapsibleRow "Arkiverade (n)" when archived accounts exist; expanded in place to their AccountCards (AC-6.4); collapsed by default, state kept for the session.
  7. AddExpenseFab.

States

Frame State What differs
S-4-home loaded "Du är skyldig Anna 3 250,00 kr"; Vardag, Huset, Japan 2027; "Arkiverade (1)" collapsed
S-4-home-settled variant settled + archived expanded hero Settled with bunting, accounts "Kvitt", archived Gotland 2026 visible with badge; no "Gör upp"
S-4-home-empty empty partner joined, no accounts: a card with both avatars (44, overlapping by 10) "Du och Anna är med" / "Hushållet är klart. Nu behövs ett konto."; then EmptyState (pile of three solid tiles: groceries, travel, restaurants) "Skapa ert första konto", "Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.", button "Nytt konto" (medium, add); no sync pill, no FAB (AC-8.6 needs an account)
S-4-home-loading loading (first sync only) hero Loading, a 40 × 200 dp pill skeleton, "Konton" header without action, three skeleton account cards; no FAB
S-4-home-offline offline + update banner pill "Offline · 2 ändringar väntar på synk"; "Nytt konto" disabled with "Kräver anslutning" as its supporting text; banner Available with "Uppdatera" disabled ("Kräver anslutning")
S-4-home-200 long, 200 %, light only app bar surface-container (scrolled 90 dp), hero amount ~1.5× with "kr" on its own line, account amounts under the names, FAB icon-only
S-4-home-en English, light only "You owe Anna", "SEK 3,250.00" (unit before the number), "Synced 3 min ago", "Accounts", "New account", "Add expense"

Interactions

  • FAB → S-7 new (account per AC-8.6). Hidden while no active account exists (only archived accounts: hidden too, UX-6).
  • "Gör upp" → S-9 global. Account card → S-5. "Nytt konto" → S-6 (offline: disabled). Pill → S-16. Banner per S-17.
  • Returning from S-7 shows the S-7 snackbar here; from S-9 "Betalningen sparades" (snackbar_payment_saved).
  • After the first full sync after pairing, the notification permission is asked once (AC-2.9).
  • Pull-to-refresh is not offered (sync runs on its own triggers, AC-15.3).

TalkBack: hero as §3.6; pill as §3.6; account cards as §3.4; "Arkiverade (1), hopfälld"; FAB "Lägg till utgift"; navigation items "Översikt, flik 1 av 3, vald".

200 %: see S-4-home-200; every element reflows, nothing truncates except account names (two lines then ellipsis).

S-5 Account detail (:feature:account)

Layout (app bar: back, account name, more_vert; column gap 12; FAB):

  1. AccountBalanceHeader.
  2. Ledger: per month a LedgerMonthHeader and a SegmentedGroup of ExpenseRow / SettlementRow in AC-8.10 order; settlement amounts are the allocation to this account (AC-11.7).
  3. AddExpenseFab (account preset to this one); hidden for archived accounts (UX-6).

Overflow menu (KvittMenu): "Ändra konto" (edit) → S-6; "Arkivera" (inventory_2) or "Återställ från arkiv" (unarchive); "Ta bort konto" (delete) enabled only when deletable, otherwise disabled with "Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter" (account_delete_blocked).

States

Frame State What differs
S-5-account loaded Vardag: "Anna är skyldig dig 1 184,50 kr", October and September groups, the SL-kort row pending
S-5-account-menu variant: overflow menu open menu over the content; "Ta bort konto" disabled with its reason
S-5-account-archived variant archived Gotland 2026: "Arkiverat" badge, "Kvitt", "Återställ från arkiv" (tonal) in the header, no FAB
S-5-account-empty empty new account "Sommarstugan": header "Kvitt" without a settle button; EmptyState "Inga utgifter än" / "Lägg till den första utgiften i Sommarstugan." (no illustration, no button); FAB shown
S-5-account-offline offline menu open: all three items disabled, each with "Kräver anslutning" as its second line; rows, "Gör upp det här kontot" and FAB work
S-5-account-error error after "Arkivera" while the server is unreachable: snackbar "Kunde inte nå servern" with "Försök igen"; account unchanged
S-5-account-long long "Sommarstugan i Österåker 2027" ellipsized in the app bar; rows with long descriptions ellipsized at one line; an amount of 1 234 567,89 kr and a month total of 7 digits shown in full

Interactions

  • Row → S-8 / S-10. "Gör upp det här kontot" → S-9 per account. FAB → S-7 with this account.
  • "Arkivera" / "Återställ från arkiv": no confirmation (reversible); success → back to S-4 with snackbar "Vardag arkiverades" + "Ångra" (account_archived_snackbar; "Ångra" unarchives) / stays with "Vardag återställdes"; network failure → S-5-account-error.
  • "Ta bort konto": ConfirmDialog (destructive) "Ta bort kontot Vardag?" / "Det går inte att ångra." → "Ta bort"; success → S-4; account_not_empty → snackbar "Kontot har fortfarande utgifter, betalningar eller återkommande utgifter".

TalkBack: overflow "Fler alternativ"; header one node (§3.6); month headers are headings; rows §3.5; disabled menu items read with their reason.

200 %: header wording and amount wrap; rows per §3.5 (amount under the text).

S-6 Account editor (:feature:account)

Layout (app bar: close, "Nytt konto" / "Ändra konto", "Spara" small filled; column gap 14):

  1. Name field "Namn" (1–60, focused on open for a new account; capitalisation Sentences).
  2. Card "Standarddelning", padding 18, gap 14: subtitle "Gäller nya utgifter i kontot. Varje utgift kan ändra sin egen delning." (account_editor_split_hint; this line also carries AC-6.3 when editing); the two people (avatar 32 + name, viewer left); SplitSlider; two percent fields ("Jacob", "Anna", suffix "%", system decimal keyboard, two decimals, the other follows as 100 minus); ButtonGroup presets "40/60 · 50/50 · 60/40"; preview box (surface-container-high, radius 16, padding 12 / 14, calculate icon): "Av 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr" (account_editor_preview).

States

Frame State What differs
S-6-account-editor loaded new "Sommarstugan", 60 / 40
S-6-account-editor-offline offline NeedsConnectionBanner "Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot." on top; field, card (45 % opacity) and "Spara" disabled
S-6-account-editor-error error name_taken: name field in error with "Det finns redan ett konto som heter Vardag" (account_editor_name_taken); "Spara" enabled again

Interactions: "Spara" enabled when the name is non-blank and the percentages sum to 100; shows a progress indicator inside the button while saving; success → back (new: to S-5 of the new account; edit: to S-5); network → snackbar "Kunde inte nå servern" + "Försök igen". Close with changes → ConfirmDialog "Slänga ändringarna?" "Släng" / "Fortsätt redigera".

TalkBack: slider "Jacobs andel, Jacob 60 procent, Anna 40 procent" (editor_ratio_slider, editor_ratio_spoken); preset buttons "60 procent du, 40 procent Anna".

200 %: percent fields stack vertically; presets wrap.

S-7 Expense editor (:feature:expense)

Full screen (§13.2), works offline (AC-8.2). The flow and the category rule are in §5.

Layout (app bar: close, "Ny utgift" / "Ändra utgift", "Spara" small filled with check; scrolling column gap 12, padding 16 bottom; keypad at the bottom while a keypad field has focus):

  1. AmountDisplay (focused on open for a new expense).
  2. DescriptionField "Beskrivning".
  3. CategoryQuickRow (five tiles + "Alla").
  4. Chips row: Konto · Datum · Betalt av · Delning (§3.7).
  5. SplitPreview (when the split section is closed).
  6. SplitSection (when open: after a tap on Delning, or always when the mode is not default in edit mode).
  7. NotesField ("Lägg till anteckning", or the field when notes exist).
  8. Edit mode: "Ta bort utgift" (danger text button, delete) at the end.
  9. AmountKeypad; CompactEditorSummary pinned under the app bar when the amount has scrolled away.

States

Frame State What differs
S-7-editor loaded (new, ten-second flow) "412", "Systembolaget", category Nöje selected by the suggestion (no caption, CS-7), Vardag, Idag, Betalt av Du, Kontots standard 50/50, preview "Du 206,00 kr · Anna 206,00 kr", keypad up
S-7-editor-split variant: exact split with error compact summary row (Ullared, Shopping, 1 060,00 kr); Delning chip selected; split section open with "Exakta belopp"; Jacob's field 1 200,00 kr in error, Anna's read-only "—"; error box "140,00 kr för mycket"; compact keypad; "Spara" disabled
S-7-editor-full variant: full split + notes (edit mode) "Ändra utgift", SL-kort 30 dagar, Anna, Transport, 14 okt., "Allt på en person" with Anna as bearer, notes expanded, "Ta bort utgift"
S-7-editor-edit variant edit mode "Ändra utgift" for ICA Maxi Lindhagen (1 247,60 kr, Mat & hushåll, Vardag, 16 okt., Betalt av Anna); nothing focused, keypad hidden; "Ta bort utgift" visible at the end
S-7-editor-200 long, 200 %, light only amount and "Spara" visible, quick row in 3 columns, the middle scrolls, keypad keys grow

Interactions

  • Defaults (new): account per AC-8.6; category per §5 (UX-3: suggestion, falling back to AC-8.6); date today; payer the viewer (AC-8.3); mode default. From S-5 the account is that account.
  • Changing the account: mode default re-copies the account's ratio (AC-8.8); the suggestion re-runs (CS-3) unless the category was picked by hand.
  • "Spara" enabled when the amount is > 0, the description is non-blank and preview is not null; tap → save. SaveResult.Invalid maps each FieldError inline: amount required "Skriv ett belopp", out of range "Beloppet måste vara mellan 0,01 kr och 10 000 000,00 kr"; description required "Skriv en beskrivning", too long; date out of range "Datumet ligger för långt fram"; exact_sum_mismatch / exact share > amount "Andelen är större än beloppet". Focus moves to the first field in error.
  • Saved (new) → back to the opener with snackbar "Utgiften sparades" (online) / "Sparad. Synkas när du är online." (offline), no "Ångra" (UX-6). Saved (edit) → back to S-8, no snackbar (the timeline shows the pending line).
  • Description IME Done: saves when valid; otherwise hides the keyboard and focuses the first invalid field.
  • Close or back with changes: ConfirmDialog "Slänga utgiften?" (new) / "Slänga ändringarna?" (edit) with "Släng" / "Fortsätt redigera"; without changes it closes directly.
  • "Ta bort utgift" (edit): ConfirmDialog (destructive, delete) "Ta bort utgiften?" / "Den försvinner för er båda." → "Ta bort"; then back past S-8 to its opener with snackbar "Utgiften togs bort".
  • Editing an expense the other person created: same editor, no warning.
  • Not reachable without an active account (no FAB).

TalkBack: close "Avbryt"; "Spara"; amount, keypad, quick row, chips, split section per §3.7; the compact row "Ullared, Shopping, 1 060,00 kronor, dubbeltryck för att ändra beloppet". Errors announced.

200 %: S-7-editor-200; "Spara" always in the app bar; chips wrap to several rows; split section scrolls.

S-8 Expense detail (:feature:expense)

Layout (app bar: back, no title, edit "Ändra" and delete "Ta bort" icon buttons; column gap 10):

  1. Hero card (fill cat-<token>-container, padding 20, gap 12): CategoryTile 60 + description (title-large, wraps) over "Mat & hushåll · Vardag" (body-medium muted); amount MoneyText(Large) with the pending marker (20) after it when pending; badge "Återkommande: Hyra" (lantern, event_repeat) when recurringTemplateName != null (AC-8.11).
  2. Facts (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 thin SplitBar, "Du 623,80 kr · Anna 623,80 kr"); Anteckning (notes, full text) only when notes exist.
  3. Timeline.

States

Frame State What differs
S-8-expense loaded + pending ICA Maxi Lindhagen, notes, timeline with create, two changes and "Din ändring väntar på synk"
S-8-expense-recurring variant recurring Hyra september, Boende · Huset, badge "Återkommande: Hyra", timeline "Skapad automatiskt (återkommande) · 25 sep. 06:00"
S-8-expense-deleted variant deleted Neutral banner on top: filled delete in error + "Anna tog bort den här utgiften · 17 okt. 09:12" + "Den räknas inte längre i saldon eller statistik."; hero and facts at 62 % opacity, tile desaturated; no "Ändra" / "Ta bort"
S-8-expense-missing empty ExpenseRepository.detail emits null: EmptyState (Cookie9 tile, sync_problem) "Utgiften finns inte på telefonen än" / "Den kan finnas på servern. Synka för att hämta den." with "Synka nu" (tonal, sync); no actions in the app bar

Interactions: "Ändra" → S-7 edit. "Ta bort" → the delete dialog of S-7, then back with snackbar "Utgiften togs bort". "Synka nu" → SyncRepository.syncNow(); the screen fills in when the row arrives. Opened from a deletion notification → deleted state (AC-13.6 opens Home for deletions; the deleted state also shows when the row is deleted while open).

TalkBack: icon buttons "Ändra utgift" (expense_editor_edit), "Ta bort utgift" (expense_editor_delete); hero one node "ICA Maxi Lindhagen, Mat & hushåll, Vardag, 1 247,60 kronor, väntar på synk"; facts per FactRow; timeline items one node each.

200 %: facts wrap; hero description wraps.

S-9 Settle up (:feature:settle)

Layout (app bar: close, "Gör upp" / "Gör upp Huset" (per account) / "Ändra betalning" (editing); column gap 10, padding bottom 20; bottom bar):

  1. DirectionCard.
  2. Amount field "Belopp" (filled, value amount-large, suffix "kr" title-large muted; system decimal keyboard; §9.6 input rules).
  3. Header "Fördelning" (title-small) and a SegmentedGroup of AllocationRows (SettleState.lines).
  4. RemainingIndicator (global variant only).
  5. "Lägg till konto" (text, add) and, after a row was edited, "Föreslå igen" (text, refresh).
  6. Info line (global variant, when an account where the payer is owed is not in the rows, §9.4): info 18 + body-small muted "Vardag lämnas som det är, eftersom Anna är skyldig dig där." (settle_untouched_partner_owes; "… eftersom du är skyldig Anna där." is settle_untouched_you_owe).
  7. Chip "Datum Idag" (date picker) and field "Anteckning" (0–200, placeholder "Till exempel Swish").
  8. Bottom bar (surface, 1 dp outline-variant top line, padding 10 / 16): "Bekräfta" (filled medium, full width, check), enabled only when canConfirm.

States

Frame State What differs
S-9-settle loaded (proposal) "Du betalar Anna", 3 250,00 kr; Huset 3 084,50, Japan 2027 165,50; "Kvar att fördela 0,00 kr"; info line about Vardag
S-9-settle-adjusted variant over-settle Huset 1 750,00, Japan 2027 1 500,00 with the warning border and strip; remaining 0,00
S-9-settle-remaining variant sum mismatch Huset 2 000,00, Japan 2027 1 000,00; remaining "250,00 kr" in warning style; "Bekräfta" disabled
S-9-settle-account variant per account "Gör upp Huset": one fixed row (no remove, amount follows), no remaining, no "Lägg till konto", no info line; direction swap and amount editable (UX-6)
S-9-confirm-dialog variant confirm dialog over S-9-settle-adjusted: dialog "Bekräfta betalningen", "Du betalar Anna 3 250,00 kr idag, lördag 17 oktober 2026.", the allocations (name + amount per line), the warning strip per warned row, "Avbryt" / "Bekräfta"
S-9-settle-empty empty global balance zero: amount empty (hint "0,00"), no rows; in place of the rows a body-medium muted line "Skriv ett belopp så föreslår Kvitt en fördelning."; no remaining indicator; "Bekräfta" disabled
S-9-settle-200 long, 200 %, light only each allocation row stacks name and debt over a full-width amount field; direction card wraps its text; bottom button stays pinned

Interactions

  • Defaults from SettlementRepository.start (AC-11.3, AC-11.4; editing: AC-11.6).
  • Editing the amount calls recompute with allocations = null (a fresh proposal) until the user edits a row; afterwards it keeps the rows and only updates the remaining amount; "Föreslå igen" re-proposes. Swapping the direction re-proposes. Per account: the single row follows the amount.
  • "Lägg till konto" → bottom sheet of all non-deleted accounts not yet listed (archived with badge, AC-11.3); picking adds a row with an empty amount, focused.
  • Remove → the row disappears and the remaining amount updates.
  • "Bekräfta": with any overSettles row → the confirm dialog (AC-11.5) → "Bekräfta" saves; without warnings it saves directly. Saved → back to S-4 or S-5 with snackbar "Betalningen sparades". Invalid → inline under the amount or the rows (settle_error_allocation_sum, settle_error_allocation_positive, …).
  • Close with changes → ConfirmDialog "Slänga ändringarna?" (common_discard_title) "Släng" / "Fortsätt redigera".
  • Works offline unchanged.

TalkBack: swap "Byt riktning"; remove "Ta bort Huset från fördelningen"; row fields labelled "Belopp för Huset"; the remaining indicator is a polite live region; dialog title announced.

200 %: S-9-settle-200.

S-10 Settlement detail (:feature:settle)

Layout (app bar: back, "Betalning", edit "Ändra", delete "Ta bort"; column gap 10):

  1. Hero card (fill settlement-hero, padding 22 / 20, centred, gap 8): payer avatar 52 (Sunny) → arrow_forward 28 → recipient avatar 52; "Anna betalade dig" (title-medium); MoneyText(Large) with the pending marker when pending.
  2. Facts: Datum ("tisdag 6 oktober 2026"), Anteckning (when present).
  3. "Fördelning" header and a group: one row per allocation (AccountTile 36, name title-medium, MoneyText(Small)).
  4. Timeline.

States

Frame State What differs
S-10-settlement loaded Anna → Jacob 800,00 kr, Vardag, "Swish"
S-10-settlement-deleted variant deleted Neutral banner "Anna tog bort den här betalningen · 17 okt. 09:12" + "Den räknas inte längre i saldona."; content at 62 % opacity; no "Ändra" / "Ta bort"; timeline ends with "Anna tog bort den här · 17 okt. 09:12"

Interactions: "Ändra" → S-9 editing. "Ta bort" → ConfirmDialog (destructive) "Ta bort betalningen?" / "Saldona räknas om för er båda." → back with snackbar "Betalningen togs bort". A missing settlement uses the S-8 missing layout ("Betalningen finns inte på telefonen än"); no frame.

TalkBack: hero one node "Anna betalade dig 800,00 kronor"; icon buttons "Ändra betalning", "Ta bort betalning".

S-11 Analytics (:feature:analytics)

Layout (app bar "Statistik" without navigation icon; column gap 10; bottom navigation with Statistik selected; no FAB):

  1. AnalyticsFilterBar (defaults AC-14.2–AC-14.5: Senaste 6 mån, Månad, Hushållet, Kategori).
  2. TotalCard.
  3. ChartCard "Utgifter över tid": StackedBarChart grouped by the group-by toggle, ChartLegend.
  4. ChartCard "Per kategori": BreakdownList of categories.
  5. ChartCard "Per konto": BreakdownList of accounts (archived included with badge, AC-14.9).

States

Frame State What differs
S-11-analytics loaded maj–okt 2026, household, by category; legend of the categories with data; "Per kategori" begins
S-11-analytics-scrolled loaded, scrolled app bar surface-container; the end of "Per kategori" and the whole "Per konto"
S-11-analytics-week variant week / my share / by account "Anpassad · 24 aug.–17 okt.", Vecka, Min del, Konto; eight weekly bars by account with "Vecka" caption; "Per konto"
S-11-analytics-empty empty "Anpassad · 1–30 nov."; total 0,00 kr; EmptyState (three other container tiles) "Inga utgifter i den här perioden" / "Välj en annan period, eller lägg till en utgift med ett datum i perioden." + "Byt period" (tonal, date_range); charts hidden, filters stay
S-11-analytics-200 long, 200 %, light only filter controls one per row; total amount ~1.5×; chart labels thinned; breakdown names ellipsize, amounts and percents whole

Interactions

  • Period chip → menu of the presets (AC-14.2: "Denna månad", "Senaste 3 mån", "Senaste 6 mån", "Senaste 12 mån", "I år", "All tid", "Anpassad…"); "Anpassad…" opens the Material date range picker; "Använd" is enabled only with both dates (AnalyticsState note in §14.2.4).
  • Toggles re-query immediately; the filter survives tab switches within the session.
  • Bar → S-12 for that bucket (all groups); breakdown row → S-12 for that category or account over the range (AC-14.8).
  • Settlements are never counted (AC-14.6). Works offline.

TalkBack: chart summary sentences as §3.10; each bar and row a node; toggles are radio groups.

200 %: S-11-analytics-200.

S-12 Analytics drill-down (:feature:analytics)

Layout (app bar: back, title formatted from the drill-down arguments: "Mat & hushåll, oktober 2026" / "Vardag, v. 41" / "Oktober 2026" / "Mat & hushåll, maj–okt 2026"; one line, ellipsis; column gap 10):

  1. Header card (fill cat-<token>-container for a category, account-header-n for an account, primary-container for a whole bucket; padding 18 / 20): tile 56 (CategoryTile, AccountTile, or a Cookie4 primary tile with bar_chart for a bucket) + "Totalt · Hushållet" / "Totalt · Min del" (label-large muted) over MoneyText(Large) over "4 utgifter" (body-small muted, plural).
  2. SegmentedGroup of ExpenseRows, newest first.

States

Frame State
S-12-drilldown loaded (Mat & hushåll, oktober 2026)

Interactions: row → S-8. If the data changed and nothing is left: the header shows 0,00 kr and the line "Inga utgifter längre" replaces the list (no frame).

TalkBack: header one node "Mat & hushåll, oktober 2026, totalt 3 910,00 kronor, 4 utgifter".

S-13 Settings hub (:feature:settings)

Layout (app bar "Inställningar"; column gap 12; bottom navigation with Inställningar selected):

  1. Household card (padding 16 / 18, gap 12; role lines only, no device name: SessionState carries none): viewer row: avatar 52 (Sunny) + name (title-medium) over "Du · Administratör" (settings_role_admin) or "Du" (settings_role_member) in body-small muted; 1 dp divider; partner row: avatar 52 + name over "Partner" (common_partner) or "Admin" (settings_role_admin_other).
  2. Group 1: SettingsRows "Profil" (person, orange, "Namn och bild"), "Kategorier" (category, green, "14 aktiva"), "Återkommande utgifter" (event_repeat, brown, "3 aktiva, 1 pausad" / "Inga").
  3. Group 2: "Enheter" (devices, blue, no subtitle: the list is online only); admin only: "Bjud in partner" (person_add, teal) while the partner has not joined, else "Para ihop en enhet" (add_link, teal); "Synkstatus" (sync, purple, the pill's text, e.g. "Synkad för 3 min sedan" / "2 väntar på synk"); "Om Kvitt" (info, grey, "Version 1.1" / "Uppdatering finns").

States

Frame State What differs
S-13-settings loaded (admin) Jacob "Du · Administratör", Anna "Partner"; "Para ihop en enhet" shown
S-13-settings-member variant member Anna's phone: Anna "Du" first, Jacob "Admin"; no invite or pairing row (AC-2.2)

Interactions: each row pushes its screen (S-14, S-15, S-19, S-20, S-21, S-16, S-22). Rows to online-only screens stay enabled; those screens show their offline state.

TalkBack: household card two nodes ("Jacob, du, administratör"; "Anna, partner"); rows "Kategorier, 14 aktiva, knapp".

S-14 Profile (:feature:settings)

Layout (app bar: back, "Profil", "Spara" small filled; column gap 14):

  1. Centred, padding 20 / 0 / 10: own avatar 128 (Sunny); buttons "Byt bild" (tonal, photo_library; "Lägg till bild" without a picture) and "Ta bort bild" (text, delete; only with a picture).
  2. Field "Namn" (1–40) with supporting text "Så här ser Anna dig i Kvitt." (partner's name).

States

Frame State What differs
S-14-profile loaded Jacob, picture, name focused
S-14-profile-loading loading a new picture uploading: progress ring on the avatar, both picture buttons disabled
S-14-profile-offline offline Warning banner "Kräver anslutning. Profilen kan bara ändras när telefonen är online." (settings_profile_offline_banner); buttons, field and "Spara" disabled

Interactions: picture changes apply on success without "Spara" (AC-4.3); "Ta bort bild" asks nothing (reversible by adding again). "Spara" enabled when the name changed and is valid; saves with setDisplayName; success → back (no snackbar). Failures: snackbar per S-2 (picture) or "Kunde inte nå servern" + "Försök igen".

TalkBack: avatar = the viewer's name; while uploading "Laddar upp bilden".

S-15 Categories (:feature:settings)

List layout (app bar: back, "Kategorier", add icon button "Ny kategori"; column gap 4):

  1. SegmentedGroup of active categories in sort_order: row min height 56, padding 4 / 12 / 4 / 8, gap 12: drag handle (drag_indicator, 32 × 48 target), CategoryTile 36 solid, name (title-medium; custom name or localised default), chevron_right.
  2. CollapsibleRow "Arkiverade (n)" and, expanded, a group of archived categories (no drag handle, 32 dp spacer, muted tile).

Editor layout (pushed; app bar: close, "Ändra kategori" / "Ny kategori", "Spara"; column gap 10):

  1. Row: live preview CategoryTile 64 + name field "Namn" (1–40; for a default category an empty field means "use the default name"); for a default category under the field "Standardnamn: Mat & hushåll" (body-small muted) and, when a custom name exists, "Återställ namn" (text, restart_alt, AC-7.4).
  2. "Ikon" (title-small) + CategoryIconPicker (33).
  3. "Färg" + ColorSwatchPicker (14).
  4. Footer row: "Arkivera" (outlined, inventory_2) or "Återställ från arkiv" (outlined, unarchive); delete: default category → lock 16 + "Kan inte tas bort: standardkategori" (body-small muted, settings_category_delete_blocked_default_short); custom unused → "Ta bort kategori" (danger text, delete); custom in use → disabled "Ta bort kategori" with "Kategorin används av utgifter eller återkommande utgifter. Arkivera den i stället." (settings_category_delete_blocked_in_use); the state comes from CategoryRepository.deletable(id) (UX-9, pending), see open point 2 for the behaviour until then.

States

Frame State What differs
S-15-categories loaded the 14 defaults in sort order
S-15-categories-scrolled loaded, scrolled the end of the list, "Arkiverade (1)" expanded with Pant
S-15-categories-offline offline no drag handles; a NeedsConnectionHint line "Kräver anslutning för att ändra" above the list; "Ny kategori" disabled; rows not clickable, no chevrons
S-15-category-editor variant editor (default renamed) "Matvaror" over "Standardnamn: Mat & hushåll" with "Återställ namn"; shopping_cart, green; "Arkivera"; "Kan inte tas bort: standardkategori"
S-15-category-editor-custom variant editor, custom archived "Pant", water_drop, teal; no default-name line; "Återställ från arkiv"; "Ta bort kategori" disabled with "Kategorin används av utgifter eller återkommande utgifter och kan inte tas bort." (settings_category_delete_blocked_in_use_archived; an active category uses settings_category_delete_blocked_in_use, which adds "Arkivera den i stället.")

Interactions

  • Reorder: long-press a row or drag its handle; on drop reorder(ids); failure → snackbar "Kunde inte nå servern" and the old order returns. TalkBack custom actions "Flytta upp" / "Flytta ned" on each active row.
  • Row → editor. "Spara" (online) → update / create; success → back. "Återställ namn" sets the field empty and the preview name to the default (saved with "Spara", name = null).
  • "Arkivera" / "Återställ från arkiv" act immediately (online) and return to the list with a snackbar ("Pant arkiverades"). "Ta bort kategori" → ConfirmDialog "Ta bort kategorin Pant?" → delete; category_in_use → the disabled reason inline.

TalkBack: drag handle "Flytta Mat & hushåll" (settings_categories_drag_handle); add "Ny kategori"; icon and colour pickers per §3.4; preview tile decorative.

200 %: icon grid stays 7 columns (icons do not scale); colour grid 7 columns; the footer wraps.

S-16 Sync status (:feature:home)

Layout (app bar: back, "Synkstatus"; column gap 10):

  1. When clockSkew: Error banner (schedule) "Telefonens klocka går fel. Rätta den och synka igen." / "Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess." with "Datum och tid" (text, open_in_new, opens the system date settings).
  2. Status card (padding 20, gap 14): 52 dp Sunny tile (owed-container + filled cloud_done; surface-container-highest + sync_problem when the last attempt failed or was refused) + "Synkad för 3 min sedan" / "Synkar…" / "Offline" (title-medium) over "Senast lyckade synk 13:59" (body-small muted).
  3. "Synka nu" (filled medium, full width, sync); disabled with a progress indicator while running; offline disabled with "Kräver anslutning".
  4. "Väntande ändringar (2)" header + group of pending rows: CategoryTile 36 (settlements: the settlement tile), description (title-medium, ellipsis), "Väntar på synk" with cloud_upload 15 in pending (body-small), MoneyText(Small); tap → S-8 / S-10. Rows carry no change-kind tag ("Ny" / "Ändrad" / "Borttagen" of U1): SyncStatusState.pending is a list of LedgerRows without one, and the tag is not needed for v1.
  5. "Ändringar som inte synkades" header + one outlined card per rejection (1 dp outline-variant, padding 16, gap 10): 36 dp tile (receipt_long for an expense, handshake for a settlement, on surface-container-highest) + summary (title-medium) + reason text per §14.4.6 with the time ("Din ändring ersattes av en nyare ändring från Anna · 17 okt. 08:31", body-medium) + a body-small muted consequence line ("Annas version gäller. Öppna utgiften om du vill göra din ändring igen."); actions "Öppna" (text) and "Ta bort från listan" (tonal).

States

Frame State What differs
S-16-sync loaded synced 3 min ago, two pending rows, one stale rejection (Babette)
S-16-sync-clock variant clock skew clock banner first; status "Synkad för 2 h sedan" / "Senaste försöket nekades: klockan" with sync_problem; pending rows; no rejection list
S-16-sync-empty empty status card "Allt är synkat" (home_sync_status_all_synced) over "Senast lyckade synk 13:59"; "Synka nu"; no pending or rejection sections

Interactions: "Öppna" opens the lost version read-only (D-109) when Rejection.lost is set: a dialog with "Din version" (headline-small, home_sync_lost_title), "Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här." (body-medium muted, home_sync_lost_body), the ledger row it would have been (ExpenseRow / SettlementRow with a null onClick, so it is not tappable), FactRows for account, payer, split and notes where present, the badge "Du tog bort den här" (delete, home_sync_lost_deleted) with the facts at the deleted alpha when the refused change was a deletion, and "Visa nuvarande version" (home_sync_lost_show_current), which opens the current version (S-8 / S-10; deleted or missing states apply); without lost (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly. "Ta bort från listan" → dismiss (works offline). The rows update live while a sync runs.

TalkBack: status card one node; rejection card one node plus its two buttons, read with their visible text ("Öppna", "Ta bort från listan", home_sync_status_dismiss).

S-17 Update available (:feature:home)

Not a screen: the UpdateBanner on S-4 (and S-22) and one dialog.

Dialog "Tillåt installation" (shown once, before the first install, when UpdateState.NeedsInstallPermission): ConfirmDialog with a 56 dp Sunny tertiary-container tile (install_mobile), title "Tillåt installation", body "Kvitt uppdateras direkt från er server, inte från Play Butik. Därför frågar Android en gång om Kvitt får Installera okända appar." and "Slå på Tillåt från den här källan och gå sedan tillbaka till Kvitt. Uppdateringen fortsätter av sig själv.", buttons "Senare" / "Fortsätt" (arrow_forward, opens the system setting for Kvitt).

States

Frame State What differs
S-17-update variant first-time dialog the dialog over S-4 with the Available banner
S-17-update-progress loading (downloading) banner Downloading 64 %
S-17-update-failed error banner Failed

Interactions: "Uppdatera" → install(); after the system setting, returning to the app continues; verified download → system installer (out of the app's hands). "Senare" hides the banner until the next cold start. Offline: "Uppdatera" disabled with "Kräver anslutning". Debug builds never show it (AC-16.5).

TalkBack: progress announced as a percentage every 10 %; dialog title read on open.

S-18 Device removed (:feature:onboarding)

Layout (no app bar, no navigation): centred column, padding top 80, gap 14: Cookie9 tile 112 (error-container, phonelink_erase 52 in on-error-container); "Den här telefonen har tagits bort" (headline-medium); "Åtkomsten togs bort och allt Kvitt-data på telefonen har raderats." (body-large muted); "Vill du använda Kvitt igen behöver du en ny inbjudningskod." (body-medium muted). Bottom bar: "Para ihop igen" (filled medium, full width, link).

States

Frame State
S-18-removed loaded

Interactions: "Para ihop igen" → acknowledgeRevoked() → S-1 (first pairing: with the name field). Back closes the app.

S-19 Recurring expenses (:feature:settings)

List layout (app bar: back, "Återkommande utgifter"; column gap 10; FAB "Ny återkommande"):

  • SegmentedGroup of templates (row min 72, top-aligned, padding 14 / 16): CategoryTile 40 (muted when paused) · description (title-medium, ellipsis) over "Huset · Anna betalar" (account · payer, body-medium muted) over "Varje månad den 25:e" and "Nästa 25 okt." (body-small muted, two lines; settings_recurring_every_month_on, settings_recurring_next) or, when paused, a "Pausad" lantern badge (pause) + "Varje månad den 12:e" · MoneyText(Small) right (70 % opacity when paused).

Editor layout (pushed; app bar: close, "Ny återkommande utgift" / "Återkommande utgift", "Spara"; column gap 12): AmountDisplay (+ keypad when focused; focused on open for a new template), DescriptionField, CategoryQuickRow (no suggestion, open point 3), option rows Konto · Betalt av · Delning (EditorOptionRows, no Datum; D-107), SplitSection when open; then a flat section "Schema" (padding 16, gap 10): "Dag i månaden" field (1–31, number keyboard) and "Första månad" (dropdown; a month picker dialog titled with the field label, with a year row and 12 month chips; months before the allowed start disabled, AC-9.8 default) side by side (1 : 1.5); "Sista månad (valfritt)" (placeholder "Ingen slutmånad"); a switch row "Pausad" with "Skapas kl. 06:00 den 25:e varje månad" (body-small muted). Day 29–31: hint "I kortare månader används sista dagen". Editing: hint "Ändringen gäller kommande utgifter." (AC-9.4); after un-pausing: "Månader som passerat under pausen skapas inte i efterhand." (§9.5). Edit mode: "Ta bort" (danger text) at the end.

States

Frame State What differs
S-19-recurring loaded Hyra, Bahnhof bredband, Netflix, Spotify Family (paused)
S-19-recurring-empty empty EmptyState "Inga återkommande utgifter" / "Hyra, el och streaming kan läggas in automatiskt varje månad." + "Ny återkommande utgift" (medium, add); no FAB
S-19-recurring-offline offline Warning banner "Kräver anslutning. Återkommande utgifter kan bara ändras när telefonen är online." (settings_recurring_offline_banner) above the list; FAB disabled (on-surface 12 % fill, 38 % content); rows not clickable, list readable
S-19-recurring-editor variant editor Hyra 12 400,00 kr, Boende, Huset, Betalt av Anna, Kontots standard 60/40, day 25, from september 2026

Interactions: FAB or empty-state button → editor (new). Row → editor. "Spara" (online) → create / update; success → back; network → snackbar. "Ta bort" → ConfirmDialog "Ta bort Hyra?" / "Utgifter som redan skapats finns kvar." → delete. Close with changes → discard dialog.

TalkBack: row one node "Hyra, 12 400,00 kronor, Huset, Anna betalar, varje månad den 25:e, nästa 25 oktober" (paused: "pausad"); FAB "Ny återkommande utgift".

S-20 Devices (:feature:settings)

Layout (app bar: back, "Enheter"; column gap 10): SegmentedGroup of devices (row min 72, top-aligned, padding 16): tile 40 (Cookie4, cat-blue container, smartphone in cat-blue) · name (title-medium) + "Den här enheten" lantern badge for the current one · avatar 20 + owner name (body-medium muted) · "Parad 3 okt. · Senast sedd nyss · Kvitt 1.1" (body-small muted) · for revocable devices "Ta bort åtkomst" (danger text, block). Revoked devices are not listed. Footer: "Du kan inte ta bort åtkomsten för den telefon du använder just nu." (body-small muted). Admin sees both users' devices, a member their own (AC-3.1).

States

Frame State What differs
S-20-devices loaded Galaxy S26 (Jacob, this device), Galaxy S26+ (Anna)
S-20-devices-revoke variant confirm dialog ConfirmDialog (destructive, phonelink_erase) "Ta bort åtkomst för Galaxy S26+?" / "Annas telefon loggas ut och allt Kvitt-data på den raderas. Anna kan para ihop den igen med en ny inbjudningskod." / "Avbryt", "Ta bort åtkomst"
S-20-devices-loading loading two skeleton rows while list() runs
S-20-devices-offline offline no list; centred cloud_off 48 + "Kräver anslutning för att visa enheter" (title-medium)

Interactions: revoke → dialog → revoke(id) → the row updates; failure → snackbar. Network failure on open → the offline layout with "Kunde inte nå servern" and "Försök igen".

S-21 Invites (:feature:settings, admin only)

Layout (app bar: back, "Bjud in partner" while the partner has not joined, else "Para ihop en enhet"; column gap 10):

  1. Before an invite is created in this visit: card with a body-medium explanation and the create buttons: "Bjud in partner" (filled, person_add, only while one user exists, AC-2.1) or, after the partner joined, "För mig" and "För Anna" (tonal, add_link; AC-2.2).
  2. After creation, in its place: a tertiary-container card (padding 20, gap 12): key (filled) + "Ny enhet för Jacob" (settings_invites_new_device_for) / "Inbjudan till din partner" (settings_invites_new_partner) (title-medium); "Inbjudningskod" (label-medium); InviteCodeDisplay; the link (body-small, centred, wraps anywhere); schedule + "Gäller till tis 20 okt. 14:02"; "Dela" (tertiary, share; Android share sheet with settings_invites_share_message) and "Kopiera kod" (outlined in on-tertiary-container, content_copy; snackbar "Koden kopierades"). Under the card: "Öppna länken på den nya telefonen, eller skriv in koden i Kvitt. Koden fungerar en gång." (body-medium muted).
  3. "Väntande inbjudningar" header + group: tile (key, cat-yellow container) + "Enhet · Jacob" / "Partner" (title-medium) over "Skapad idag 14:02 · Gäller till tis 20 okt. 14:02" (body-small muted) + "Avbryt" (text). Rows created earlier have no code ("Koden visas bara när den skapas").

States

Frame State What differs
S-21-invites loaded created device invite for Jacob; one pending row
S-21-invites-offline offline Warning banner "Kräver anslutning. Inbjudningar kan bara skapas och visas när telefonen är online." (settings_invites_offline_banner); create buttons disabled; no pending list
S-21-invites-error error after a create: snackbar "Kunde inte nå servern" with "Försök igen"; no card

Interactions: create → create(kind, targetUser); household_full → snackbar "Din partner har redan gått med" (error_household_full). "Avbryt" on a pending row → ConfirmDialog "Avbryta inbjudan?" → cancel(id). Leaving the screen discards the shown code (it cannot be shown again).

TalkBack: code spelled per group (§3.11); "Dela" (settings_invites_share), "Kopiera kod" (settings_invites_copy_code); pending row "Enhet för Jacob, gäller till tisdag 20 oktober 14:02" + "Avbryt inbjudan".

S-22 About (:feature:settings)

Layout (app bar: back, "Om Kvitt"; column gap 10): centred app mark 96 (Sunny, primary, receipt_long 48), "Kvitt 1.1 (11)" (headline-medium), "Delade utgifter för två" (body-medium muted); UpdateBanner when an update is available; group of FactRows: Server (dns, URL wraps), Serverversion (memory, "1.1.0 (schema 3)" from /health when S-22 opens online, settings_about_server_version_schema; offline or when /health fails the last response's server-version header without the schema, "1.1.0", or "Okänd" when none is known; D-110); no open-source licences row (OQ-5, D-099); "Sök efter uppdatering" (tonal medium, system_update, centred; hidden in debug builds, AC-16.5).

States

Frame State
S-22-about loaded

Interactions: "Sök efter uppdatering" → check() with progress in the button; result: the banner appears, or snackbar "Du har den senaste versionen"; network → "Kunde inte nå servern".

Notifications (:app builds them, copy in :core:design)

Built locally after the sync per AC-13.5; channel "Aktivitet" (notif_channel_activity). Small icon: the monochrome receipt_long app mark; accent colour primary; large icon: the actor's avatar when cached (else none). Title and body per §13.3 in the phone's language (notif_*), e.g. "Anna lade till en utgift" / "ICA Maxi Lindhagen · 1 247,60 kr · Vardag"; "Anna registrerade en betalning" / "Anna betalade dig 800,00 kr". Generic fallback without amounts when the sync fails. Tap per AC-13.6.

Frame State
N-notifications the notification shade with two notifications

5. The add-expense flow (S-7) in detail

The most important flow (§13.1). It must need at most: open app, tap add, type amount, type description, tap save; account and category are defaulted (AC-8.6, and UX-3 for the category).

5.1 The ten-second path (binding)

Step User Screen
1 Taps "Lägg till utgift" on S-4 (or S-5) S-7 slides up: amount focused, keypad up, all defaults set (Konto per AC-8.6 or the S-5 account; category = the step-4 default of CS-5; Datum "Idag"; Betalt av "Du"; Delning "Kontots standard …"). "Spara" disabled
2 Types 4 1 2 on the keypad "412" in the amount; preview computed; "Spara" still disabled (no description)
3 Taps "Beskrivning", types "Systembolaget" keypad slides out, the Swedish system keyboard in; on every change the suggestion re-runs (§5.3) and the quick row selects Nöje; "Spara" enabled
4 Taps "Spara" (app bar) or the keyboard's Done save → Saved; the editor closes; S-4 shows "Utgiften sparades" (offline: "Sparad. Synkas när du är online."); balances already include it (AC-10.4); sync after 2 s (AC-15.3)

One tap each, never more, for the common overrides from step 3 on: payer (tap "Betalt av" toggles), category (tap a tile), split (tap "Delning" opens the section; one more tap picks a mode). Nothing else needs touching.

5.2 Keypad and keyboard

  • The keypad (§3.7) owns the amount, the exact-share field and the ratio percent fields; the system keyboard owns the description and the notes. Only one of the two is visible.
  • Focus order: amount → description → (notes when expanded). Tapping the description while the keypad is up swaps keypad for keyboard in one motion (keypad slides down 150 ms while the IME animates in; the content keeps the description visible above the IME with imePadding).
  • Tapping outside any field hides both.
  • The keypad never inserts invalid input (§9.6); the amount needs no separate validation message for format, only for "required" and the range.

5.3 Category suggestion (UX-3, CS-1 … CS-7)

  • Scope (CS-1): new expenses only. In edit mode (S-7-editor-edit, S-7-editor-full) the saved category stays and nothing is suggested.
  • Manual flag (CS-2): categoryPickedByHand = false on open. Tapping any tile in CategoryQuickRow or CategoryGridSheet sets it true, also when the tile was already selected. It is never reset in this editor session.
  • Runs (CS-3) while the flag is false, on every description change and every account change, by calling ExpenseRepository.suggestCategory(accountId, description) (UX-3; local, no network); debounce 150 ms so typing stays smooth. A non-null result becomes the selected category; null (no suggestion possible) leaves the step-4 default (CS-5 step 4; for an empty description, CS-7).
  • Order (CS-5): history exact → history first token → keyword table → default; "most often", ties by most recent. The UI does not know which step answered.
  • What the user sees (CS-7): the Kategori selection simply moves to the suggested tile (container → solid cross-fade and check badge, §2.11); no caption, no marker, no snackbar. If the suggested category is not among the five tiles it takes the fifth place (§3.7). TalkBack does not announce suggestion changes (no live region); the selected tile reads "vald" when focused.
  • Clearing the description returns the selection to the step-4 default (CS-7).
  • A wrong guess costs one tap: the right tile, or "Alla" and the right category.

5.4 Split override

  • Closed state: the "Delning" chip shows the current mode and ratio ("Delning Kontots standard 60/40"); SplitPreview under the chips shows the shares.
  • One tap on the chip opens SplitSection in place (spatial default spring) and scrolls it into view; the chip turns secondary-container with expand_less; the keypad switches to its compact height when a keypad field in the section gets focus.
  • Picking a mode is one tap; "Allt på en person" then needs the bearer (one tap; default the partner when the viewer pays, else the viewer — the person who did not pay is the usual reason for "all on one"); "Egen fördelning" starts at the account's ratio; "Exakta belopp" starts with the viewer's field empty and focused.
  • Closing the section keeps the chosen mode. Changing the account in mode default re-copies its ratio (AC-8.8); in other modes the split is kept.
  • The preview is always ExpenseRepository.preview(draft); when it returns null the preview hides and "Spara" is disabled.

5.5 Edit mode

Same editor with "Ändra utgift", fields loaded from load(id), nothing focused and the keypad hidden (S-7-editor-edit); tapping the amount shows the keypad. The split section is open when the mode is not default. Notes are expanded when present. "Ta bort utgift" at the end. Save returns to S-8.


6. Open points

  1. Quick-row usage data (UX-9). CategoryQuickRow needs ExpenseEditorOptions.frequentCategoryIds (proposed in proposed-decisions.md UX-9: per active account, up to five active categories by use count in that account, ties by sort_order, from the Room copy). Until it lands the row uses the fallback of §3.7.
  2. Category deletability (UX-9). S-15-category-editor-custom needs CategoryRepository.deletable(id) (UX-9). Until it lands, "Ta bort kategori" is enabled for custom categories and Failed("category_in_use") shows the in-use reason inline after the tap.
  3. Recurring editor suggestion. CS-1 limits the suggestion to new expenses; S-19's editor uses the AC-8.6-style default (last category of the account, else Övrigt) and no suggestion (category-suggestion.md open point 3).
  4. System font on One UI. FontFamily.Default may resolve to a One UI font style instead of Roboto; M6 checks on the owner's S26 that amounts stay tabular and weights 500/600/700 are distinct (D-020 stands either way).
  5. Expressive API status. Every Expressive API of §3.1 is checked against the current Material 3 release when M6 pins it; any that is not usable takes its listed fallback without a design change.

Resolved in the U2 alignment pass: the catalogue frames follow this file (payer chip swap_horiz, ratio presets 40/60 · 50/50 · 60/40, allocation remove buttons, S-13 "14 aktiva" without an "Enheter" subtitle and without a device name, S-16 rows "Väntar på synk", S-12 count caption), and every label quoted here has a strings.csv key (including the split-section collapse label, the keypad's "Rensa" action, the S-10 missing state and the category delete confirmation).

Recorded after U2 in SPEC v0.10: open points 1 and 2 are resolved by M12d (D-059 to D-062) and open point 5 by M6 (D-064); English "Dining" for restaurants (D-067, so §2.13 reads "Dining") and ledger_settlement_label, the settlement-title wrap and the empty-chart baseline (see D-068) live in SPEC.md and strings.csv, which win where this file differs.