Skip to content

ADR-0069: Emphasis, surface and type roles are named once

  • Status: Accepted
  • Date: 2026-10-17
  • Amended: 2026-10-20 (Warm ledger — hero/nav/income/expense role extensions and the dynamic-colour rule; ADR-0079)

Context

ADR-0062–ADR-0068 gave the app tokens for spacing, shape and type and one implementation per reusable control, but stopped short of naming roles for colour emphasis, surfaces and typography tiers. Screens therefore still decided those per screen: grouping used the Material Card default (surfaceContainerHighest — the heaviest tonal surface), so every section carried equal visual weight; hero emphasis was ad hoc; and nothing said which type step owns a dominant amount versus a supporting value. Issue #180 ("Material 3 design system and complete UI refinement") asks for one calm, precise hierarchy — "clear financial hierarchy, restrained tonal surfaces and clear actions" — which needs those roles owned once.

Decision

Roles are named in ui/theme/Roles.kt (AppSurface, AppTextRole) and only composed from the existing colour scheme and type scale — no new literals.

  • Grouping surface — AppSurface.grouping is surfaceContainerLow, the low, restrained container for an ordinary group (a card, a section). Normal repeated ledger rows stay plain — no per-row surface, no nested cards. The Material Card default (surfaceContainerHighest) is no longer a section surface.
  • Hero surface — AppSurface.hero is the primary-filled container with the paired onPrimary foreground. Exactly one destination (Home) owns a hero; no other full-screen destination may add another full-bleed emphasis surface.
  • Emphasis and action — AppSurface/buttons use primary for the key action; primary copy is onSurface, supporting copy is onSurfaceVariant.
  • Warning — the error family signals an actual failure or an over-limit state only. A financial amount stays onSurface; an over-limit message and its progress use the contrast-corrected error roles (budgetOverLimitTextColor, ADR-0009-adjacent #152). Exactly 100 % is at limit, not over.
  • Type roles — AppTextRole.heroAmount is headlineLarge (Home), compactAmount is headlineSmall (category spending and the compact Reports total), identity is titleMedium (merchant / category name), supporting is bodyMedium, status is labelLarge. A financial amount is never truncated, size-reduced, k/M-abbreviated or horizontally scrolled to fit.
  • Never colour alone — income/direction/state also carry text or an icon.

scripts/ui-guard.sh enforces the parts a reviewer could otherwise miss: a destination may not hardcode surfaceContainerHighest/surfaceContainerHigh for grouping (it composes AppSurface.grouping), with deliberate exceptions recorded in the allowlist.

Consequences

A section reads as a restrained group and the hero as the single emphasis surface, so hierarchy no longer has to be re-decided (and re-litigated) screen by screen. Extending the language means adding a role here and — if it is mechanically checkable — a rule to ui-guard.sh; a screen that genuinely needs a heavier surface records it in the allowlist with a reason. The guard checks structure (which role, which token) and the cross-screen tests own geometry; neither judges pixels — visual review does.

Warm ledger amendment (2026-10-20)

The role set grows to cover the Warm surfaces and directions:

Role Static Warm value Dynamic-colour fallback
AppSurface.hero #1D5136 light / #224B33 dark primary
AppSurface.onHero #FFFDF7 / #F8FBF2 onPrimary
AppSurface.heroMuted #C9DDCE / #C3D8C7 onPrimary (full; amended by ADR-0087)
AppSurface.heroAccent #F3C978 both tertiaryContainer (amended by ADR-0087)
AppSurface.nav #F1F4EA / #141A12 surfaceContainer
AppSurface.income — primary
AppSurface.expense — onSurface (neutral, never error)

Dynamic-colour rule: the static Warm palette is supplied only when LocalDynamicColor is false. When dynamic colour is on, every surface role is a step of the active dynamic ColorScheme; no brand literal may leak into the wallpaper-derived scheme. AppSurface.grouping remains surfaceContainerLow and text roles remain scheme steps in both cases. A financial amount is always primaryText/income, never error; error is reserved for a real failure or an over-limit state (ADR-0079).

The "exactly one destination (Home) owns a hero" rule means one full-bleed hero. Reports' bounded month-insight card reuses the same hero role without adding a second full-bleed emphasis surface; every other grouping remains AppSurface.grouping.

Verification

RoleContractTest (JVM) asserts the named roles resolve to the documented steps — AppSurface.grouping equals the scheme's surfaceContainerLow, AppSurface.hero equals primary/onPrimary when dynamic colour is on, and the amount/identity/support type roles match their scale entries. WarmLedgerContractTest pins the static Warm hero/nav literals and the dynamic fallback contract; scripts/ui-guard.sh rule 5 fails when a destination hardcodes a heavy container instead of AppSurface.grouping, and rule 8 fails a raw colour literal outside ui/theme.