Skip to content

ADR-0065: The design language is enforced, not re-decided per screen

  • Status: Accepted
  • Date: 2026-10-17

Context

Three 0.18.1 symptoms had one root cause: no owner for the app's visual language. The title-to-content gap was 8 dp on one screen and 16 dp on another (ADR-0062), the app shipped two search fields (ADR-0064), and each settings row composed its own ListItem (ADR-0063). Nothing stopped every new screen from re-deciding spacing, control shape and control implementation, so the drift came back as soon as it was patched.

Decision

The design language is a contract with one owner each:

  • Spacing — ui/theme/Spacing.kt is the only spacing scale (ADR-0062); screens compose outer rhythm and section steps from it instead of bare dp.
  • Shared controls — a screen may not re-implement a control that lives in ui/components/; the search field (ADR-0064) and the settings row (ADR-0063) are the first two, and both call sites route through them.

scripts/ui-guard.sh enforces the parts that a reviewer could otherwise miss. Its rules are targeted and allowlist-backed (scripts/ui-guard-allowlist.txt) so it cannot be flaky or raise false positives; it prints FAIL: lines and exits non-zero like scripts/docs-guard.sh, and runs in the docs-guard CI workflow on pull requests. It fails when:

  1. a file outside ui/components/SearchField.kt pairs an OutlinedTextField with a search string (a second search field);
  2. a top-level destination does not apply Spacing.titleToContent, or uses a raw 8/16 dp top literal (a hand-rolled screen rhythm);
  3. a file under ui/settings/ hand-rolls a KeyboardArrowRight chevron or a raw ListItem row instead of SettingsNavigationRow / SettingsRow;
  4. a settings section bypasses SettingsSectionCard or a row paints its own background (the section card owns the surface);
  5. a destination hardcodes a heavy surfaceContainerHigh/Highest container instead of AppSurface.grouping;
  6. the composable that applies Spacing.titleToContent hand-rolls a screen-level vertical gap — a raw Arrangement.spacedBy literal, a raw 8/12/16 dp padding literal, or a different token step instead of Spacing.sm (ADR-0062). Nested card/surface interiors are ignored, so their deliberate steps stay legitimate.

The written contract lives in docs/architecture/ui.md (Design language), with the reuse step in the "add a screen" recipe and the guard documented in docs/architecture/tooling.md.

Consequences

Drift is caught mechanically before review instead of by eye. Adding a control to ui/components/ and a rule to ui-guard.sh is the way to extend the language; a screen that needs an exception records it in the allowlist with a reason. The guard checks structure (which component, which token), not pixels — the cross-screen tests own geometry.

Verification

scripts/ui-guard.sh fails on the pre-change tree (two search fields, seven destinations without the token, hand-rolled rows, raw screen-level gaps) and passes after the fixes. Rule 6 ships fail/pass self-test fixtures — a raw screen gap fails, while the shared token and a nested card-interior literal pass — so the rule itself is exercised on every run. TitleToContentRhythmTest asserts the shared title gap and the first-control→next-block and block→block steps for every destination, SearchFieldTest and SettingsRowTest assert the shared controls' contracts. From the repo root: bash scripts/ui-guard.sh and bash scripts/docs-guard.sh.