ADR-0087: Adaptive month grid, short-height rail and bounded reading width
- Status: Accepted
- Date: 2026-11-02
- Related: ADR-0042, ADR-0044, ADR-0066, ADR-0069, ADR-0071, ADR-0079
Context
W08 rechecked the remaining adaptive-layout hypotheses at their named seams.
The month picker assumed three columns and a single-line label, the rail assumed
its five items always fit the window height, and Settings/category editors had
never adopted the shared reading width. Onboarding's bank step put the only
scroll viewport inside a weighted child, and the lock screen did not consume
system/IME insets. The dynamic-colour hero also paired a cross-role accent
(tertiary) with primary.
Measured before the change at 320 dp / 200 % text and a 640×320 dp window:
- Settings index and category editor content were 1368 dp wide in a 1400 dp window (no reading bound).
- The rail's selected Settings item sat at 296–352 dp in a 320 dp viewport, below the last visible pixel, with no scroll container.
- The short onboarding bank row was laid out below the 320 dp viewport, and its zero-height inner scroll viewport could not reveal it.
- Dynamic light
hero↔tertiarycontrast was 1.00:1; dynamic dark was 1.00:1 (the two roles use the same tonal step). Supporting copy at 72 % on-primary measured 4.27:1. - Warm static hero accent remains 5.89:1 and is unchanged.
Decision
Measured month grid (#385)
MonthPickerDialog keeps the year stepper and renders MonthPickerGrid in a
vertical scroll. The grid measures the widest TextStyle.FULL month name in
the active locale, adds its cell padding, and uses a column count only when that
whole name fits unwrapped: 3 → 2 → 1 columns as width tightens. Month names are
largely single words, so a narrower cell can break a name rather than reflow at
a word boundary; dropping a column keeps every glyph intact. Every cell keeps the
selectable role, its selected state and a ≥48 dp height. When the grid becomes
taller than the dialog, it opens on the highlighted month through
BringIntoViewRequester rather than always at January. The pure
monthGridColumnCount function owns the decision and is JVM-tested.
Short-height navigation rail (#385)
The rail's own vertical scroll recovers the overflow. Every item reports its
position in the scrolling content, and a selection (initial, restored,
deep-linked or newly chosen) is revealed with the smallest move when the
viewport or selection changes. The reveal uses Motion.NAV_REVEAL_MILLIS and
is a no-op when the item is already visible. Labels may wrap to two lines at
large text. Because the rail is the logical-start child of the shell Row, RTL
places it on the right without a separate layout branch.
Existing reading width, not a new one (#388)
SettingsIndex, SettingsTaskScaffold and CategoryEditContent adopt the
existing AdaptiveContentColumn (ADR-0066) instead of inventing a second
width owner. The destination column remains 1200 dp at 600 dp+ and 600 dp
below, keyed to available content width. Onboarding's bank step scrolls the
whole step with the primary action pinned, so a short or large-text window
cannot collapse the list to zero height.
Insets and contrast (#388)
The lock screen consumes WindowInsets.systemBars (status and navigation bars;
a display cutout is a separate inset and is not included), keeps its content
scrollable, and compacts its vertical rhythm below 640 dp. The IME is
deliberately not consumed: consuming it collapsed the 320×640 lock viewport to
~82 dp when the hidden PIN field focused and broke hardware PIN entry on the CI
image. No element is positioned relative to the IME: an open keyboard may
overlay any lower content, and visibility of the PIN field, Unlock, biometrics
or the keypad while it is open is not claimed. The 320×640 lock suite passes
with the IME closed. The rail owns
NavigationRailDefaults.windowInsets on its measured viewport and gives the
rail empty insets, so a revealed item never aligns under a system bar. Dynamic
colour uses the tonal tertiaryContainer accent (measured 5.01:1 light /
5.53:1 dark on the hero) and
full on-primary supporting copy (contrast-safe for every generated hue; a
translucent blend is hue-dependent); Warm Ledger literals and the static palette
are untouched. The onboarding illustration's decorative dot uses the
tertiary role on its pale grouping tile, because the hero-container accent
would disappear there in dynamic light. Destructive confirmation buttons now use the
error content role: the category-delete confirmation, the Activity list delete,
the transaction-detail delete and the review-queue clear-all. Settings
delete-all already used it. “Remove this month's limit” stays the ordinary
primary role because it is recoverable and not a row/category deletion.
Motion ownership (#389)
Motion remains the only owner: NAV_REVEAL_MILLIS replaces the duplicated
compact-bar constant, and shakeAmplitude is an 8 dp value converted through
the real density by shakeAmplitudePx, replacing fixed 8 px lock offsets. The
lock's 27/32 title remains the one deliberate typography exception (a
lock-specific size built from the shared display role); component defaults
(animateColorAsState, animateContentSize) stay intentional Material
component motion. All tween specs continue to honour the system animator
scale, so scale 0 settles immediately.
Kept hypotheses
These were inspected and kept, with the reason recorded instead of changing code:
- Budget percent pill at 320 dp / 200 % text with an uncapped
2147483647 %keeps the pill inside the card; the weighted category name wraps. - Reports tap hint already names both “slice or bar”; it stays in the donut card because that is where the hint is first encountered.
- Attachment image repetition is two distinct roles: the attachment list row owns name/size/Open/Delete, while the thumbnail strip is the visual receipt preview. Removing either would lose a task.
Consequences
No new width or motion owner is introduced; the shared tokens/components gain new consumers. Month labels and rail destinations remain reachable at 320 dp / 200 % text and in RTL. Dynamic colour no longer collapses the hero progress fill into the hero surface. The trade-off is a taller, scrollable rail and month grid on short windows, which is preferable to hiding content.
Verification
- JVM:
MonthPickerColumnsTest,RoleContractTest,WarmLedgerContractTest(motion constants and density conversion). - Instrumented:
MonthPickerAdaptiveTest(320 dp, 200 %, light/dark, en/de),NavigationRailAdaptiveTest(640×320 dp, 200 %, selection change and resize, RTL),W08AdaptiveLayoutTest(tablet widths, short onboarding, uncapped budget pill),HeroAccentContrastTest(warm/dynamic, light/dark, ≥3:1 accent and ≥4.5:1 copy), plus the existing picker/rail/compact-navigation/lock suites.