The UI system
The device has four buttons, a 240×320 reflective panel, and a microcontroller with no room to waste. The interface that runs on it is deliberately small: no_std, zero-allocation, and built with no retained widget tree — no DOM, no Box<dyn Widget>, no per-frame layout pass. A screen is just a value; navigation is just a return value; and the whole thing runs identically in the browser simulator and on the device.
This page is about how that works — the handful of abstractions that make a real navigable UI out of almost nothing.
A screen is a value, not a widget tree
The core idea: each screen is an enum variant wrapping a little struct of typed state, and the set of screens is one enum Screen dispatched by match. The enum, its handle/draw delegation matches, and each screen's classification are all generated from a single declarative screens! table — one row per screen — so there are no trait objects, no heap, and no second list to keep in sync: adding a screen is one table row plus its module.
Screen enum forwards both through a match — static dispatch, no dyn, no allocation. The enum and its delegation matches expand from one screens! table, and there's no widget tree to retain between frames.// The one screen table. Each row declares a variant, its state type, and its capabilities (Caps);
// a dumb local macro expands it into the Screen enum, the handle/draw/prepare delegation matches,
// and the per-screen Caps table that every cross-cutting UI policy reads.
screens! {
Home(HomeScreen) => Caps::nav().timed(), // screensaver clock ticks each minute → timed
Map(MapScreen) => Caps::map().timed(), // reads the map Reader; a ride view + browse-exempt
Statistics(StatisticsScreen) => Caps::riding().timed(),
RideControl(RideControl) => Caps::nav().ride_view().hold_fill(), // the Paused page; guarded Finish/Discard
RideStart(RideStartScreen) => Caps::nav(), // the browse map's start card (route-less ride)
Menu(MenuScreen) => Caps::nav().timed(), // the compass dial sweeps its needle → timed
RideMenu(RideMenuScreen) => Caps::nav().timed(), // the same dial chrome, with ride-scoped stations
UpAhead(UpAheadScreen) => Caps::nav(), // the merged waypoint + corridor-POI timeline
Detour(DetourScreen) => Caps::map().remap(RemapKind::Route), // live route index follows rescans
DetourPreview(DetourPreviewScreen) => Caps::map().remap(RemapKind::Route), // the planned detour + cost line
PoiMenu(PoiMenuScreen) => Caps::nav(), // POIs browser: the category list
PoiList(PoiListScreen) => Caps::nav().reader(ReaderNeed::PoiSnapshot), // one-shot nearest-16 query
PoiDetail(PoiDetailScreen) => Caps::nav().reader(ReaderNeed::PoiHours), // one-shot opening-hours read
RouteMenu(RouteMenuScreen) => Caps::nav().remap(RemapKind::Route), // holds a route index (rescan remap)
Rides(RidesScreen) => Caps::nav().remap(RemapKind::Ride),
RideDetail(RideDetailScreen) => Caps::nav().timed().hold_fill().remap(RemapKind::Ride),
RouteOverview(RouteOverviewScreen) => Caps::nav().timed().hold_fill().remap(RemapKind::Route),
RouteSwap(RouteSwapScreen) => Caps::nav().exempt().timed().hold_fill().remap(RemapKind::Route),
// Event-opened cards — raised by something happening, not a gesture: the BLE seam (route uploads
// + pairing, see "Screens the companion link pushes") or the storage/sensor path (the warning
// card). `modal()` = idle-return exempt: the timeout never yanks one away until it's dismissed.
RouteReceived(RouteReceivedScreen) => Caps::modal().timed().remap(RemapKind::Route),
Passkey(PasskeyScreen) => Caps::modal(), // the 6-digit pairing code, modal + non-dismissible
Warning(WarningScreen) => Caps::modal(), // advisory: missing sensor / slow map / write error
// The Settings tree. `settings()` is what holds the debounced settings save while one is on top;
// a guarded row adds `.hold_fill()` (the factory Reset, Forget phone, Fields delete, forget sensor).
Settings(SettingsScreen) => Caps::settings(), Ride(RideScreen) => Caps::settings(),
Reset(ResetScreen) => Caps::settings().hold_fill(), Bluetooth(BluetoothScreen) => Caps::settings().hold_fill(),
// Five groups: Ride, Display, Connections (→ Bluetooth / Sensors), Power, System (→ Units,
// Date&Time, Language, Firmware, Reset), plus Ride's Fields → AddField editor. All Caps::settings().
}
// Each variant is a module with typed state and two methods (plus an optional third for the two POI
// screens — `prepare`, which resolves a Reader-backed one-shot before drawing, see "the POIs browser").
impl MenuScreen {
fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition { /* logic */ }
fn draw(&self, cv: &mut impl Surface, rx: &mut Render) { /* pixels */ }
}
Every cross-cutting behavior — which screen counts as live data, which is idle-return exempt, which needs the map Reader, which has a timer or a guarded hold, which remaps catalog indices after a rescan — is a capability on the row, not a matches! scattered across the app. Adding a cross-cutting policy is an explicit new field on Caps, exhaustively matched, so a screen can't be silently forgotten; the invariant tests enumerate the whole table and check the combinations are consistent (a Reader-needing map screen, a modal that isn't a ride view, and so on).
Navigation is a return value
A screen never reaches out and changes the UI. It returns what it wants — a Transition — and a tiny apply function runs that against the screen stack (a heapless::Vec<Screen, 10>). The bottom of the stack is always Home, which is never popped, so back always has somewhere to go and the stack can never empty.
Transition; apply is the one place the stack mutates. Because the whole vocabulary of navigation is this six-variant enum, every flow in the UI — overlays, back, sibling swaps, "load a route and ride" — is expressible without any screen knowing what's above or below it.pub enum Transition {
None, // gesture handled in place
Push(Screen), // open an overlay / navigate forward
Pop, // back — return to the screen that opened this one
Replace(Screen), // swap the top without growing the stack (Map ↔ Elevation)
Root(Screen), // truncate to the Home root, then push — "load a route and ride"
Home, // clear every overlay back to Home (Finish / Discard)
}
pub fn apply(stack: &mut Stack, t: Transition) {
match t {
Transition::Push(s) => { let _ = stack.push(s); }
Transition::Pop => { if stack.len() > 1 { stack.pop(); } } // root is never popped
Transition::Replace(s) => { if let Some(top) = stack.last_mut() { *top = s; } }
Transition::Root(s) => { stack.truncate(1); let _ = stack.push(s); }
Transition::Home => stack.truncate(1),
Transition::None => {}
}
}
Here's a whole screen's logic — the main Menu, a compass dial whose amber needle sweeps to the selected station — to show how little a screen has to say. Even with an animation, the logic is three lines per gesture: the sweep itself runs through the same timer-poll contract the Home clock uses (see Render on demand below), so it costs nothing once the needle has landed:
fn handle(&mut self, g: Gesture, cx: &mut Ctx) -> Transition {
match g {
Gesture::Step(n) => self.dial.step(n), // shared selection wrap + eased needle target
Gesture::Press => match self.dial.selected() {
0 => Transition::Push(Screen::RouteMenu(RouteMenuScreen::new())), // Routes
1 => Transition::Push(Screen::Rides(RidesScreen::new())), // Rides
2 => Transition::Push(Screen::PoiMenu(PoiMenuScreen::new())), // POIs
3 => open_map(cx), // Map — browse map / ride base
_ => Transition::Push(Screen::Settings(SettingsScreen::new())), // Settings
},
Gesture::Back => Transition::Pop, // return to whoever opened the Menu
_ => Transition::None,
}
}
Four of the five stations open a menu; the Map station opens the riding map directly. Which map depends on whether a ride is already being tracked. Mid-ride it lands you back on the live riding map — the ride base — by rooting the stack to a clean [Home, Map], the same normalization the idle return does, so it never stacks a second Map or leaves stale menus buried underneath. With no ride running it opens a route-less browse map: the identical Map screen — GPS-follow camera, zoom on Up/Down, hold to Pan — reached without a route or a session, for reading the map while riding without recording. On entry the browse map briefly shows a one-shot bottom-centre Press to start a ride hint (auto-hiding after a few seconds — the lowest-priority tenant of the bottom chip slot). On the browse map back pops back to the Menu (there's no Statistics sibling without a ride) and press opens the start card — a small pre-ride launchpad: the selected bike's pixel sprite and profile name (the same sprite the Bike type settings screen draws), a two-row GPS / Battery checklist (the live fix state and the battery percent), then Start ride / Back rows. Start ride begins a route-less tracking session (the same session-begin the Route overview's START runs, minus the route) and roots to [Home, Map]. A route-less ride records and saves exactly like a guided one; only the route-relative stat tiles (to go, to climb, grade) read --, and the Statistics band shows a "No route loaded" note over an otherwise-live grid. The browse map is a deliberate view, so — unlike a menu left open — the idle-return timeout leaves it be.
Once a ride is running, back-hold opens a second compass with the same five-station bezel, amber needle sweep, and label strip, but ride-scoped stations: Up ahead, Detour, POIs, Routes, and Main menu. Up ahead starts at north; one Up step reaches Main menu. Up ahead opens the route's timeline — every stop still coming, the rider's own waypoints and the map's POIs on one axis, opened on the next one; Detour opens the rejoin chooser below; POIs and Routes open their existing browsers. A route-less recording keeps the same five positions: Up ahead and Detour stay dimmed and Up ahead opens No route loaded / Load a route first, so the ring never changes under the rider's hand. Detour additionally dims on a map without a routing graph and while the rider is off the route — the station is actionable exactly when a detour could actually be planned.
Detouring around a blocked stretch
Detour routes the rider around a closed or unpleasant stretch of the active route: pick how far ahead to rejoin, let the device plan a real path to that point on the map's routing graph, preview its shape and distance cost, and commit — after which the detour simply is the route and normal guidance continues through it.
press commits from either view, back cancels.Press freezes the request — the rider's along-route position, the chosen rejoin distance, and the current fix — and hands it to the host, which plans on the map's §8 navigation graph behind the shared spinning-needle wait. The skipped stretch is not resolved to graph edges by id — a route polyline is planner GPX, not guaranteed graph-aligned — but blacklisted geometrically: the span is downsampled into a corridor, and the search skips any edge both of whose endpoints hug it within ~40 m. Both-endpoints proximity doubles as the parallelism test: the blocked road and a street hugging it are skipped, while a bridge crossing the corridor (endpoints off to either side) and the junction edges that leave the road stay usable. Discs around the snapped start and rejoin nodes stay exempt so the search can always take off and land on the route itself.
A successful plan lands on the preview: the detour's polyline in blue over the warning-coloured skipped stretch, and one figure — the signed distance cost (+434 m: detour length minus the stretch it replaces). It is deliberately distance-only; the routing graph carries no elevation, and a made-up climb figure would be worse than none. When the planned path reaches the route earlier than the chosen point, the rejoin advances to that first sustained contact rather than riding up to the ring and doubling back — the chosen distance is a minimum, and the preview already describes the shortened detour.
Committing splices: the device streams a derived route — the ridden part up to the rider, the detour, then the original route from the rejoin — into the reserved computed-route slot and re-adopts it as the active route; the recording session, breadcrumb, and ride totals are untouched. Waypoints on the avoided stretch are dropped, climbs and the elevation profile rebuild from the spliced geometry (the detour's elevation is interpolated between its endpoints), and the matcher re-anchors at the splice seam with a forward-only floor, so GPS jitter can never snap navigation back into a stretch that no longer exists. The spliced route is an ordinary route file and survives a power cycle like any other.
A few guards keep the flow honest: the chooser follows the live progress anchor while open, so a Step followed immediately by Press can't plan against a stale candidate; a catalog reorder rekeys the chooser, plan request, and preview by the route's durable id, and if the route vanished — or the rider passes the rejoin while deciding — the flow cancels back rather than committing a stale plan. Commit and cancel both pop back to the exact riding view that opened the compass.
Four buttons, five gestures
The physical input model is tiny: Up and Down on the left flank, Select and Back on the right. No touchscreen, no wheel — four rubber buttons you can find and work through a winter glove without looking. A single shared recognizer — the same code on the simulator and the MCU — turns the raw stream of selection steps and button edges, plus a millisecond clock, into exactly five gestures. A screen's handle only ever sees these five.
Up and Down are step controls: they move the selection the instant they go down, and auto-repeat while held, so a long list scrolls under one thumb. Only Select and Back carry both a short-press and a long-press meaning, so only those two are timed against the clock.
pub enum Gesture { Step(i32), Press, Hold, Back, BackHold }
That the recognizer is fed by an injected InputSource is the key boundary: on the device ButtonInput turns GPIO levels into the port's raw events; in the simulator DeviceInput does the same for the on-housing pads, the keyboard and the mouse wheel. Both implement obc-ports::InputSource directly, so neither the recognizer nor any screen knows which host supplied it. (The location, altimeter and track sinks cross the same kind of HAL seam.)
Hold to confirm
Some actions are irreversible — finishing or discarding a ride. Rather than a modal "are you sure?", the UI uses a guarded-action pattern that's reusable across screens: a guarded option fires only on a completed Hold, and its row fills with a warning bar tracking the live hold-progress. Let go early and nothing happens — the recognizer makes that clean at the gesture level: a press is a Press only if released within a brief tap window (~200 ms); released after the window but before the hold completes, it's a cancelled long-press that yields nothing, never a surprise tap.
A hold is also cancelled if the screen stack changes while it charges — Select and Back recognise independently, so a Back tap can dismiss a popup mid-hold, and a long-press that started over one screen must never complete onto whatever replaced it (a hold aimed at a prompt's "Finish & new" landing on another screen's guarded row could fire an action the rider never aimed at). The transition cancels the in-flight hold; the bar retracts, the release stays silent, and the next press starts clean.
Hold exactly when the press completes, so a screen's Hold arm is the confirmation — there's no separate "did they really hold long enough?" check. The fill is driven by the live hold-progress (0–1) the input plane exposes, so the bar and the commit are always in sync.Gesture::Hold => match self.selected { // reaching this arm IS the confirmation
FINISH => self.end_ride(cx, TrackAction::Save),
DISCARD => self.end_ride(cx, TrackAction::Discard),
_ => Transition::None, // Resume isn't guarded — a hold does nothing
},
The factory Reset screen is the one place a hold guards a destructive action. The hold threshold is a fixed ~500 ms — too short to feel safe alone — so reset is two deliberate steps: a press arms the screen, then a hold erases. A stray hold on an un-armed screen does nothing; only an armed, completed hold clears the settings (with a bar filling on the live progress).
Deleting things — the hold-to-delete footer
The same guarded hold does duty as a delete control. Rather than a modal "are you sure?", a screen that can delete an item fills a "hold to delete" bar with the live hold-progress — the completed hold is the confirmation, there is no second popup. One idiom drives deletion in two shapes: a reserved footer band below the Fields grid, and — because routes and rides are each deleted from their detail page, not the list — a guarded delete row: Delete route at the bottom of the Route overview, Delete ride at the bottom of the Ride detail. On the overview the hold is also aimed: Up/Down moves the selection between START RIDE and Delete route, and the hold charges the delete only while the Delete row is selected — a hold anywhere else does nothing. One hold, one muscle memory.
Two behaviours make it safe to press without thinking:
- Hidden when the item is in use. The delete simply isn't offered when it would break live state: the Route overview draws no Delete row for the route you're actively navigating (deleting the file under an open geometry handle mid-ride would break navigation — but a route merely previewed from idle is still deletable), and the Ride detail draws none while a ride is being recorded (its file isn't even written until Finish, and the filesystem refuses to delete an open handle). A state that can't act doesn't show — and the guard behind it still makes a stray hold a no-op.
- Sync state is visible before you delete. A tracked ride with no copy anywhere else is unrecoverable if deleted, so the Rides list marks every ride a peer has confirmed it holds with a small check mark — the device's success idiom; a ride nobody has taken shows nothing there — and the Ride detail's title bar says it in words either way (synced / not synced). Still deletable, just informed. (Routes get no such cue: the phone can always re-upload one.) The cue tracks durability, not the iPhone: the phone and the desktop app both set it, while the hosted site's one-shot GPX export deliberately never does, so a ride exported in a browser still reads not synced here — and says why.
confirm_row fill, driven by the same live hold_progress — so there's no new gesture and no new confirmation dialog. The Route overview's Delete-route and the Ride detail's Delete-ride rows are the same machinery in confirm-row clothes, with the same guards (the row is hidden outright for the actively-navigated route, and while a ride records). A device-side delete then flows through the object store, so the phone reconciles it from the live change signal, with a connected catalog audit as the fallback for a lost notification (see the companion link).Trips — folders in the Route menu
A trip groups routes into a multi-day plan — the Alps in five stages — and on the device it is exactly that: a folder in the Route menu. A trip is a tiny metadata object (a name plus the object ids of its member routes, in ride order); the routes themselves stay ordinary, unchanged route files that a trip merely references. So the folder is a grouping, never a copy: a route lives on the card once, filed into at most one trip or loose at the top level.
The Route menu's top level lists the trip folders first, then the unfiled routes — each group in the catalog's order. A folder row is visually distinct from a route row — the trip's name wearing a rounded count badge (how many routes it resolves) on the name line, the summed distance and climb beneath in the same two columns a route row uses — but it keeps the same list chrome as everything else: uniform rows, names over metadata. Pressing a folder opens its stage list: the member routes as completely standard route rows, under the trip's own name as the title. Picking one there loads it identically to picking a loose route — same Route overview, same START, same ride loop; nothing downstream is trip-aware, because the device draws one route at a time and never needs to know it came from a folder. The hierarchy is exactly one level deep: a stage list never nests, and back pops it to the top level.
Under the hood this is one screen, not two — the screens! Route-menu screen carries a scope (the whole catalog, or one trip's members), so the stage list is a thin variant of the top-level list rather than a fork. The folders are resolved from the trip catalog each frame: a member route deleted on its own simply drops out of its folder (a dangling reference the trip tolerates), and a folder whose every reference has dangled still lists — wearing a 0 badge and showing the empty-list state when opened — so it can always be cleaned up.
That cleanup is a long-press on the folder. Unlike the in-place hold-to-delete of a single route or ride, deleting a trip removes several files at once — the trip and every route inside it (on-device delete is post-trip cleanup, so it cascades) — so it earns a deliberate confirm dialog: a card naming the trip, a warning-red hold-guarded Delete all row (the same guarded-hold idiom, entry resting on Cancel so nothing is armed on the way in), and Cancel. Confirming hands the host the trip's durable id; the host deletes the trip file and each member route file, then rescans — the folder is gone, its routes with it, and the menu regroups. The phone reconciles the removals from the live store-change signal, with its connected catalog audit as the fallback for a lost notification.
The POIs browser
POIs is one of the compass Menu's stations, and it answers a bikepacker's question directly: where's the nearest water / campsite / bakery? — near me, right now, with no regard for where you're headed. (The other half of that pair, what's coming up on my route, is the Up ahead timeline; the two questions and why they stay separate screens are laid out there.) The flow is two screens — a category list, then that category's nearest-16 list — both built from the same screens! table rows and the shared list widget as every other menu, so there's almost nothing new in the plumbing. What's new is where the data comes from and how one element stays live.
back climbs one step; pressing a row opens that POI's detail view.A static list with one live element
The list is a static snapshot, frozen the moment you enter. Membership, order and distances don't move — rows never reshuffle under the cursor as you scroll, and the SD card isn't re-scanned every frame. Re-enter the category to refresh it against your current position. Under the hood the nearest-16 query needs the streaming map Reader, which the host only builds for the frame that needs it — so the snapshot is taken in a small pre-draw prepare pass (the one place both the Reader and the fix are in hand), the first time both are present, into a single buffer the app owns (holding it per-screen would inflate every slot of the screen stack). Drawing then just reads that frozen buffer; the query is a side effect, and side effects don't belong in draw (see Logic and drawing get different views). Opening a list invalidates the buffer, so the next prepare re-queries.
The one thing that is live is the bearing arrow — recomputed every frame from the POI's stored coordinates and the rider's current heading, pure trig with zero SD access. It points from you toward the POI relative to your heading, so "straight up" means "dead ahead." The drawn glyph snaps to eight compass directions (45° steps) and is a full arrow — shaft plus barbs, double-stroked to a 2 px line: at ~11 px a degree-true arrow just smudges, while the eight snapped shapes read without focusing. That heading has two sources, and which one is used depends on whether you're moving:
The POI detail view
Pressing a list row opens the detail view for that POI — one more Nav screen, carrying the selected POI out of the frozen snapshot. It shows the same thing the row does, but unabridged: the full stored name with the category's pixel icon beside it (the row ellipsizes the name to fit its width; the detail wraps it to a second line instead of truncating), the subtype label as a muted subtitle, and — promoted to its own row directly under it — the distance and the same live 8-way bearing arrow at body size: the two numbers that decide do I go, with the identical heading seam as the row (arrow hidden when neither course nor compass is known). What the row can't fit is the reason the detail exists: today's opening hours and whether the place is open right now — a green OPEN pill, or a warning-red CLOSED one. At the bottom, a full-width ▶ Route here bar — the Route overview's START RIDE bar, reused — makes the screen's press action visible: it opens the create-route confirm.
prepare pass the list snapshot uses, then picks today's intervals — Today with the day's ranges stacked, Closed today, or Hours not listed. The OPEN / CLOSED pill is the only live piece, recomputed each frame from the local wall-clock (GPS clock + UTC offset): Zeller's congruence picks today's row, and the minute-of-day decides open vs. closed, including overnight intervals that opened last evening.Settings: a second level of focus
Most screens have one focus: the row cursor. Settings is five themed groups rather than one long list — Ride, Display, Connections, Power, and System. Two of them are plain menus whose rows just open their own pages (Connections → Phone / Sensors; System → Units / Date & Time / Language / Firmware / Reset); the rest hold their controls inline, and Ride does both — it opens Bike type and Data fields as their own pages while cycling Climb, Waypoints, Up ahead, page-cycle and auto-delete in place. The rule is simple: a real interaction (a picker, an editor, a guarded action) keeps its own page; a lone toggle or value that belongs with a couple of siblings is just a row.
Those control screens add a second level of focus. A value isn't a separate sub-screen; it's edited in place. Rotating still moves the amber row cursor, but once you press a value row, focus drops into a field: a ▲▼ box marks the live one, rotating now changes its value, pressing steps to the next field, and back steps out. The same two-level model drives every editor — a UTC offset, a fix interval, the stats page-cycle period — and the same toggle row flips a switch like the power saver. No new gestures; the existing five just mean different things at each level.
The Display screen governs the Map's chrome and the auto-return: two toggles for the Map overlays — the small floating top-centre clock digits and the bottom-left scale bar (the largest round 1/2/5 distance that fits the current zoom) — plus the idle-return timeout (15 s … 5 min / Never, default 30 s) that decides how long an untouched UI waits before returning to Home (or, mid-ride, the Map). The Map's other chrome isn't a setting: a bottom-centre one-slot chip where "No GPS Fix" outranks "off route", which outranks the calmer waypoint chip and the browse map's one-shot Press to start a ride hint; the scale bar steps up above whichever chip is showing, and a warning-red low-battery glyph appears top-left below 10 %.
The Ride screen gathers everything you tune for a ride. Two rows open their own pages — Bike type (the routing-profile picker) and Data fields — and the rest edit in place. Data fields opens the grid editor, which simply is the grid: the same 3×2 tile pages the riding view shows, painted by the same tile renderer, with fixed sample values so a layout is judged against realistic content. The rider picks fields from an in-code catalogue (speed, distance, climb, grade, clock, heart rate, power, cadence, …); a field takes one of three shapes — a single column, a full-width tile, or the page-sized waypoint list panel. Reordering reuses the grab idiom (press lifts, rotate moves through the reflowing grid, press drops), a ghost + tile opens the field picker, and a panel is removed by the same hold-to-delete bar as everywhere else. The screen also carries the press-to-cycle mode rows: Climb (Off / Manual / Auto — whether the climb panel appears on its own), Waypoints (Off / Approach / Always — the waypoint chip), Up ahead (Both / Waypoints / Map POIs — who feeds the timeline), and Auto-delete (how long a synced ride is kept — see What the rider sees). Everything lives in the same persisted Settings value, so it survives a reboot.
back) lifts it back out. Edits apply live as you step — there's no save button and no staging buffer: back just exits, and the change was already made.Settings survive a reboot — independent of the SD card
A setting is worthless if it's forgotten on power-off, so settings persist. The values live in a small Settings value (Copy, no floats) that the screens edit; the medium is a host concern behind one more trait, exactly like the sensor seams. The app seeds itself from load() at boot and asks the host to save() only when something actually changed — detected by the same one-== before/after compare the camera uses to decide it's dirty. The write is debounced to leaving the settings subtree: a stepper sweep edits live in RAM but never drives a write per step, and nothing is written while any settings screen is still on top — the store sees exactly one write when you back out.
Persistence is acknowledged, not fire-and-forget. save() reports whether the write reached durable storage; the app keeps the change marked pending until the host confirms it, so a failed RRAM/file write is retried (with a small backoff) instead of being silently lost, and a fresh edit safely supersedes an older pending one. A persistent failure also raises the shared advisory card ("Settings not saved") so it's visible, not just logged — the edit stays live in RAM the whole time.
pub trait SettingsStore {
type Value;
fn load(&mut self) -> Option<Self::Value>; // None (blank/corrupt) → app default
fn save(&mut self, value: &Self::Value)
-> Result<(), SettingsSaveError>; // Ok = durable; Err = retried
}
// Both shipped adapters bind `type Value = Settings`.
The nominal trait lives in dependency-free obc-ports; its associated value keeps that foundation from learning the app's Settings model. The simulator's FileSettingsStore and the board's RramSettingsStore implement that port directly. The simulator writes the blob to a file; the device writes it to a reserved slice of the nRF54L's on-chip RRAM — its program memory is RRAM, which is byte-writable with no flash-style erase cycle, so a tiny key-value store is cheap and needs no SD card present. Both sides share one versioned, CRC-checked byte codec, so a blank or corrupted read cleanly falls back to defaults rather than loading garbage — and the factory Reset is just writing the default blob back.
Self-cleaning storage: routes and rides expire
Uploading a route takes ten seconds; deleting one takes a discipline nobody has. After a season the Route menu is thirty stale routes deep, and rides you long since pulled to the phone still sit on the card. So stored objects clean themselves up — but only ever when it is provably safe to.
The rule is anchored to use, not upload. A route is deleted once it has gone unused for its retention window — a per-route "keep this for…" the app picks at upload time (default two weeks; from Never up to two months). "Used" means becoming the active navigation route, so a weekly commute loop renews itself forever and a route can never expire mid-tour underneath you: when the housekeeping pass finds the route you're navigating about to expire, it re-stamps it instead of deleting it. Rides are simpler and device-side — a ride is deleted a set time after it was verifiably synced to the phone (the ackRides reconcile is the proof it's safely off the device), and an unsynced ride is never touched, at any age.
The device has no clock — so deletion waits for a trusted one
None of that can run on a guess about the date. The device has no RTC: at boot the wall clock resumes from a persisted set-point that is stale by however long the device was powered off — fine for showing HH:MM, useless for deciding whether a route has sat idle for two weeks. So the whole feature rests on a single gate — nothing is deleted, and no expiry timestamp is written, unless the clock was freshly established this boot by one of exactly two real sources: a GPS fix (whose payload carries full UTC) or the phone's setClock on connect. Until one of them stamps, the boot set-point is display-only and the housekeeping pass does nothing at all.
That gate is also why the Date & Time screen (under Settings ▸ System) no longer lets you hand-set the clock — a fat-fingered year must never be able to trigger a mass delete. It's now three rows: a read-only GPS fix (the UTC anchor), a read-only Local time, and the one UTC offset stepper, which shifts only the displayed hour. Expiry arithmetic is pure UTC, so a wrong offset is purely cosmetic — after a flight you nudge the stepper once, or the next phone connect refreshes it silently.
What the rider sees
Almost all of this is invisible, which is the point. Two surfaces show it:
- The Auto-delete row on the Ride screen — Synced rides, choosing how long a synced ride survives before the device removes it (Never / 1 day / 1 week / 1 month, default 1 week). A press cycles the four values in place — the same press-to-cycle idiom as the Climb and Waypoints rows beside it — and
backis the save. Route retention has no device editor — it's per-route and set on the phone; the device only ever displays it. - The Route overview's expiry line — a small caption above the route's stats: a muted
Auto-deletelabel beside the ink value (in 3 d), two-tone and space-separated (no separator glyph — the device font has no middot). It's deliberately not an always-on countdown: it appears only once the route is within five days of deletion (a deadline already past, before the hourly pass collects it, readssoon), so it reads as a heads-up that this route is about to go, not standing chrome. A route that never expires, or whose usage clock hasn't started, shows no line at all.
Where that per-route state actually lives — an SD sidecar beside the catalog, never inside the byte-pinned route file, reached over BLE as a command rather than a re-upload — is a companion-link design note.
The UI speaks four languages
Every user-facing word — English, German, French, Spanish — is a lookup, not a literal. The language is a runtime setting, not a build flag: it lives in the same persisted Settings value as Units, RRAM-backed and switchable on-glass from the Language screen under Settings ▸ System (a one-row value picker showing each language's own name — English / Deutsch / Français / Español, so the row reads to someone who can't yet read the current UI). Language and Units are orthogonal — English + Metric is a perfectly good combo.
Because the render path is stateless (a screen is a value, not a widget tree), there is no ambient "current language" to set. Translation is a pure function of the message and the language, and the language rides along on the context every draw already receives:
// A message key + the language → the &'static str, a plain double index into a flash table.
pub const fn t(msg: Msg, lang: Language) -> &'static str { TABLE[msg as usize][lang as usize] }
// Draw-time convenience: `rx.t(Msg::…)` reads settings.language off the context the screen holds.
draw_text(target, rx.t(Msg::MenuRoutes), at, Font::Body, TextAlign::Center, ink);
The Msg enum and the TABLE it indexes are generated at compile time. Four per-language catalogs — obc-app/i18n/{en,de,fr,es}.toml — hold the copy as [section] + key = "value" TOML; a small build.rs parses all four and emits one Msg variant per key plus const TABLE: [[&str; 4]; N], the columns ordered to match the Language discriminants (En, De, Fr, Es). Because the table is const, the whole catalogue lands in flash .rodata — nothing touches the device's tight 256 KB RAM. English is the canonical key set: the build fails with a named list of offenders if any of de/fr/es is missing a key, carries an extra one, or changes the load-bearing leading/trailing spaces English glues into a concatenated readout (AVG + a value, grade + a percent) — so a half-translated or mis-spaced string can't ship silently. (A separate obc-app test walks the finished table and asserts every character is in the device font's repertoire — Latin-1 + Latin Extended-A — so a stray curly quote or em-dash fails CI rather than rendering as a ? on-glass.)
The words are translated; the formats are not. The 24-hour clock, ISO / Mon DD dates, and the metric/imperial unit suffixes (KPH, km, m) stay identical across languages — only the twelve month abbreviations are localized. Symbol-like labels (KPH) are language-independent by design; only word-bearing enum labels (a Units name, a ClimbMode name) route through the catalogue.
Adding a string is a key in all four i18n/*.toml files plus a Msg::SectionKey use at the draw site — forget one file and the build stops with a MISSING key error naming it. Adding a language is a new catalog plus a handful of matched edits (enum variant, picker slot, codec byte), and a const assertion ties TABLE's width to Language::COUNT, so a forgotten column is a compile error rather than a first-draw out-of-bounds panic.
Logic and drawing get different views of the world
handle and draw are handed deliberately different contexts. handle gets Ctx — the mutable slice of app state a screen is allowed to change (the camera, the ride mode, the clock). draw gets Render — a read-only view plus the resources it needs to paint (the map reader, the renderer, the active route, its elevation profile, the active climb, the breadcrumb, the in-flight hold-progress). A screen literally cannot mutate state while drawing, because it isn't given the means to — Render carries no mutable state at all, so a frame's only outputs are pixels and the map's render statistics.
The one thing that used to break that rule was the POI browser: its snapshot and hours reads needed the map Reader, which lives in the draw path, so they wrote back into the draw context mid-paint. That acquisition now happens in a third, even narrower context — Prepare, handed to a screen's optional prepare method in a pass that runs before the draw loop, carrying just the Reader, the App-owned snapshot buffers, and the fix. prepare does the side-effectful read; draw consumes the frozen result read-only. So the split is now clean in both directions: prepare may touch the Reader-backed one-shots, handle may change app state, and draw may do neither.
There are two such buffers now, and the second one is shaped a little differently. The nearest-POI snapshot belongs to the screen that opened it, so a per-screen prepare fills it. The route-corridor snapshot belongs to no single screen — the Up-ahead timeline reads it, and so does the stat fields' next-of-category cache — so instead of a per-screen row it is driven by an App-level request: whoever wants a snapshot declares its key, one reconcile step arms the buffer from the topmost declared request (a screen outranks the cache), and the fill runs at the same pre-draw boundary. The seam that decides whether the host builds a Reader at all folds that pending request in beside the per-screen needs, so a device whose rider never opens the list and places no Next: tile is in the disarmed state permanently and pays nothing for any of it.
Reader, the reusable MapRenderer) are gathered once by the host and lent to whichever screen is on top.Render on demand — and the idle path is free
A reflective memory-LCD holds its image with no redraw, and a map frame is the expensive thing the device does (tens of milliseconds). So the UI is render-on-demand: each frame the host ticks the app, feeds it input, then asks take_dirty() what actually changed — and renders only that. A screen sitting still draws zero times.
The dirty signal has two independent planes, mirroring the two-plane architecture:
- map — the screen stack. Set when a gesture was applied, when a fresh GPS fix moved the camera on a view that shows live data, when the route or ride session changed, when a screen's own timer poll (
tick_timers) reported a change — the Statistics cursor springing back to the live position, or a wall-clock minute crossing (the Home screensaver's big clock, or the Map's small floating clock, each region-clipped to just the digits) — or, on a riding view, when the GPS fix goes stale or returns (raising or clearing the "No GPS Fix" chip, a timer edge surfaced from the per-frametick). A parked Home screen fires none of the camera edges — so between those once-a-minute clock ticks the map stays clean. - overlay — the transient hold "bulge" and confirm ring. Derived from the live hold state by the input plane, which on the device runs preemptively so press-feedback latency never waits on a long map render.
loop {
app.tick(RideClock(now), sensors, route); // fold in sensors, move the camera
app.handle_input(InputClock(now), &mut io); // recognise gestures, run transitions
let dirty = app.take_dirty();
if dirty.map { app.render_map(&mut display, &reader, route, w, h, color_fn); }
if dirty.overlay { app.render_overlay(&mut display, w, h, color_fn); }
}
The conservative rule — any applied gesture dirties the map — is what keeps the idle path exact: when nothing is touched, no gesture is recognised, apply_gesture never runs, and the panel isn't redrawn at all.
One refinement rides on the timer poll: a screen that knows its timed change is spatially small can attach a containing region to it. Two screens use it: the route-planning screen's spinning compass needle repaints many times a second for several seconds while the router runs, yet only the needle's disc ever changes; and the Map's floating clock, which ticks its HH:MM over once a minute but only inside the small top-centre digit box — so a parked riding view never re-renders the whole map plane just to advance a digit (and when the clock overlay is hidden, no minute wake is armed at all). The dirty signal carries that region out to the host, which clips the repaint to it: the full draw sequence replays, but the framebuffer discards every pixel write outside the region (and rejects whole out-of-region primitives before rasterizing them), so the frame costs the digits/disc, not the chrome — and the changed-rows-only push shrinks with it. The region is a promise, not a request: any other dirt in the same frame — a gesture, a fix, a popup — folds it away and the host full-repaints, so under-drawing can't happen; screens that never report a region behave exactly as before.
Overlays can composite over a live map
Most navigation replaces the view, but the stack also supports screens that draw over the one below: render_map finds the topmost opaque screen and draws from there upward, so an Overlay-kind screen composites on top of whatever is beneath it — and because the host keeps folding in GPS fixes behind it, the map underneath doesn't visually freeze. No current screen uses the kind (the pause menu did, until it grew into the full Paused page); the mechanism is kept for what it's really shaped for — transient notifications, like a route arriving from the companion app mid-ride.
Overlay kind stays for transient panels that should not steal the whole display — a notification composites over the map, and the host keeps folding in GPS fixes behind it, so the ride doesn't visually freeze.Screens the companion link pushes
Almost every screen is opened by a gesture — a press or a menu pick. A few are opened by the BLE link instead: the host distils the radio into a tiny app-side BleStatus snapshot each pass, and when it changes the app pushes (or pops) a card the rider never navigated to. The screen system needs nothing new for this — a host-pushed card is a plain Nav screen and a Push/Pop on the same stack — but the policy around it is what makes the link feel like part of the device rather than a separate app.
The passkey card
Pairing puts a 6-digit code on the glass for the rider to type into the phone. That code is a full-screen card, but unlike every other screen it's host-pushed: set_ble_status opens it the instant the seam's passkey goes Some and pops it the instant it clears (pairing done, failed, or link dropped). It is deliberately non-dismissible — Back and press both do nothing — so a stray button can't lose the code mid-pairing; the SMP handshake time-boxes the window, so the app runs no timeout of its own. The card is opaque, so when it pops the map plane repaints whatever was underneath exactly once.
Route-upload prompts
A route arriving over BLE or USB surfaces as one of three advisory cards — advisory because the route is committed to the store (and the Route menu) before the prompt, so dismissing loses nothing. All of them auto-close after 30 s (timeout = dismiss), and the display wakes to show them (an upload usually means the phone is right there). Which one appears depends on the rider's state: an idle "Route received — View route / Dismiss" (with the route's stats and a mini elevation sparkline; View route opens the same Route overview a Route-menu pick does), a mid-ride guarded swap prompt (the same shape a mid-ride route pick uses), or — when the upload replaced the route being navigated — an info-only card, because the device has already adopted the new version (the old file is gone). A trip upload adds a fourth member: the trip object commits after its member routes, so its "Trip received — View trip / Dismiss" card (the trip's name, summed stats, and stage count; View trip opens the trip's folder in the Route menu) replaces the last per-route prompt — a whole-trip transfer ends on one card, not a parade. The companion-link page tells the full story; the UI-side rules are the interesting part: a prompt never lands while a hold is charging (it defers a tick, so a half-done Finish & new hold can't complete onto it — the same stack-change hold-cancel at work), consecutive uploads replace the prompt by object id rather than stacking, and the passkey card outranks it (a pending prompt is dropped, not queued).
The connected indicator
A single static Bluetooth rune says "a phone is linked right now." It appears in exactly two places: the main Menu's title bar (the right slot of the framed header, inset left of the battery readout) and the Home screen, in a fixed top-right corner status slot — the two screens a rider glances at between rides. It is deliberately absent from the riding views. That's not an oversight but a render-on-demand decision: the Map and Statistics screens repaint only when something they show moves, and a phone connecting or dropping is not something the ride cares about — putting a link glyph there would dirty an expensive map frame on every BLE edge. So the indicator lives only where the panel is already cheap to redraw.
The Bluetooth settings screen
Settings ▸ Connections ▸ Phone is where the link's few knobs live. It's an ordinary settings screen (two-level focus like the rest), carrying: an on/off toggle for the radio (persisted in Settings like every other setting, and pushed to the radio plane by the host), a read-only status line (Off / Advertising / Connected, straight from the seam), a "Paired: yes/no" row (no phone name — deliberately not worth a protocol addition), and a hold-guarded Forget phone row. Forget uses the delete-footer's guarded hold — a completed hold fills it warning-red and clears the bond — and it matters more than it looks: because a stored bond now rejects new pairings, Forget phone is the only way to re-pair a replaced or reset phone. The guarded hold is the confirmation; there's no extra popup.
The Sensors screen
Settings ▸ Connections ▸ Sensors pairs the ride's BLE sensors, and sits beside Phone under the Connections menu. It's three rows — Heart rate, Power, Cadence — each a kind label over a live status line: Not set, Searching, Connecting, or Connected · 78% (the battery percent shown only once the sensor reports it). Press a row to open its scan list — the discovered sensors of that kind, each a name (or its address when the advert carried none) over an RSSI reading; a press there saves + connects the highlighted sensor and pops back to the now-Connecting row. On a saved row a hold forgets it, using the very same guarded delete footer as Bluetooth's Forget phone — a plain prompt until the row is selected, then the base fills warning-red on the live hold. Saving or forgetting is just a Settings edit; the host reconciles the change to the radio and persists it, so there's one durable path and a saved sensor auto-reconnects across a reboot. How those sensors are actually scanned, connected, and decoded — the device's central role — is on the companion-link page.
The live values land on the riding grid as three stat tiles — Heart rate, Power, Cadence — single-column raw integers (bpm / watts / rpm) that read -- until a fresh sample arrives, and again once one goes stale (older than 5 s). They join the field catalogue like any other tile, so a rider picks and places them in the Ride screen's Data fields editor; per-ride averages and maxima are recorded into the ride object.
Climbs get their own panel
A planned route's hard parts are its climbs, so a third riding view is given over to the one you're on. The Climb screen draws the current climb the way a dedicated climb computer does — a tall elevation trace with the gradient shown as colour, each column tinted by its local steepness from green through red. A "you are here" cursor rides the trace, and four tiles below read only this climb: the ascent and distance still to the top, the gradient here, and the average gradient of what's left.
The climbs are found once, at load. Because the route is planned, its shape is known before you turn a pedal — so finding climbs is an offline segmentation, not a live detector. The same load-time pass that builds the whole-route elevation profile walks the height-against-distance signal and cuts it into a handful of climbs on plain rules: a stretch counts only if it gains enough, averages steeply enough, and runs long enough; a shallow dip is bridged (a false flat is still one climb) while a deep col splits it in two. Deciding up front turns the live question — am I on a climb? — into an interval test on the matched distance, and the found climbs cost about a kilobyte of resident state.
Detail without a bigger buffer. The whole-route profile is decimated to a few hundred columns — plenty for the Statistics band, far too coarse to read one climb's gradient. So the Climb screen draws from a second, finer profile scoped to the active climb alone: one small resident buffer, rebuilt only when you cross into a new climb, never per frame. It's the profile pyramid's trick again — precompute on load, read cheaply while riding — and, like the profile, it's a runtime structure. Nothing new is stored in the route file or sent over the link.
It appears on its own — or waits to be asked. A Climb setting picks the manner: Auto (the default) switches to the panel the moment a climb starts and drops back to the Map when you crest; Manual keeps it out of the way but in reach; Off hides it. With the panel live, the riding views' back becomes a three-stop ring — Map → Statistics → Climb → Map — that collapses to the plain Map ↔ Statistics swap the instant the climb ends. The auto-switch is polite: it fires only from a riding view, never yanking you out of a menu.
Waypoints on the route
A planned route can carry named waypoints — a water stop, a viewpoint, the pass at the top — pinned along it in an along-route table in the route file. Each one now carries a category and a signed lateral offset as well as a name, harvested from the planner's <sym>/<type> at import. In the riding views the device still treats them as calm furniture: three always-available cues plus the stat fields. The ride menu's browser — Up ahead — is where they stop being furniture and become a list you read; every surface shares the same resident table and matched-distance axis.
The fixed-memory boundary remains explicit. A normal route's complete named table fits the resident 32-waypoint cache. If a file carries more, the existing next-waypoint machinery advances that cache as a 32-entry window; the list shows that current resident plan window, and passed entries evicted from an oversized route are not re-read just to reconstruct history.
Diamonds on the map. Each named waypoint draws as a small ink diamond (~9 px) on the route line at its position — no label, the way the route's direction arrows read as furniture. They're always on when the loaded route has waypoints; the name lives in the chip, not on the glyph. A waypoint whose coordinate sits slightly off the drawn polyline shows its diamond slightly off the line, which is honest — the diamond marks the point, not the nearest pixel of route. The category deliberately doesn't reach the marker: a categorised diamond would be a second icon species on a line that already carries direction arrows, and the map is the one surface where waypoints should stay quiet. Categories earn their icons in the list, not on the map.
The approach chip. A one-line pill at the bottom of the Map — the same idiom as the off-route chip at the top, but calm ink-on-parchment rather than warning-orange — reads ◆ NAME <distance>: the along-route distance still to go to the next waypoint (the same arithmetic as the climb tiles' to climb). A three-state setting governs it, like the climb mode:
- Off — never shown. Route planners sprinkle artefact waypoints into their GPX exports; a route full of junk must be silenceable, so Off is the escape hatch.
- Approach (the default) — the chip appears only once the next waypoint is within 500 m ahead and counts the metres down, so you notice the stop without standing chrome.
- Always — visible whenever a waypoint is still ahead (kilometres beyond 1 km, metres inside).
Two details keep it steady. Passing is distance-hysteresis, not time: the chip lingers on a waypoint until you're 100 m past it — the shown distance pinned at 0 through the linger — before advancing to the next, so GPS jitter at the stop can't flap the readout. And the chip hides off-route: the along-route distance is meaningless once you've left the line, and the bottom slot belongs to the warning chip (off route / No GPS Fix) when it's up. The waypoint chip takes that slot only when it's clear — and the scale bar steps up above whichever chip is showing.
Ticks on the progress bar. Under the Statistics elevation profile the amber live-fraction bar already shares the route's distance axis, so each waypoint gets a thin black tick at its fraction, and the fill sweeping toward the next tick is free "distance to the next stop" context. Black, not red, is deliberate: the bar tints warning-red when you're off-route, and a red tick would vanish against it exactly then. (Diamonds on the profile were tried and dropped — ten waypoints clutter the thin band; the calm ticks won.)
Opt-in stat fields. For a waypoint readout on the numbers page, the field picker offers two, so they cost nothing for riders who don't use them:
- a 2×1 "next waypoint" tile — name and distance-to-go, a direct sibling of the wide clock tile;
- a 2×3 list panel — the next few waypoints, name left / distance right, the first row emphasised. It's the one page-sized field: six slots, so it always begins a page, mirroring how a two-span tile always begins a row.
Six more sit between the two in the picker — Next: water, Next: campsite, …, directly under the next-waypoint tile and above the list panel — which answer the same question per category and, unlike these two, read the map's POIs as well as the route's own waypoints.
Unnamed waypoints are ignored everywhere — no diamond, no tick, no chip, no list row. An empty label carries no information anywhere it would surface, so a waypoint whose name is blank after trimming is dropped as the route's table loads; the diamond, tick and row counts then stay consistent by construction. Together with Off, that's the whole answer to junk-waypoint routes: nameless artefacts never appear, and a route full of named clutter is silenced with one setting.
Up ahead — one timeline for the route
A bikepacker asks two different spatial questions, and for a long time this device could only answer one of them. Where's the nearest water? is a question about a disc around you — it ignores where you're going, and the POIs browser answers it with a radius query and a bearing arrow. Is there water in the next 30 km of my route? is a question about a line you're travelling along, and no amount of panning a map answers it. The two look similar on paper and are completely different queries: one is sorted by how far away a thing is, the other by how far along it is.
Once you have that corridor query, a second thing falls out: a route's own waypoints and the map's POIs are the same kind of answer. Both are a point with a category, a distance along the route, and a lateral offset. Splitting them into two lists would make the rider remember provenance — "was that spring in my GPX, or on the map?" — which is exactly the kind of bookkeeping a four-button device can't afford. So the ride compass's north station opens one route-ordered list over both, and the rider's own stops are distinguished within the rows rather than by which screen they're on.
One list, two sources
The merge is an iteration, not a copy. Both inputs — the resident waypoint table and the App-owned corridor snapshot — are already ascending by along-route distance, so a two-finger walk over the two slices produces the list in one pass, filtering as it goes and breaking ties waypoint-first. Nothing is materialised: the Screen variant holds a cursor, a filter, and its snapshot key, never rows, because a variant is a slot in a .bss union and every row it held would be paid for by every other screen on the stack.
The side hint is a triangle, not a glyph. An entry's lateral offset is decision-relevant — is that fountain worth the detour? — and so is which side it's on, so past a tunable 50 m the row draws a small arrow and the distance at line 2's right edge. The arrow is drawn: the device font's Latin strip has no arrow character, so a ← would have shipped as a silent ?. The text itself is the unsigned magnitude; the triangle carries the direction, resolved from the sign the OBCR record and the corridor projection both use — positive is right of the direction of travel. It applies to every row of both sources, because hand-placed waypoints sit off-route just as readily as map POIs. (One asymmetry the corridor imposes: a map POI's hint can never exceed 300 m, since farther ones aren't in the snapshot at all, while a waypoint's offset is whatever the planner drew.)
Source is per-row, never a section. Map-POI icons keep the browser's muted/ink scheme; a custom waypoint's icon draws in amber — except on the amber cursor row, where it falls back to ink, so a small diamond pip beside the icon carries the distinction in every state and does the colourblind-safe half of the job. A waypoint with no usable category shows the familiar plain diamond as its icon and is only listed under Everything: a category filter is a question a generic waypoint cannot answer.
Passed entries are treated differently by source, on purpose. A passed waypoint stays in the list, muted, with both forward-looking figures clamped to zero — reviewing the day's plan is a real use, and silently dropping half of it isn't friendlier. A passed map POI is simply never there: the snapshot is anchored at the progress the screen was opened with, and the corridor query drops anything behind that anchor on the way in. The list opens scrolled to the first unpassed entry of the merged list, and it always opens on Everything — predictable beats sticky.
The snapshot is frozen, and the screen never queries anything. The corridor result lives in one App-owned buffer keyed by (category filter, progress anchor), and the screen only ever declares what key it wants; a small reconcile step arms the buffer from whatever the screen stack asks for after each gesture and once per pass. Entering arms and invalidates, so re-entering refreshes; changing the filter re-keys, which is what re-queries; riding on changes nothing, so rows can't shift under the cursor and no frame touches the card; and leaving the screen — back, or an idle return — drops the request, so the host stops building a map Reader for it. A host-pushed card laid over the list (a passkey prompt, a warning) is deliberately not an exit: the reconcile step scans the whole stack for a declared key rather than only its top, so the frozen snapshot stays armed underneath the card and is still the same snapshot when it clears. That is the same frozen-snapshot discipline the POIs browser runs on, now with a second tenant.
Three empty states keep the screen honest, and the copy names the actual cause: No route loaded / Load a route first, Nothing up ahead / No stops on route, and — with a category filter live — Nothing up ahead / None of this kind. While a query is still in flight the list draws nothing at all rather than flashing a wrong answer for a frame, and a query that fails settles empty rather than spinning, so the honest answer arrives either way. Pressing a POI row opens its detail view, which repeats the same arrow with the side spelled out in words; pressing a waypoint row does nothing, because there is no waypoint detail to open and a dead hint row would be worse than silence.
Hold opens this screen's own config
The category picker is the third instance of a pattern the UI had been using without naming: Hold opens something belonging to the screen you're on — a mode of it, or its configuration — and never navigates away from it. The Map's hold enters Pan mode; the Statistics screen's hold enters Zoom mode on the elevation profile; and the timeline's hold opens the seven-row filter — Everything plus the six categories, cursor resting on the active one, press applies and back cancels. (Where a screen has no mode to enter, hold still acts on that screen rather than away from it: in the Fields editor it's the hold-to-delete bar on the highlighted field, while reordering is the press-lift/press-drop grab idiom.) The hold-hint bulge the overlay plane draws on every screen is what advertises that there's something under the hold at all, so the pattern needs no on-screen affordance.
Two shapes it deliberately does not take. It isn't a filter cycle — six categories under one button would make the rider press through five wrong lists to reach the sixth. And it isn't a Screen: the picker is a mode inside the timeline (an optional row index), so there's no new stack slot, no cross-screen write-back, and the list's own cursor survives a cancel untouched. Its rows are drawn at a tighter pitch than the POI menu's for one reason found in the simulator — at the menu's pitch only five of seven fit, and a filter you have to scroll to reach is a filter you won't use.
"Up ahead shows": a scope with sharp edges
Category is moment-to-moment intent; who feeds the list is a stable preference, so it lives in Settings rather than on the screen. One press-to-cycle row in the Ride group — Up ahead shows: Both (default) / Waypoints / Map POIs — persisted in the same RRAM settings blob as everything else. The row reads as a sentence, "Up ahead shows ◄ Waypoints", which is what lets the value word be one short word at 240 px and still carry the "only".
Two consequences are worth stating because they're the difference between a filter and a scope:
- Waypoints-only asks for nothing. Under that scope the screen declares no snapshot key at all, so the buffer is disarmed, nothing is pending, and the host never builds a map
Readerfor the timeline. The list costs exactly what the plain waypoint list used to. A category filter can't turn the query back on, either — filters apply within the configured sources. - The empty copy names the scope. With one source scoped away, "No stops on route" would be a lie: the route may be lined with map POIs the rider chose not to see. So the sub-line reads Waypoints only or Map POIs only instead — unless a category filter is live, in which case "None of this kind" wins, because that's the thing the rider just did.
The scope is the Up-ahead list and nothing else. It does not hide the map's waypoint diamonds, does not touch the POIs browser, does not touch the stats waypoint panel, and — the one that reads as an inconsistency until you say it out loud — does not scope the Next: category tiles below. Those answer a different question ("how far to the next water?") on a different screen; a preference about what a list shows has no business silently blanking a number.
The "Next: category" stat fields
Six more fields join the Fields editor — Next: water / campsite / lodging / resupply / pharmacy / bike shop — grouped in the picker directly after Next waypoint. Each is a full-width tile with a second anatomy: the category's own row icon, the ellipsized name of the next one of its kind, and the distance-to-go right-aligned beneath — -- when nothing of that kind is ahead, with the category's word as the caption so the tile still reads as an answer rather than a blank. In the picker they read as a block: the category's icon in a left gutter and its own word as the label, sitting under Next waypoint. That costs them the span badge every other row wears — but all six are full-width by construction, so the badge carries no information the block doesn't already state, and the freed width is what lets the longest category word in every language (Campingplatz, Fahrradladen, Hébergement) draw whole. There is no setting for field curation: configuration-about-configuration is the thing to avoid, and if the picker ever feels crowded the fix is ordering inside it.
The answer merges the same two sources as the list, with the same waypoint-wins tie-break, so one stop can never read differently in a tile and in the timeline. But the data policy is deliberately the opposite of the list's:
- The waypoint half needs no machinery at all — the table is resident, route-ordered and at most 32 entries, so the tile walks it every draw. Zero I/O, correct on the first frame.
- The map-POI half is a cache, not a snapshot. A list needs membership and order frozen so rows don't shift under the cursor; a tile shows one entry and the rider wants its distance to count down. So the tile is fed a distilled
(distance along, name)per category and re-derives the distance from live progress every frame, and the cache re-takes on exactly four triggers: nothing is cached yet; matched progress has advanced 500 m since the take that filled it; matched progress has fallen more than 100 m behind that take; or the cached entry has been passed — the one case where a stale answer is actively wrong. The rewind trigger is there because a snapshot is only an answer for the axis ahead of its anchor, and progress does go backwards: a route re-upload or a second lap zeroes it, which would otherwise leave the tile blind to everything now in between. The 100 m tolerance is what keeps that from firing on noise — the route matcher searches a few segments behind the cursor, so ordinary re-matching wobbles progress back a handful of metres, and a zero-tolerance rule would burn a query per wobble. Swapping the route out from under the cache doesn't stale it, it empties it: distances are route-relative, so a same-index/new-bytes replace — a re-upload, a committed detour — leaves entries that are wrong rather than old, and the App-level "drop everything derived from the route's geometry" seam clears them alongside the matcher and the profile.
Two rules keep that cheap and truthful. It refreshes one category per query, which is correctness rather than tuning: the corridor caps at 16 results across the whole filter, so a union query on a POI-dense map could come back with sixteen fountains and never mention the pharmacy 12 km on — and the pharmacy tile would lie. Filtered to one category, the nearest of it is the first result by construction, and a round-robin over the placed categories keeps any one from starving. And it only asks while the Statistics grid is the screen being drawn, with a tile actually placed and a route loaded — a rider on the map, in a menu, or with no such tile pays nothing, and leaving the page drops the request but keeps the cache, so coming back within 500 m is instant.
The cache and the timeline share one buffer, with an explicit priority: a screen always wins. The reconcile step takes the topmost screen's request first and only falls back to the cache when no screen wants anything — and the cache's harvest accepts a landed snapshot only if it was taken for the cache's own key, so the two can never read each other's rows. In practice they never even compete: the cache asks only while Statistics is the base screen, which is never while the timeline is up.
The whole flow
Put the pieces together and the navigation graph is small and legible. Two screens are always riding views — the Map and the Elevation/Statistics profile — and they're siblings: back swaps between them without growing the stack. On a climb a third view joins the ring between them. All three share press (pause) and back-hold (the ride-scoped compass); the Paused page accepts the same back-hold. Opening that menu never changes the activity mode, so it neither pauses a rolling rider nor resumes a paused one. The Map's Pan sub-mode keeps one deliberate override: there, back-hold exits Pan first instead of opening a menu.
[Home, Map]. During a recording, back-hold from any riding view or Paused pushes the fixed Ride menu (Up ahead · Detour · POIs · Routes · Main menu) without touching the activity mode, and back returns to the exact view that called it; a route-less ride keeps all five stations but dims Up ahead and Detour. Pan is the one exception: its back-hold exits Pan rather than opening ride chrome. The idle-return timeout clears abandoned chrome back to Home or — while tracking — the Map.The "field map" look
The UI is styled like a weatherproof field map — a wood frame, a parchment panel, ink text, an amber selection highlight — and it shares the map's colour pipeline exactly. Screen colours are authored in RGB565 and resolved through the same color_fn the map styles use, so chrome and map quantise together. That matters because the panel is only 64 colours (RGB222): every palette value is chosen for how it looks after quantisation, and a test asserts each one's device-64 result so a retune can't silently drift.
Surface vocabulary (rounded rects, text in four font tiers, triangles, lines, rules), implemented once by its Canvas — the same primitives for every screen, so the Menu, the Route list and the Elevation header share one look. There's no theming engine; the palette is a handful of named constants.Where this lives
- The screen system, stack, and
Transition:obc-app/src/screen/mod.rs - The Climb screen:
obc-app/src/screen/climb.rs; the climb detection + per-climb profile it reads:obc-route/src/climb.rs,obc-route/src/climb_profile.rs - The waypoint UI — the ride compass:
obc-app/src/screen/ride_menu.rs; map diamonds + the approach chip:obc-app/src/screen/map.rs; progress-bar ticks:obc-app/src/screen/statistics.rs; next-waypoint tracking (resident-window advance + pass linger):obc-app/src/ride_engine.rs; the stat fields:obc-app/src/stat_fields.rs - The Up-ahead timeline + its category picker:
obc-app/src/screen/up_ahead.rs; the App-owned corridor snapshot:obc-app/src/corridor.rs; the next-of-category refresh cache:obc-app/src/next_ahead.rs; the query underneath both:obc-reader/src/corridor.rs - The host→app BLE seam (
BleStatus— the connected indicator, passkey, paired):obc-app/src/ble.rs - The host-pushed cards — the passkey card and the route-upload prompts:
obc-app/src/screen/passkey.rs,obc-app/src/screen/route_received.rs - The Rides screen, its Ride detail, and the Bluetooth settings screen:
obc-app/src/screen/rides.rs,obc-app/src/screen/ride_detail.rs,obc-app/src/screen/settings/bluetooth.rs - The settings screens (the two-level editors + the shared kit):
obc-app/src/screen/settings/ - The
Settingsvalue + its byte codec and theLanguageenum:obc-app/src/settings.rs; the dependency-freeSettingsStoreseam:obc-ports/src/lib.rs; direct adapters:obc-sim/src/settings_store.rs,obc-fw-nrf54l/src/settings.rs - The i18n catalogue + codegen — the per-language TOMLs, the
build.rsthat generatesMsg/TABLE, and thet()/rx.t()lookup:obc-app/i18n/,obc-app/build.rs,obc-app/src/i18n.rs; the font-repertoire guard:obc-app/tests/i18n.rs - The gesture recognizer:
obc-app/src/input.rs - The input + overlay plane:
obc-app/src/input_plane.rs - The app driver (frame loop, render-on-demand, compositing):
obc-app/src/app.rs - The injected-hardware traits, settings persistence seam, and input types:
obc-ports/src/lib.rs; platform adapters:obc-platform/src/; compatibility-only app re-exports:obc-app/src/hal.rs
For how the two planes keep input responsive under a long render, and where the HAL fits, see system architecture. For how a screen's draw actually puts pixels on the panel, see the rendering pipeline.