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.groupingissurfaceContainerLow, 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 MaterialCarddefault (surfaceContainerHighest) is no longer a section surface. - Hero surface —
AppSurface.herois the primary-filled container with the pairedonPrimaryforeground. Exactly one destination (Home) owns a hero; no other full-screen destination may add another full-bleed emphasis surface. - Emphasis and action —
AppSurface/buttons useprimaryfor the key action; primary copy isonSurface, supporting copy isonSurfaceVariant. - Warning — the
errorfamily signals an actual failure or an over-limit state only. A financial amount staysonSurface; 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.heroAmountisheadlineLarge(Home),compactAmountisheadlineSmall(category spending and the compact Reports total),identityistitleMedium(merchant / category name),supportingisbodyMedium,statusislabelLarge. 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.
Related
- ADR-0062 — One spacing scale
- ADR-0065 — The design language is enforced, not re-decided per screen
- ADR-0066 — Wide destinations share one adaptive content width
- ADR-0068 — One settings section card
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.