Skip to content

D-pad-only operation (no touchscreen required)

Vela is fully drivable with a 5-key D-pad (↑ ↓ ← → + OK) plus BACK. Touch is a bonus, not a requirement. This document is the authoritative record of the design, the findings that shaped it, every change made, and the rules for keeping the work easy to merge with upstream.

Why this is mostly free in Compose - and where it isn't

Finding 1: Compose gives D-pad focus traversal for free on anything clickable. Every Modifier.clickable, Material button, chip, switch, checkbox, dialog button and dropdown item is already a focus target: arrows move focus spatially, OK activates. Roughly 90% of Vela's UI (search rows, place-sheet actions, settings, dialogs, route picker, nav controls) needed no operability work - only focus visibility work.

Finding 2: five things were genuinely touch-only. These were the real gaps:

  1. The map itself - pan/zoom/tap/long-press are all gestures on the MapLibre view.
  2. The search overlay's focus semantics - two traps (below).
  3. Sheet handles - the place sheet's expand/collapse handle was a detectTapGestures
  4. drag detector (no focus target, no key activation). The results list and the directions panel already had button/clickable alternatives.
  5. The nav maneuver banner - stepping through upcoming turns was swipe-only.
  6. The full-screen photo viewer - paging was swipe-only.

Finding 3: focus visibility is the other half. Material's default focus indication (a faint ripple state layer) is too subtle on Vela's fixed-gray sheets. Without an obvious ring you can't tell where you are, which makes the app technically operable but practically unusable.

Design

