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.ktis 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:
- a file outside
ui/components/SearchField.ktpairs anOutlinedTextFieldwith a search string (a second search field); - a top-level destination does not apply
Spacing.titleToContent, or uses a raw 8/16 dp top literal (a hand-rolled screen rhythm); - a file under
ui/settings/hand-rolls aKeyboardArrowRightchevron or a rawListItemrow instead ofSettingsNavigationRow/SettingsRow; - a settings section bypasses
SettingsSectionCardor a row paints its own background (the section card owns the surface); - a destination hardcodes a heavy
surfaceContainerHigh/Highestcontainer instead ofAppSurface.grouping; - the composable that applies
Spacing.titleToContenthand-rolls a screen-level vertical gap — a rawArrangement.spacedByliteral, a raw 8/12/16 dp padding literal, or a different token step instead ofSpacing.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.