Skip to content

ADR-0087: Adaptive month grid, short-height rail and bounded reading width

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 ↔ tertiary contrast 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.