Core helpers - app/ui/DpadFocus.kt (new file)

  • rememberDpadFirstDevice() - the D-pad is a PRIMARY input, so default to a D-pad-first UI (affordances shown persistently, initial focus/engage placed for the user). Detection is deliberately conservative (rewritten 2026-07-08 after it broke touch phones):
  • FEATURE_TOUCHSCREEN - genuinely touchless (Android TV / a real no-touch keypad) is the one unambiguous signal. Feature phones lie about it (the MTK panel reports touchscreen=finger), so it only ever produces a true-negative, never a false-positive.
  • A PHYSICAL InputDevice with SOURCE_DPAD - a game controller, remote, or a keypad whose own hardware exposes the D-pad.
  • Do NOT count the framework's Virtual aggregate device (id −1). It reports KEYBOARD | DPAD on essentially EVERY Android phone (confirmed on a Pixel 9 via dumpsys input). An earlier version enumerated it (the note here used to insist on including virtual devices), which classified ordinary phones as keypad devices and broke the search bar - a plain tap stopped opening the field / raising the keyboard, and the +/− zoom buttons appeared on a touch phone (repo-owner + tester reports 2026-07-08).
  • KeyCharacterMap.deviceHasKey(DPAD_CENTER) is dropped - true on a Pixel too (the virtual keymap carries the key) and already false on the MTK phone, so it only added false positives.
  • A fake-touchscreen keypad phone therefore isn't detected as D-pad-first; it still gets full D-pad operation REACTIVELY the moment a key is pressed (via rememberDpadMode), just without pre-placed focus on the very first frame. That is the price of never breaking touch, since no startup signal separates such a phone from a Pixel.
  • rememberDpadMode() - dpadFirst || inputMode == Keyboard. True on a D-pad-first device, and on any other device the instant Compose sees a non-touch key event (affordances appear on the first key press, melt away on the next tap). This is what carries hybrid/keypad phones now that dpadFirst no longer trusts the virtual device.
  • Modifier.dpadHighlight(shape) - a 3 dp focus ring (fixed orange since the 2026-07-23 Settings redesign, see the last section; it was the teal primary before), drawn only while the element (or a descendant - Material buttons host their own focus node, and onFocusEvent.hasFocus covers both) holds focus and the UI is key-driven (honors dpadFirst directly, since a D-pad-first phone may still read inputMode == Touch until the first key event). Never appears under touch.
  • rememberDpadAutoFocus(vararg keys) - D-pad-FIRST initial focus. Returns a FocusRequester; attach it to a screen's primary element via Modifier.focusRequester(...) and focus is placed there the moment the screen/overlay appears - so the user never has to press a key just to wake up focus (see "Initial focus" below). Retries 20 × 50 ms because a freshly-composed focus node usually isn't attached on frame 1 (the first requestFocus() throws and nothing ends up focused - the exact reason the map-target acquisition retries). Only requests on a D-pad-first device, so touch UX is byte-identical. Its sibling Modifier.dpadAutoFocus() does the same as a Modifier but keeps re-requesting until onFocusEvent confirms focus actually landed (not just "requestFocus didn't throw") - use it when the target may be off-screen; see Known limitations.
  • Modifier.dpadFieldEscape() - makes a text field escapable by D-pad: UP/DOWN move focus to the previous/next form control instead of being swallowed by the field's own cursor handling. A single- or multi-line TextField/BasicTextField otherwise eats the vertical arrows, trapping focus on the field so nothing below it is reachable (measured on-device: the Settings "Try the voice" / variant-jump / routing-filter / voice-search fields, and the reviews search field - DOWN sat blinking in the field, the controls under it never got focus). Fires via onPreviewKeyEvent (root→leaf) so it wins before the field consumes the key, and swallows the matching key-up so the field never sees a half event. Returns false at a list edge where focus can't move, so the field still behaves normally there. Inert under touch (gated on rememberDpadMode). Apply to any text field sitting in a vertical list of focusable controls. (The search bar's field solves the same trap inline - see Trap C below - because it also needs the BACK/close handling in the same key handler.)

  • UP at the top of a TopAppBar screen (Settings): the back button lives in the TopAppBar, a separate container from the scroll Column, so Compose's directional UP can't bridge them and an UP from the top row clears focus (nothing focused, no way back via arrows). Settings tracks when its top row holds focus and routes that UP to the back button with requestFocus - moveFocus(Up) can't be used here because it itself clears focus at the top edge before it returns.

The MapLibre MapView steals D-pad keys - the load-bearing fix

Finding 0 (the reason "nothing happened"): MapLibre's MapView calls requestFocus() on itself and overrides onKeyDown to handle hardware keys (DPAD_CENTER = zoom in, arrows = scroll). On a keypad phone it therefore grabs focus and swallows every D-pad key before Compose focus ever sees it - arrows did nothing visible, OK zoomed the map, and no Compose focus ring ever appeared (proven on-device: OK zoomed 2000→1000 mi). Fix (VelaMapView.kt): make the MapView and its descendants non-focusable (isFocusable = false, descendantFocusability = FOCUS_BLOCK_DESCENDANTS), unconditionally and re-asserted in the AndroidView update block (MapLibre re-enables it on surface recreation). Touch gestures don't need view focus, so nothing is lost; keys now flow to the Compose focus system and MapDpadController drives the map instead.

The map - MapDpadController (new file) + a focusable center target

app/ui/map/MapDpadController.kt is the key→camera seam. VelaMapView wires it up in getMapAsync alongside the touch listeners; MapScreen owns the key handling. The controller deliberately reuses the exact same code paths as touch:

  • selectAtCenter() calls the same tap-resolution lambda the click listener uses (search-result pins → ambient POI dots → alternate-route lines → basemap POIs → unnamed-POI reverse geocode). The touch listener body was only named (handleTap)
  • not moved or reindented - so upstream edits to it merge cleanly.
  • longPressAtCenter() → the same onMapLongPress path (drop pin / set a Choose-on-map point).
  • panBy() sets the same gestureMove flag a drag sets (so "Search this area" and camera-idle viewport updates fire) and detaches the nav follow-camera exactly like a finger pan.
  • zoomBy() during nav adopts the manual nav-zoom override exactly like a pinch (navUserZoom); in browse it counts as a user gesture.

MapScreen's map target: a 140 dp focusable box at the screen center, with a two-stage model:

  • Focused (pill: "OK: move the map"): a normal focus stop. Arrows keep traversing the chrome - search bar, chips, zoom buttons, FABs, sheets all stay reachable. OK engages map control.
  • Engaged (crosshair + screen-edge ring):
Key Action
↑ ↓ ← → pan by 22% of the view (auto-repeats when held)
OK (short) "tap" whatever is under the crosshair; in Choose-on-map mode, confirm the pick
OK (held ≥ 500 ms) long-press at the crosshair (drop pin / set pick directly)
+/− or zoom keys zoom (bonus for devices that have them)
BACK disengage - focus stays on the target, arrows traverse the chrome again

History (v1 trap, fixed same day): v1 had a single stage - the map consumed all four arrows whenever it merely held focus, and BACK cleared focus to nowhere, after which Compose's focus restoration put the next key press right back on the map. Result: "no way to grab the search bar or switch focus to anything but the map". The two-stage model is the fix: panning is an explicit OK-entered mode, and leaving it never clears focus. Don't regress this - a focusable that consumes arrows must always be a mode you enter, not a place focus merely lands.

Long-press detection uses the native key event's repeatCount/eventTime − downTime (≥ 500 ms while held), so the pin drops while holding, like touch. On a D-pad-first device the app starts focused + engaged, so the first key press already pans the map; one BACK hands the arrows to the chrome.

Panel-aware (proven fix): the map target is shown/focusable ONLY when the map is the primary surface. With a list/sheet/panel/search open (mapTargetHidden), it unmounts so the panel owns focus - a center crosshair + focus stop floating over the results list stole DOWN traversal into the rows (measured: DOWN from the results header jumped to the zoom + button, never reaching a result). Returning to the bare map re-acquires + re-engages it via LaunchedEffect(dpadFirst, mapTargetHidden) (retries because the focus node may not be attached on the first frame). Nav keeps the map primary (the banner is an overlay), so you can still pan to look around during nav.

Choose-on-map is the one exception (sweep fix, 2026-07-07). "Choose on map" (setting a route origin/stop by placing a pin) opens with directionsOpen still true underneath, which mapTargetHidden would normally treat as a panel and unmount the map target - but this mode requires the map be pannable so the user can position the pin. Without the exception the arrows only moved focus to the cancel ✕ and the pin couldn't be moved (measured). So mapTargetHidden now has a leading state.pickOnMap == null && guard: while a pick is in progress the map target stays mounted and auto-engaged, so arrows pan immediately and OK confirms the pick (hold-OK sets it directly). Two matching cosmetic tweaks: the "OK: move the map" focused pill is suppressed during a pick (mapFocused && state.pickOnMap == null) - there the arrows already pan and OK confirms rather than enters map control, so that pill would be a lie - and the crosshair/pin + "Move the map to set…" banner come from the existing ChooseOnMapOverlay instead.

Zoom buttons: pinch has no 5-key equivalent, so a D-pad-mode +/− pair shows on the map. Shown only while browsing the bare map (not during search / results / place sheet / directions / nav) - in their first spot, mid-right, they sat in the vertical focus path of those panels and intercepted DOWN into their rows (measured). Behind a panel the map is covered anyway; zoom it via the engaged crosshair after closing the panel. Since 2026-09-14 (issue #393) the pair is one pill in the bottom-right stack, above the parking button, and touch phones get it too when Settings > Navigation > "Prefer buttons over swipes" is on (dpadMode || PreferButtons.on in MapScreen).

Initial focus on every screen (D-pad-first, hard rule - sweep 2026-07-07)

Rule: no screen or view may open with nothing focused. On a D-pad-first device the app is driven entirely by keys, so a screen that appears un-focused wastes the user's first keypress just establishing focus instead of acting - that press should already be doing something. Every screen/overlay must land already focused on a sensible element.

Compose does not give this for free: it only auto-focuses in a few cases, and when a focused element is removed (e.g. a results row → the place sheet), focus recovery is nondeterministic - measured on-device it landed sometimes on a photo, sometimes on the search bar behind the sheet, sometimes nowhere. The fix is rememberDpadAutoFocus() (Core helpers) attached to each surface's primary element:

Screen / overlay Auto-focus target Was broken?
Bare map nothing on open - the map neither auto-focuses nor auto-engages; the user's first arrow lands on the search bar (the first focusable). See note below. changed 2026-07-08
Settings back button (top of screen) nothing focused - confirmed + fixed
Welcome Get-started button fixed
Place sheet drag handle focus leaked to the search bar behind it - fixed
Directions panel first travel-mode tab (Drive) fixed (classic panel; the Google-style chooser, the default since 2026-09-18, has no auto-focus target yet, see ROADMAP "D-pad hardware pass")
Route steps sheet first step row (preview); the current step row while navigating fixed
Reviews WebView (full-screen) back arrow (until the WebView loads + grabs focus) fixed
Photo gallery (pre-existing galleryFocus) already OK
Search overlay armed search field already OK (arming focuses it)
Nav map stays primary (the banner is an overlay) already OK

Proven on-device (focus dumped immediately on open, no keypress): Settings → back button, place sheet → handle, directions panel → Drive tab all land focused. The three share the one helper, so the sheet/reviews/steps that attach it identically follow. Results after a search keep focus on the search field (Google-style - you can refine or press DOWN into the list); that's "already focused", so it's left as-is.

Reproducible verification: ../dpad_test_suite/. The manual adb focus-dump checks used throughout this doc are scripted there. Run all three after any change that touches focus (audit_static.sh needs no device):

  • run_all.sh - per-surface assertions (bare map → search bar, Settings back button, Welcome/dialog auto-focus, place-sheet handle + VelaMenu, Choose-on-map engages, Directions pill reachable).
  • audit_static.sh - EXHAUSTIVE source scan: every clickable/toggleable/selectable has a dpadHighlight ring, every gesture (detect*Gestures/draggable/…) has a key path, no bare DropdownMenu/AlertDialog, no isSystemInDarkTheme, plus triage notes for bare .focusable() / raw windows / text fields. Fails the build on any real violation.
  • audit_dynamic.sh - EXHAUSTIVE on-device tour: every surface opens focused, focus is never lost across a full DOWN-traversal (a null sample = a dead-end trap), and BACK exits.

Focus rings on the place sheet (2026-07-08). The place sheet's custom clickable content rows (the Directions/Call/Street-View action pills, route-alternate rows, From/Add-stop/To editors, phone/website rows, "also at this location", "people also search for", photo thumbnails, hours/ transit/popular-times expanders) originally had no dpadHighlight, so they were focus-reachable but invisibly (Material's default state-layer is too faint on the fixed-gray sheet). They all carry a ring now - audit_static.sh enforces it going forward. Same for the Settings voice-group headers.

Choose-on-map auto-engages (2026-07-08). The bare map opening un-focused (above) removed the global auto-engage that Choose-on-map depended on, so pick mode opened dead (nothing focused, map not engaged, crosshair/pill suppressed in pick mode). Restored scoped to pick mode: a LaunchedEffect(pickingOnMap) in MapScreen requestFocuses + engages the center target the moment state.pickOnMap goes non-null, so arrows pan immediately to place the pin and OK confirms. This works (unlike the cold-open bare map) because pick mode is entered mid-session, so focus already exists and requestFocus lands.

Settings horizontal-key focus trap (2026-07-08, found by audit_dynamic.sh's multi-axis walk). Settings is a Column(verticalScroll). A LEFT/RIGHT press on a plain row (a SelectableRow, a switch row - no horizontal neighbor) made Compose's focus search CLEAR focus outright, with no way back via arrows (only BACK escaped). Root cause: moveFocus clears on a no-target directional move in a scrolling column; focusGroup() doesn't help - only swallowing the key keeps focus. Fix: the reusable Modifier.dpadSwallowHorizontal() (DpadFocus.kt) on the Settings root Column AND on the top-bar back button (it lives outside the Column, so it needed it too - the auditor's 1/44 residual). It fires AFTER a focused child's own handler. The ONE horizontal row, the vibrate FilterChips, drives its OWN LEFT/RIGHT via per-chip FocusRequesters (requestFocus never clears at the ends, and consuming the key stops it reaching the root swallow), so it still walks Driving↔Walking↔Cycling↔ Transit. SelectableRow's RadioButton is also onClick = null (display-only) so the row is a single focus stop. Any new vertical-list screen with a lone horizontal row wants the same shape.

Switch rows follow the same single-focus-stop pattern (fixed 2026-07-08, user report). The Settings toggles used to be a bare Row + focusable Switch: traversal stopped on the Switch, whose only focus indication is Material's faint state-layer halo, invisible against the dark theme. From the Map section to the updater (nine sections, 15 toggles) a D-pad user lost the cursor completely and the screen read as "not D-pad compatible". Fix: ToggleRow (now in ui/settings/SettingsComponents.kt) mirrors SelectableRow - dpadHighlight ring + clickable on the ROW, Switch(onCheckedChange = null) display-only. Bonus for touch: the whole row is now tappable, like every platform settings app. Don't add a bare focusable Switch/Checkbox anywhere; wrap it in a ringed row.

The bare map is the ONE intentional exception (2026-07-08). It used to auto-focus AND auto-engage the center map target on open, so arrows immediately panned and you had to press BACK before you could reach the search bar (user report). Now the map neither auto-focuses nor auto-engages: nothing is focused on open, and the user's first arrow lands on the search bar (Compose's real-first-key initial focus picks the first focusable, which is the search bar). Why not just pre-focus the search bar? Compose won't let us on the opening screen - verified ~13 ways: requestFocus no-ops while nothing is focused yet; moveFocus only ever lands on the center map target (even after removing its FocusRequester); moveFocus(Up)/Enter and synthetic KeyEvents don't take. So "nothing focused, first key → search bar" is the closest reachable behavior - and it doesn't violate the spirit of the always-focused rule (the map is ambient; the first key isn't wasted, it goes straight to search). From the search bar, DOWN walks to the category chips and the map target; OK on the map target engages it to pan.

Trap A: focusing the field opened the overlay. searchOpen keys off field focus, so merely walking focus across the search bar flipped the whole screen into the search page. Fix (SearchBar.kt): in dpadMode the field is unfocusable (focusProperties { canFocus }) until armed - OK on the search text region arms + focuses the field. Un-focusing disarms. Touch phones: dpadMode false ⇒ byte-identical.

The arm clickable goes on the text region, NOT the whole Card. A first version put it on the Card, which made the entire bar one focus stop and swallowed the Settings gear inside it - the gear became unreachable by D-pad (measured: RIGHT stayed on the bar, never reaching the gear). With it on the text region, the gear / clear / back IconButtons stay independently focusable (Settings proven reachable + navigable on-device).

Trap B: no way back out (the reported bug, root-caused on-device). Two compounding causes, both fixed:

  1. A derived focus-latch (searchHold && searchTreeFocus) kept searchOpen true after clearFocus() because focus never fully left the overlay tree - so BACK could never close it. Replaced with an explicit searchExpanded boolean: opened on field focus, closed on touch-blur / BACK / once a search runs or a place is picked (LaunchedEffect(results, selected, picking…)). Deterministic; no latch to get stuck.
  2. A shown soft IME holds an active InputConnection that swallows the BACK key, and BasicTextField's built-in "BACK clears focus" ate the KeyDown before a KeyUp handler could run - so it took THREE presses to escape (measured: IME-hide, blur, close). Fix: in dpadMode don't raise the IME (the keypad types on hardware keys straight into the focused field), and catch BACK on the field via onPreviewKeyEvent + KeyDown (fires before the field's own handling). Now it's the platform-standard two presses (IME window eats the first to hide itself, the next closes) - deterministic, proven returning to the map.

Trap C: DOWN couldn't leave the field (sweep fix, 2026-07-07). With the field armed and focused, the entry rows below it (Home / Work / saved places / recent searches / live suggestions) were unreachable - a single-line BasicTextField swallows DOWN as a cursor move, so focus never left the field. Fix: the field's existing onPreviewKeyEvent handler gained a DirectionDown case that calls focusManager.moveFocus(Down) and consumes the key, handing focus into the rows. (It lives inline in SearchBar rather than reusing the generic dpadFieldEscape because it shares the handler with the BACK/close logic above; the effect is identical.) Now: OK arms → type → DOWN walks into the suggestions → OK opens one.

Touch phones are unaffected: all of the above is gated on dpadMode.

Gesture alternatives

  • Nav maneuver banner (NavOverlays.kt): the banner is focusable; ←/→ walk the upcoming steps (the key mirror of the swipe), OK resumes live guidance while previewing (via the preview-mode clickable, which OK activates). The ←/→ key handler sits before that clickable in the modifier chain (key events bubble leaf→root, i.e. from the focused node up to its ancestors); a standalone focusable() exists only when the clickable doesn't, so the banner is always exactly one focus stop. Proven on-device: RIGHT → next step, RIGHT → further, LEFT → back, OK → resume.
  • Place sheet handle (PlaceSheet.kt): the tap-only detectTapGestures became a real clickable (focusable, OK toggles peek/expanded; identical under touch). The drag detector is untouched. Collapse-to-dismiss stays on BACK (already handled by MapScreen's peel BackHandler).
  • Directions panel (PlaceSheet.kt): the handle is clickable and gained the focus ring. Sweep fix (2026-07-07): with up to 4 route alternates the mode tabs + route list + depart options pushed the Start button off the bottom of the screen with no way to reach it - a real layout bug that affects touch too, not just D-pad. The AnimatedVisibility body is now a verticalScroll Column capped at ~58% of screen height (heightIn(max = screenHeightDp * 0.58)), so focusing (or tapping) Start scrolls it into view. (Since 2026-07-13 the From/To rows live in RouteTopCard at the top of the screen, and since 2026-09-13 Start is a footer under the route list that stays put while the list scrolls.)
  • Photo viewer (PlaceSheet.kt PhotoGallery): grabs focus on open (it's a Dialog, its own focus scope); ←/→ page through photos; BACK dismisses (Dialog default). Pinch-zoom has no key equivalent (accepted - see limitations).
  • Text fields (Settings + reviews search): Modifier.dpadFieldEscape() so UP/DOWN leave the field instead of being trapped in it (see Core helpers, and Trap C for the search bar's inline equivalent).
  • Results list / sheets: expand/collapse already had focusable buttons; they gained rings. Scroll happens implicitly as focus walks the rows (LazyColumn/verticalScroll bring-into-view). The results list became a BOTTOM sheet on 2026-07-08: OK on its handle opens it from the minimized bar and toggles peek and expanded, and BACK steps it down (expanded, peek, the minimized bar, then cleared), so no drag is ever required. There is no "Hide results" button any more.
  • Nav bottom bar (NavOverlays.kt, 2026-09-04): dragging the bar up opens the step list, and that gesture is touch-only on purpose. The key path is the chevron handle above the figures, a focusable clickable with a ring that opens the same sheet, plus a list button in the bar whenever "Prefer buttons over swipes" is on or the device is D-pad-first.
  • Pause and mute (NavHoldControls, used when Settings > Navigation "Pause button on the navigation bar" is off): a long press mutes, which a D-pad cannot do, so the first OK on the button slides mute out beside it for a few seconds as its own target, and a second OK on the same button pauses.

Focus visibility pass

dpadHighlight applied to: search bar card, suggestion/shortcut/saved rows, search result rows, the results sheet's handle, category chips, both re-center FABs, the zoom buttons, steps-sheet rows, both sheet handles, the maneuver banner. Since then dpad_test_suite/audit_static.sh fails on any clickable/toggleable/selectable without a ring, so the list is complete by construction rather than by this paragraph. Material buttons/switches/dialogs keep their built-in focus indication (adequate on those components; extend the pass if a spot proves hard to see).

The search bar's text region insets its content 10 dp inside the ring (2026-07-09): the ring is drawn on the Box whose edge is exactly where the first typed letter starts, so with no inset the 2 dp stroke cut straight through that letter on every keypad-phone search.

Everything checked, file by file

Surface Verdict
WelcomeScreen made scrollable - its fixed weight(1f)-spacer layout pushed the Get-started button off the bottom of a small (480×640) screen with no way to scroll to it, so a D-pad user couldn't SEE it (focusable-when-clipped, but invisible). Now verticalScroll + heightIn(min = screen); button reveals + activates on-device.
Onboarding prompts (VelaRoot) dialogs + buttons - natively focusable (proven: "Offline maps" dismissed via D-pad; that prompt was cut from onboarding on 2026-07-10, and every dialog is a VelaDialog now)
SearchBar armed-field design + BACK-out + DOWN-escape into the entry rows (Traps A/B/C above)
Search entry page (shortcut/saved/recent rows, menus) clickable rows + DropdownMenus - operable natively; rings added
Search results list rows/chips operable; the sheet's drag has key equivalents (handle tap, BACK); rings added. It was a top sheet when this pass ran and is a bottom sheet since 2026-07-08
Map MapDpadController + center target (above)
Place sheet handle fixed; action buttons/tabs/rows are Material or clickable - operable; Reviews tab search field got dpadFieldEscape (proven UP-escapes + dismisses the IME)
Live reviews WebView (ReviewsPanel, "Read all reviews") reachable (OK on the button) + exitable (BACK) proven; ↑/↓ now page-scroll the WebView (sweep fix); visual scroll not confirmable on the test network - see limitations
Directions panel handle ring; rows/chips/buttons (incl. stop reorder) are buttons - operable; body scroll-capped so Start is reachable with 4 alternates (sweep fix, helps touch too)
Route steps sheet rows clickable; rings added
Nav (banner, controls, faster-route card, arrival card) banner keys added; the rest are Material buttons
Choose-on-map map stays pannable to position the pin (the pickOnMap exception, sweep fix); OK confirms; hold-OK sets directly; Set/Cancel buttons focusable
Settings rows/switches/±steppers focusable; text fields got dpadFieldEscape so UP/DOWN escape them (sweep fix); no gesture-only widgets (no sliders)
Trip replay pill, notices, info cards buttons - operable

Proven on-device (MTK "M5" keypad phone, Android 13, no touch used)

Every flow below was driven with adb input keyevent D-pad keys only and verified by screenshot. Device facts that shaped the design were captured by on-device logging, not assumed: touchscreen=finger (lies), SOURCE_DPAD only on the Virtual device, deviceHasKey=false, keypad center = KEYCODE_DPAD_CENTER (scancode 232), BACK = 158.

  • Launch: crosshair + zoom buttons + engaged edge-ring appear immediately (dpad-first). Superseded on 2026-07-08: the bare map now opens with nothing focused and the first arrow lands on the search bar (see "The bare map is the ONE intentional exception").
  • Map: ← ↑ → ↓ pan (crosshair fixed, map slides under it); BACK disengages to the "OK: move the map" pill; arrows then traverse the chrome.
  • Traversal + rings: map → category chip → search bar → results rows → place-sheet rows, every stop showing a visible focus ring.
  • Search: OK arms the field, hardware typing enters text, live suggestions load, and two BACK presses return to the map (the reported trap - fixed).
  • Category search → results → place: OK on a chip returned 20 live results; OK on a row opened the place sheet (photos, actions, hours all focus-reachable).
  • Directions → nav: OK on Directions computed a live route; OK on Start began turn-by-turn.
  • Nav banner: RIGHT/RIGHT walked steps ("Turn left onto Avenue K" → "…Nostrand Ave" → "Arrive"), LEFT went back, OK resumed live guidance.
  • End: focus reached the End button; OK returned to the route preview.

Full-function sweep (2026-07-07). Beyond the flows above, every surface of the app was driven end-to-end by D-pad - map browse, search entry, results filters, the whole place sheet, the directions panel, navigation controls, and every Settings section - specifically to find anything still touch-only. That exhaustive pass surfaced the five refinements marked "sweep fix" here: (1) the search field's DOWN-escape (Trap C), (2) dpadFieldEscape on the Settings + reviews text fields, (3) Choose-on-map staying pannable to place the pin, (4) its cosmetic pill suppression, and (5) the directions-panel scroll cap so Start is reachable with 4 alternates. Nav was re-verified through Start → banner ←/→ step preview → voice-mute toggle → End. The one remaining touch-only surface - the full-screen "Read all reviews" WebView - was also swept: it's now D-pad-scrollable (↑/↓ → pageUp/pageDown), and its reach (OK opens) + exit (BACK closes) were proven on-device; only its loaded-page visual scroll is unconfirmable on the test network (see limitations). Nothing gesture-only remains outside the documented limitations below.

(GPS was mocked via a gps test provider to give routing a valid origin; the network's content filter otherwise leaves routing without a usable fix on this device.)

Known limitations / follow-ups (also on the ROADMAP)

  • Menus & dialogs - SOLVED (VelaMenu / VelaDialog), was the last framework wall. A Compose Material DropdownMenu (Popup) and AlertDialog (Dialog) open with their window focused but no content Compose-focused - Compose sets that focus only on the first key event, so they'd open un-highlighted. Nothing in-app pre-places focus onto their content: ~10 approaches verified failing on-device (2026-07-07) - requestFocus() on the item / a custom focusable Row / a TextButton / a directly-.clickable Text, a retry-until-onFocusEvent-confirms loop, an outer-scope delayed request, FocusManager.moveFocus(Down), and a synthetic DPAD_DOWN KeyEvent dispatched to the popup's ComposeView, then its rootView, then again with a real DPAD input source. The one seam that works is a hand-built raw Dialog with an explicit .focusable() element (Vela's photo gallery proves it), so both were rebuilt on it:
  • VelaDialog (ui/VelaDialog.kt) - drop-in two-button AlertDialog replacement (raw Dialog + Material-matched Surface) that auto-focuses the dismiss/safe button on open; buttons are a directly-.focusable() Text (the only node requestFocus lands on in a Dialog) with OK via .onKeyEvent and touch via pointerInput (not .clickable, which adds a 2nd focus target). Every dialog in the app uses it (there is no bare AlertDialog left, and audit_static.sh fails on a new one); looks identical under touch.
  • VelaMenu (ui/VelaMenu.kt) - drop-in DropdownMenu replacement. Under touch it renders the ordinary anchored DropdownMenu byte-identical; under D-pad a raw-Dialog chooser whose first item is focused on open. VelaMenu(expanded, onDismissRequest){ item("A"){…}; item("B"){…} }. Every menu in the app uses it; a bare DropdownMenu fails audit_static.sh.

    Proven on-device: onboarding dialogs auto-focus "Not now"; the place-sheet ⋮ auto-focuses "Set as Home"; the share menu auto-focuses "Google Maps link"; OK selects, DOWN/UP walk, arrows move between dialog buttons, BACK dismisses. No secondary window opens un-focused any more.

  • Off-screen initial-focus targets (small screens). Compose won't move focus to an element it can't bring into view, so a primary control that starts below the fold can't be auto-focused on open (measured: the Welcome screen's Get-started button on a 480×640 keypad screen). Modifier.dpadAutoFocus() (the retry-until-onFocusEvent-confirms variant) lands it when it's on-screen (normal phones); when it's off-screen the D-pad user presses DOWN to reveal + focus it. Force-scrolling it into view on open was tried and reverted - it hides the welcome intro on a once-seen screen, a worse tradeoff. rememberDpadAutoFocus() (the simpler requester) is fine for on-screen targets; use dpadAutoFocus() when a target may be off-screen.

  • Time and date pickers (fixed 2026-07-11). "Depart at / Arrive by" used to open the platform android.app.TimePickerDialog, outside Vela's Compose focus system. It is now the Material 3 time and date picker inside PickerDialog (PlaceSheet.kt), a raw Dialog with the VelaDialog button grammar whose confirm pill auto-focuses on open. Confirmations were already VelaDialogs (above).
  • Text entry relies on hardware keys. In dpadMode Vela focuses the field but does NOT raise the soft IME - the keypad's physical keys type straight into the focused field (verified). A device with neither a hardware keyboard nor a D-pad-navigable IME would have no way to type; that's out of scope (such a device isn't "D-pad operable" to begin with).
  • Live reviews panel (ReviewsPanel, the full-screen "Read all reviews" WebView) - now D-pad-scrollable (sweep fix, 2026-07-07). A raw WebView's default D-pad handling hops focus between the page's links instead of scrolling, which is useless for reading. In fullScreen mode the WebView now maps ↑/↓ to pageUp/pageDown (stable WebView APIs) via an OnKeyListener, and requestFocus()es on page-finish so it receives the keys - so it scrolls deterministically regardless of the page's focusables. The handler only fires on hardware D-pad keys, so it's completely inert under touch, and only in fullScreen (the inline scroll-sync path is untouched). Proven on-device: the panel is REACHABLE (OK on the "All N reviews" button opens it) and EXITABLE (hardware BACK closes it back to the sheet, via the Dialog's BackHandler, which fires even while the WebView holds focus - so it can never trap you). Not visually confirmable on the test device: its network content filter throttles the reviews carve, and the panel keeps the WebView at alpha=0 until the carve is "ready", so the page never becomes visible here - it always falls back to the fully D-pad-operable native reviews list. The page-scroll wiring is therefore verified-by- construction (documented APIs, focus requested, inert-safe) rather than pixel-verified here.
  • Photo pinch-zoom has no key equivalent (view-only; ←/→ paging works).
  • Map tilt (two-finger drag) has no key equivalent; browse/nav don't need it.
  • Tuning still worth a pass: pan step (0.22 of view), OK-hold threshold (500 ms), focus-ring visibility in both themes. Values felt right on the test device.
  • On a touch phone in dpadMode (external keyboard), the first tap on the search field can be eaten while the input mode flips back to Touch (tap again). Cosmetic; D-pad-first devices unaffected.

Merge-with-upstream policy (how this stays rebasable)

The whole feature follows these rules - keep following them when extending it:

  1. New behavior lives in new files: DpadFocus.kt, MapDpadController.kt, docs/dpad.md. Upstream can't conflict with files it doesn't have.
  2. Edits to shared files are additive and anchored, never restructuring: one contiguous, commented import block per file ("docs/dpad.md" marker); new modifiers inserted into existing chains; new state vars added next to related ones. The one near-refactor (naming the map click listener handleTap) changed only the lambda header and three return@ labels - the 50-line body is untouched, so upstream diffs to it apply cleanly.
  3. Touch paths are never forked - D-pad code calls the same lambdas/flags/state the gesture handlers use. Upstream fixes to tap resolution, reroute-on-pan, nav-zoom overrides etc. automatically apply to D-pad input.
  4. Everything is gated on dpadMode/noTouch where it could change touch behavior; with a touchscreen and no key input, the UI is byte-identical.

If a pull does conflict, the conflicts will be in the small anchored insertions - re-apply them around the upstream change and re-read this file's design section to confirm the invariants still hold.

Settings is hub-and-spoke (2026-07-23 redesign, ported from the vela-dpad fork)

Settings was a single extremely long page; it is now a short hub of category rows (ui/settings/SettingsHub.kt), each opening its own small sub-screen (ui/settings/sections/*.kt). Every page - the hub and each spoke - renders through SettingsScaffold (ui/settings/SettingsScaffold.kt), which carries, once, all of the device-found Settings focus plumbing: Back auto-focus, the DOWN-from-Back and UP-from-top-row bridges, and the horizontal swallow. Its content lambda hands the page a topRow modifier - attach it to the page's FIRST focusable control or the Back bridges dead-end. BACK peels spoke -> hub -> map; the hub explicitly re-focuses the row a spoke returned from (Compose's recovery is nondeterministic when the focused tree unmounts). When you add a Settings page, build it on SettingsScaffold - do not re-inline any of this. The shared row vocabulary is SettingsComponents.kt: SettingsGroup, ToggleRow, SelectableRow, GroupDivider, PageIntro, Hint, CollapsibleSectionTitle.

Two focus findings from the fork's verification (both live in SettingsScaffold):

  • The Back auto-focus needs a SETTLE WINDOW, not a land-once retry. Window-focus churn on open can steal freshly-landed focus off Back; dpadAutoFocus stops at its first landing, so the steal left the page unfocused. The scaffold re-requests while focus is neither on Back nor anywhere in the content; any key press ends the window at once, so it can never fight the user.
  • A second instance of the cold-open focus WALL. When Settings is the session's first focus-touching surface, requestFocus and moveFocus both no-op (same wall as the cold-open bare map). Accepted: the first key press establishes focus on Back, costing exactly one press.

Settings search on the hub is label -> OPEN-THE-SPOKE (SEARCH_INDEX in SettingsHub.kt): the old page scrolled to the matched row's measured Y, which died with the long page. When a section gains a row, add its label to the index.

The redesign's focus-ring color is the fork's fixed orange (0xFFFF6D00, DpadFocus.kt) - the teal primary ring read as "green on green" against the teal ON-switch. Two new reusable helpers came with it: Modifier.dpadContainVertical() (keeps a no-target UP/DOWN at a scroll container's edge from clearing focus irrecoverably - the vertical twin of dpadSwallowHorizontal; the scaffold applies it) and Modifier.dpadClickable() (a clickable that drops Material's gray focus layer while input is key-driven, so the ring is the one focus signal; pair it with dpadHighlight on the same row - dpad_test_suite/audit_static.sh exempts only DpadFocus.kt itself from the ring rule).