YamDrive · Android · Expo Router

Component canvas

Every component the app ships, in every state it can render, drawn from src/components, src/app/(tabs) and the tokens in src/constants/theme.ts. Each specimen sits on a real app surface — light #F5F5F1 and night #141715 — so a state can be read in the scheme it will actually ship in. Captions name the props that produce the state.

Components
45
Screens
3 tabs · 6 stack
Theme colours
42 × 2
Text roles
14
Icons
65 names · 63 glyphs
Source
claude/github-main-sync-cjlf4j
Scheme
1 px = 1 dp · hairlines drawn at 1 px
01

Foundations

Light and night are declared side by side in the theme file, so ThemeColor stays the intersection of both and nothing can exist in one scheme only.

Theme colours

src/constants/theme.ts

Forty-two keys per scheme, in three groups. The first eighteen are the surface palette; tint is the only one that changes role between schemes, a darker gold in light for text contrast and the brand yellow at night. The three health* grounds do invert. Everything from route down is deliberately identical in both schemes — those colours sit on the light OSM basemap or carry a fixed meaning (a speed band, an event kind) that must not shift when the phone switches to night. Alongside them, theme.ts exports Floating — elevation 2 with a 12 % black shadow at radius 3 — the one lift shared by everything that hovers over the map.

Colors.light / Colors.dark · 42 keys
TokenLightNightRole
text#242523#F5F5F1Primary copy
background#F5F5F1#141715Screen canvas, sheet body
backgroundElement#EAEAE5#30342FSecondary button, input, chip
backgroundSelected#DEDFD8#3C4039Pressed row, active saved ride
textSecondary#636760#B2B7ADCaptions, hints, placeholders
tint#665000#F4C430Icon / link accent
onTint#242523#242523Copy on the yellow fill
border#DCDDD6#41453EHairlines, dividers, sheet handle
primary#F4C430#F4C430Primary action fill
surface#FFFFFF#252825Raised card
navigation#246BAC#70B4EFTurn glyph in the guidance bar
rest#E3DCEB#3B3345Break / checklist tile
fuel#F0DFC4#443B2ARefuel tile
positive#367923#86CD65Checklist progress
departure#E8C2A6#E8C2A6Departure card ground
onFeature#242523#242523Copy on departure / tiles
danger#C22C24#F87171Destructive action, form error
healthGood#DCEBD3#28381FVehicle health tile · on track
healthSoon#F3E3B5#43391DDue within 30 days or 500 km
healthDue#F4D3CF#4A2523Service due
route#368DDD#368DDDRoute line, ride thumbnail
heat#5B21B6#5B21B6Heatmap line
heatGlow#8B5CF6#8B5CF6Heatmap glow, blurred under it
speedLow#D9A52C#D9A52CSpeed band · below range
onSpeedLow#3A2A05#3A2A05Copy on the low band
speedBalanced#3F7A50#3F7A50Speed band · within range
speedHigh#B4502F#B4502FSpeed band · above range
onSpeed#FFFFFF#FFFFFFCopy on the green and red bands
eventTraffic#D98B52#D98B52City event · traffic wait
eventStop#6B6F68#6B6F68City event · stop
eventBreak#8A72B8#8A72B8City event · break
eventAccel#368DDD#368DDDCity event · hard acceleration
eventBrake#8C3A24#8C3A24City event · hard braking
fuelLevel#E0A12E#E8B04AEnergy tile · the fuel in the tank
fuelBody#FBF4E8#2E2819Energy tile · the tank body
fuelOutline#5C4424#F0DFC4Energy tile · the tank outline
reliefWall#CFE2F4#34506BRoute relief · the wall under the ribbon
reliefSteep#1F5E99#8CC4F2Route relief · the colour at a 10 % grade
toggleThumb#FFFFFF#FFFFFFToggle · the thumb, white on either track
dock#202220#202220Floating dock ground
dockIcon#D3D5D1#D3D5D1Floating dock · inactive icon
scrim11 18 21 / 45%0 0 0 / 60%Dim behind a modal sheet

Stop categories

src/lib/categories.ts

Six categories drive chip colour, marker colour, glyph and the nearby-search phrasing. The queries are free text rather than a Places type enum, so regional wording works — “petrol pump” reads better than “gas station” in India. other is excluded from one-tap nearby search as too vague to help.

CATEGORIES · CATEGORY_ORDER
KeyColourLabelIconnearbyQueryNearby search
fuel #EA580CFuel gas-stationpetrol pumpYes
food #DC2626Food silverware-fork-kniferestaurantYes
rest #0891B2Break coffee-outlinerest stop cafeYes
stay #7C3AEDStay bed-outlinehotelYes
sight #059669Sight camera-outlinetourist attractionYes
other #475569Other map-marker-outlinepoint of interestNo

Literal colours outside the theme

ride-widget · map-markers · map.tsx · use-floating-navigation

Most of this table has moved into the theme. The speed bands, the route line and the city-event colours are now speedLow…onSpeed, route and eventTraffic…eventBrake above — declared identically in both schemes rather than hard-coded at each call site. What remains below is still written as a literal in a component, because it is drawn over the raster basemap and the OSM tiles keep the same brightness in both schemes. These are the values to change if the map ever gets a dark style.

Still literal in a component
UseFillForegroundWhere
origin pin “A” #166534#fffMapPin
destination pin “B” #991b1b#fffMapPin
break · rest #0369a1#fffMapPin Rn
break · fuel #c2410c#fffMapPin Fn
break · selected #6d28d9#fffMapPin, target set
search result #475569#fffMapPin +n
your position #2563eb#fff ringNavigationMarker
planned fuel badge #E9C2A8#242523PetrolMarker planned
road label #1769AA#fff“Toward …”
compass north / travel #F4C430 #367923#242523 labelMapCompass
tab dock #202220#F4C430 / #D3D5D1useFloatingNavigation
progress bars #D5DDBB #E3D9ECon #F5F5F1RideOverview

ThemedText · 12 roles

src/components/themed-text.tsx

type default | title | subtitle | screenTitle | hero | display | metric | label | small | smallBold | caption | link | linkPrimary | code  ·  themeColor ThemeColor

On Android, label, smallBold, metric, subtitle and title switch to sans-serif-medium rather than leaning on synthetic weight. screenTitle is the newest: 20 / 26 medium, the one size every page header uses, so Home, Ride and every stack screen agree. hero, metric and display carry tabular figures so digits do not shift as speed and distance tick. hero is the newest role and the largest: it carries the live speed on the city dashboard and the total on Rides. includeFontPadding is off everywhere; line heights are explicit.

hero48 / 56 · 500 · tabular · −1
52 km/h
display36 / 40 · 500 · tabular · −0.8
04:30
title36 / 42 · 600
Ride plan
subtitle24 / 30 · 600
Trip
screenTitle20 / 26 · 500 · every page header
Widgets
metric20 / 26 · 500 · tabular · −0.4
7h 30m
default16 / 24 · 400
Estimated arrival · 12:30 PM IST
label14 / 20 · 500
Ride checks
smallBold14 / 20 · 500
Guntur → Bangalore
small14 / 20 · 400
600 km · planning estimate
smallthemeColor="textSecondary"
No live traffic
caption12 / 16 · 400
Checked for this plan
link14 / 20 · inherits text
Report a map issue
linkPrimaryhard-coded #3c87f7
OpenStreetMap licence
code12 · mono · 700 on Android
yamdrive.rides.v1

Spacing, radius, metrics

src/constants/theme.ts

Planning screens group at 16, cards pad at 16, associated rows gap at 8, label/value pairs at 4–6. Shared controls hold a 48 dp minimum target per Material guidance; the icon-only button and floating map buttons go to 52.

Spacing · Radius · Layout
ScaleKeydpApplied to
Spacinghalf / one / two2 / 4 / 8Icon gaps, label stacks, chip rows
three / four16 / 24Card padding, screen gutters, group gap
five / six32 / 64Empty-state padding, tall spacers
Radiussm / md8 / 16Handle, toast · button, input, flat card
lg / xl24 / 28Screen card, route summary · sheet top, dock
pill999Chip, icon button, speed meter, marker
LayoutMinTouchSize48Every shared control
BottomTabInset80 Android · 50 iOSMaterial tab bar is the taller one
MaxContentWidth800Reading measure cap
dock clearanceinset + 84Scroll padding under the floating dock

Icon set

src/components/icon.tsx

name IconName  ·  size number = 20  ·  color string (required)  ·  weight is always regular

Phosphor glyphs are imported one file at a time (phosphor-react-native/src/icons/…) so the bundle never pulls the whole set. 65 names map to 63 glyphs — two pairs are aliases kept for call-site readability, marked below. hunter is the exception to all of this: not a Phosphor glyph but a hand-drawn Hunter 350 on Phosphor's 256 grid with its 16-unit round strokes, so it sits beside the rest. map-marker-path and distance were redrawn — a stop on a route, and a signpost — and map-marker-path is no longer an alias of routes.

information-outline
home
clock
checklist
check-square
square
turn-left
turn-right
turn-uturn
turn-roundabout
turn-arrive
play
pause
stop
map-outline
motorbike
format-list-numbered
map-marker-path
routes
chevron-up
chevron-down
chevron-right
crosshairs-gps
fit-to-screen-outline
arrow-expand
arrow-collapse
tune-variant
close
pencil-outline
magnify
arrow-left
arrow-up
arrow-down
navigation-variant
navigation-variant-outlinealias · NavigationArrow
trash-can-outline
delete-outlinealias · Trash
alert-circle-outline
plus-circle
check
plus
account
code
trafficcity event · traffic
handcity event · stop
trend-upcity event · accel
trend-downcity event · brake
distance
compasssensor widget
sunsensor widget
mountainssensor widget
cpu
scan
hunterthe rider's own bike
heart
chevron-left
fireheatmap
long-ride
city-ride
gas-station
silverware-fork-knife
coffee-outline
bed-outline
camera-outline
map-marker-outline

Basemap

src/lib/map-style.ts

BasemapId openfreemap | osm  ·  MapStyleId positron | bright | liberty | dark | fiord

The map is vector now. OpenFreeMap serves OpenMapTiles-schema tiles with no key and no limits, and five ready-made styles the rider picks between; the OSM raster source stays as a fallback, with its labels still baked into the pictures. Each style carries the building colour for the 3D extrusion layer, or null where the style already draws its own. Every basemap has its own required credit, and it shows on every map.

the five stylesswatch = background + roads
PositronLight grey and quiet. Your route and trail stand out most.
BrightColourful roads, parks and water, like a paper map.
LibertyDetailed and colourful, with 3D buildings built in.
DarkNear-black. Easy on the eyes on night rides.
FiordDeep blue-grey, softer than Dark at night.
MapCreditrequired, on every map
OpenFreeMap © OpenMapTiles · © OpenStreetMap contributors © OpenStreetMap contributors 9 / 13, shrinks to 80 % before it truncates. A known colour exception in DESIGN.md §2.
02

Primitives

Six shared controls. Every one of them takes its press and disabled state from opacity rather than a second colour, so a variant never needs a pressed palette of its own — the exact value differs per component and is called out on each specimen.

Button

src/components/button.tsx

label string  ·  onPress () => void  ·  variant primary | secondary | floating | danger = primary  ·  icon IconName  ·  iconOnly false  ·  disabled false  ·  stretch false  ·  hint string?  ·  style StyleProp

48 dp minimum height, radius 16, hairline border. floating is new: the background ground plus a border and the shared Floating lift, for a control that hovers over the map rather than sitting on a surface. danger is the only variant with a transparent ground. iconOnly drops the label to a 52 × 52 target, raises the glyph to 24, and gains a long-press toast carrying the label — the label is never lost, only moved. hint is read out as the screen-reader hint and shown on the same long press, which is how the two ride buttons explain themselves without a line of copy on the map.

primarydefault
Create ride
primarypressed · opacity .8
Create ride
primarydisabled · opacity .45
Create ride
secondarydefault
Route & stops
secondarypressed · opacity .8
Route & stops
secondarydisabled
Demo ride
floatingover the map · Floating lift
Route & stops
dangerdefault · border only
Clear stops
dangerpressed · opacity .8
Clear stops
icon + labelglyph at 18
Open map
iconOnly52 × 52 · glyph at 24
stretchsheet footer pair · flex 1
CancelAdd to trip
style overrideStart ride · pill, 52 dp
Start ride

IconButton

src/components/icon-button.tsx

name IconName  ·  accessibilityLabel string (required)  ·  onPress () => void  ·  size number = 22  ·  color string = theme.text  ·  disabled false  ·  elevated false

The label is required rather than optional — an icon alone gives a screen reader nothing to announce. Every instance also carries “Hold to show label” and a long-press toast, so the hint is unconditional here, unlike on Button where it appears only in iconOnly mode. hitSlop 6 extends the 48 dp target past the drawn bounds.

defaultsize 22 · theme.text
pressedopacity .6
disabledopacity .35 · not hydrated
colortheme.tint · theme.danger
size20 sheet close · 26 add result
elevatedsolid ground + elevation 4
style overridemap control · 52 sq, radius 16

TextField

src/components/text-field.tsx

label string?  ·  multiline false  ·  …all TextInputProps except style

Input type is 16 / 22 — above the 16 px threshold that would trigger a zoom on focus — while its label stays at 14 in textSecondary. The label doubles as the accessibility label, so a field without one is unlabelled to a screen reader. Multiline grows to 77 dp and aligns text to the top.

label + placeholderempty
NameBharat Petroleum, Nashik bypass
label + valuefilled
Ride name (optional)Weekend escape
no labellabel undefined
05:00
multiline77 dp · textAlignVertical top
Note (optional)Fill up here — next pump is 90 km
side by sideflex 1 each · gap 12
Estimate (km)600
Departure · ISTOptional · 04:30
with errorsibling alert · theme.danger
Start time (IST · 24-hour)5pm
Use 24-hour time, for example 05:00 or 16:30.

CategoryPicker

src/components/category-picker.tsx

value StopCategory  ·  onChange (category) => void

A horizontally scrolling single-select row with accessibilityRole="radio" per chip. The selected chip takes the category’s own colour for both ground and border and flips its glyph and label to white; unselected chips keep the glyph in the category colour on backgroundElement. Chips are 40 dp — the one deliberate exception to the 48 dp rule, since the row is a filter rather than a primary control.

value="rest"the add-stop default
Fuel Food Break Stay Sight Other
value="fuel"+ pressed chip · opacity .75
Fuel Food Break Stay Sight Other
selected, each keysix terminal states
Fuel Food Break Stay Sight Other

Brand · ThemedView

src/components/brand.tsx · themed-view.tsx

Brand is a 24 dp launcher mark plus the wordmark at 14 / 20 / 500 with −0.2 tracking. It appears on the three planning screens; the riding map deliberately omits it so guidance is never competing with identity. ThemedView is a bare View whose only job is backgroundColor: theme[type ?? 'background'].

Brandplanning screens only
YYamDrive
ThemedViewtype: background | backgroundElement | surface

Sheet

src/components/sheet.tsx

visible boolean  ·  onClose () => void  ·  title string  ·  subtitle string?  ·  footer ReactNode?  ·  showHandle false  ·  topPadding 8  ·  bodyMaxHeight number?  ·  children ReactNode

Built on the platform Modal and deliberately dependency-free: no snap points, no gesture library. What it does have is a PanResponder on the handle that dismisses past 50 dp of travel or 0.7 velocity, a tappable backdrop labelled “Dismiss”, and Android hardware-back wiring. In landscape the sheet becomes a 480 dp right-hand panel and its body gains its own ScrollView; in portrait children are rendered directly so each caller owns its own scrolling. bodyMaxHeight caps the body so whatever is behind the sheet — usually the map — stays visible.

title onlyshowHandle=false · no footer
Trip options
Trip nameGuntur → Bangalore
subtitle + footertwo stretch buttons
Add stop16.30670, 80.43650
Body content renders directly in portrait.
CancelAdd to trip
showHandledrag to dismiss · dy>50 or vy>0.7
Your rides3 saved on this phone
New ride

Toggle · PageHeader

src/components/toggle.tsx · page-header.tsx

Two small components that exist to stop drift. Toggle wraps Switch and removes the colour props from its own type — callers cannot set a track or thumb colour, so every switch in the app is the same yellow. PageHeader gives every stack screen one header: a round back button that falls back to a given route when the page was opened cold, the title as screenTitle, an optional caption, and an optional round action. Header icon buttons are circles; the ones on surfaces stay squares.

Toggleon · off
PageHeadertitle + subtitle + action
WidgetsLive from this phone's sensors · for reference
BackButtonvariant="floating" over a map
03

Ride instruments

The pieces that only exist while a ride is running. Their colours are hard-coded because they sit over the raster basemap, and their content is derived from a GPS fix that is allowed to be missing — every one of them has an unavailable state.

RideWidget

src/components/ride-widget.tsx

session ReturnType<useRideSession>  ·  range SpeedRange | null  ·  onSettings () => void

Ground colour comes from speedBand(speed, range) and is applied only while status === 'active' with a known band. The visible text is limited to the number and km/h; the band word — Below, In range, Above, GPS…, Paused, Start — is exposed to accessibility services only, so the meter never reads as an instruction to change speed. Idle shows 0, not a blank.

idle“0 km/h, Start” · playfixed
0 km/h
active · band unknown“—” · GPS…fixed
— km/h
active · below#F4C430 on #422006fixed
34 km/h
active · within#367923 on #ffffixed
52 km/h
active · above#B62E28 on #ffffixed
71 km/h
paused“0 km/h, Paused” · playfixed
0 km/h

MapCompass

src/components/map-compass.tsx

heading number | null  ·  onReset () => void

A fixed north-up reference, not a rotating rose: the yellow arrow always points to the top of the dial and the green arrow rotates to travel heading, independently of map bearing. With no heading the green arrow is absent entirely and the label falls back to an em dash. The centre label uses a smaller letter for the weaker diagonal axis — Ne means north-of-north-east — and crossfades with a 3 px blur on Android 12+, a plain fade below that, and nothing under reduced motion. Heading changes take the shortest arc across north rather than spinning a full turn.

heading=nullno travel arrow · “—”fixed
—
heading=0due north · arrows alignedfixed
N
heading=45exact diagonal · both capsfixed
NE
heading=30nearer north · “Ne”fixed
Ne
heading=250nearer west · “wS”fixed
wS
heading=180due southfixed
S

RideOverview · progress bars

src/components/ride-overview.tsx

totalKm totalMinutes remainingKm minutes nextStop destination breaks paused demo elevation now landscape onEnd onStops

Two bars share one row in both orientations and open the ride-summary sheet. Fill ratio is 1 − remaining / total, clamped to 0–1, so an unknown value reads as an empty bar rather than a full one. Off-route or a missing fix suppresses distance and time independently — either bar can be unavailable alone. 44 dp portrait, 48 dp landscape.

on route31 % travelledfixed
412 km left
~6h 20m left
remainingKm=nulloff route or no fixfixed
Position unavailable
~6h 20m left
minutes=nulltime suppressedfixed
412 km left
Time unavailable
both nullnothing projectablefixed
Position unavailable
Time unavailable
near arrivalratio clamped at 1fixed
0.4 km left
~1 min left
landscape48 dp rowfixed
228 km left
~3h 40m left

RidePreparation · cards

src/components/ride-preparation.tsx

plan RidePlan  ·  elapsedMinutes number  ·  onChange (plan) => void

Three stacked controls on the Ride Plan screen. The departure card paints a static lavender-to-peach SVG gradient — dawn, not a live sky — with dark text at a fixed onFeature, so it is identical in both schemes. Changing the departure time clears the checklist, since the checks belong to a plan rather than to the app.

departuredepartureTime setfixed
Departure · IST04:30Arrival ~2:15 PM
departuredepartureTime undefinedfixed
Departure · ISTSet timeMake an early start your plan
checklist0 of 10 · fresh plan
Before you ride0/10
checklist7 of 10 · in progress
Before you ride7/10
light maplightMap !== false · on
Light mapLess animation · best for Nokia
light maplightMap === false · off
Light mapLess animation · best for Nokia

ElevationChart

src/components/elevation-chart.tsx

points ElevationPoint[]  ·  markerKm number | null?  ·  threshold number = 5  ·  note string?

Area chart of height against distance, 120 dp tall with a 40 dp label gutter. Gridlines land on round steps (1, 2, 2.5 or 5 × 10ⁿ) chosen to give three or four lines, so the axis never reads 137 m. threshold is the smallest change counted towards climb and descent — 5 m for a terrain profile from Valhalla, 10 m for a recorded GPS track, because the sensor is noisier than the model. Returns null outright below two points rather than drawing an empty frame.

route profilethreshold 5 · no marker
Elevation
↗ 1,240 m↘ 980 m
0 km598 km
Low 12 m · High 604 m
recorded ridethreshold 10 · marker + note
Elevation
↗ 860 m↘ 640 m
You · 486 m412 km
Low 12 m · High 604 m · GPS, smoothed

RideThumbnail

src/components/ride-thumbnail.tsx

path Position[]?  ·  elevation ElevationPoint[]?  ·  width height number

Three-step fallback in one component: the route shape if a path was recorded, else the elevation line, else the motorbike glyph. East–west is scaled by cos(latitude) so a route does not stretch sideways, and the shape is fitted into the box with 14 dp of padding. The path draws in route; the elevation fallback draws in textSecondary, so the two are never mistaken for each other at 96 × 104 in a list row.

pathGPS route shape
elevationno path recorded
neitherride logged by hand

City dashboard

src/components/city-dashboard.tsx · src/lib/city-ride.ts

speed city CityState range SpeedRange now paused demo km ridingMs layout 'glance' | 'cluster'  ·  onPause onResume onEnd onLayout

Three exported layouts over one useCityView hook, so the two arrangements can never disagree about state. Bands are a city range, not the touring one: low under 25 km/h, balanced 25–50, high above. Standing still — under 2 km/h — reads Stopped whatever the spell later becomes, while crawling reads as traffic, because a rider at a signal should not be told they are in a band. Paused and stationary both suppress the band entirely and fall back to surface.

CityGlanceTopbalanced · moving
52 km/h
Balanced
Balanced 25–50
Moving · 8.4 km · 0:24:10
Moving 0:15:02Traffic 0:05:20Stops 0:02:11Breaks 0:01:37
CityGlanceToplow · in traffic
14 km/h
Low
Balanced 25–50
In traffic · 0:01:12 · 8.4 km · 0:24:10
Traffic recorded
CityGlanceTopstopped · band suppressed
0 km/h
Stopped
Balanced 25–50
Stopped · 0:03:44 · 8.4 km · 0:24:10
CityGlanceToppaused · no GPS reading
— km/h
Paused
Balanced 25–50
Paused · break · 0:02:05 · 8.4 km · 0:24:10
CityGlanceBottomcounters + controls
6Traffic
2Stop
1Break
3Accel
4Braking
PauseEnd ride
CityClusterband scale · marker at 52
0255070
52 km/h
High8.4 km · 0:24:10
Moving
Flash3 s after an event is recorded
Hard braking recorded
demo12× · finished
— km/h
Paused
Balanced 25–50
Demo finished · tap End demo · Demo 12× · 4.1 km · 0:06:00

TankGauge · RangeRing · BikeAvatar

src/components/tank-gauge.tsx · range-ring.tsx · bike-avatar.tsx

Three small drawings that carry fuel and identity. The tank is the Hunter's own tank traced from the bike artwork — the black rear sweeping up from the seat, the flat top with the filler cap, the rounded nose — filled to the current level and clipped to its own outline, green until the low-fuel light is on and then yellow. The range ring hugs the bike card's top-right corner, concentric with the card radius so its bend follows the corner, and fills clockwise from the top; without an estimate only the track shows. The avatar is the bike's two paint colours split on the diagonal, with a hairline ring so a light or dark paint still reads on the background.

TankGaugefull · low · empty
RangeRingfilled · track only
BikeAvatartwo-tone, split on the diagonal

RouteRelief · RecentRides

src/components/route-relief.tsx · recent-rides.tsx

path Position[]  ·  elevation ElevationPoint[]?  ·  startName endName string?  ·  colorAt (fraction) => string?  ·  compact false

The ride drawn as a ribbon standing on a wall, in Skia — the newest dependency and the first thing in the app that is not plain React Native views. The path is projected to metres east and north of its centre, tilted, and each point raised by its recorded altitude; the wall under the ribbon runs from reliefWall to reliefSteep as the grade approaches 10 %. A height range under 60 m is drawn at its true share of that, so a flat city ride stays flat rather than being stretched into mountains. colorAt lets a city ride colour the ribbon by speed band instead. RecentRides stacks the last four GPS rides on Home as still, compact reliefs — title and drawing, nothing else — and renders nothing at all until there is a ride to show.

RouteReliefcompact · wall by grade
RouteReliefcolorAt · city ride by band
RecentRidesHome · last four, title + relief
Morning Ride
Evening Ride
04

Map markers

Three marker components carry every point on the map. All colours are literals — the OSM raster basemap keeps its brightness in both schemes, so a themed marker would disappear at night. Letter codes are the app’s own shorthand: A / B endpoints, Rn rest, Fn fuel, Sn saved, +n search result.

MapPin

src/components/map-markers.tsx

id string  ·  point Position  ·  label string  ·  color string  ·  onPress ()?

A 28 dp pill with a 2 dp white ring that grows with its label — minWidth 28 rather than a fixed square, so “You” and “F12” both fit. The label is also the accessibility label, which is why the codes are short but never blank.

route endpoints#166534 · #991b1bfixed
AB
planned breaksrest #0369a1 · fuel #c2410cfixed
R1R2F3
break selectedtarget === i · #6d28d9fixed
R4F7
saved stopscategoryMeta(category).colorfixed
S1S2S3S4S5
search results#475569 · hidden while ridingfixed
+1+2+3
idle position“You” · #2563ebfixed
You
endpoint pickertheme.tint · the one themed pin
AB

NavigationMarker

src/components/map-markers.tsx

point Position  ·  heading number | null  ·  mapBearing number  ·  demo boolean  ·  roadLabel string?

A 44 dp white disc ringed in #2563eb, anchored to its top with a −22 offset so the disc centres on the fix. With no heading it holds a plain 14 dp dot rather than an arrow pointing nowhere. With a heading the glyph rotates by heading − mapBearing + 45° — the 45 compensates for Phosphor’s NavigationArrow resting on the diagonal. The “Toward …” label comes from Valhalla street names stored on newer routes; older routes need a rebuild before it appears, and it is suppressed while off route.

heading=nulldot · “heading unavailable”fixed
heading=0 · bearing 0rotated 45° · points northfixed
heading=135 · bearing 90rotated 90°fixed
roadLabel168 dp · one line, truncatedfixed
Toward NH-16 Vijayawada
demosame mark · label “Demo position”fixed
Toward Kurnool Bypass

PetrolMarker

src/components/map-markers.tsx

id point label  ·  planned false  ·  selected false  ·  onPress ()?

A charcoal pill with a pump badge, a tail and a ground dot, anchored to its bottom so the dot marks the point. The badge colour is the whole distinction: yellow means a real petrol stop — a search result or a saved fuel stop — and peach means a planned refuelling area, a distance along the route where the schedule says to refuel. The peach label always reads “Fuel area · n km”, never a station name, so the marker never implies a pump exists there.

defaultreal stop · yellow badgefixed
Bharat Petroleum
selected#F4C430 ringfixed
Bharat Petroleum
plannedpeach badge · “Fuel area”fixed
Fuel area · 150 km
planned + selectedboth modifiersfixed
Fuel area · 300 km
long labelmaxWidth 174 · truncatedfixed
Indian Oil Petrol Pump, Kurnool Bypass Road

Buildings3D

src/components/buildings-3d.tsx

OSM building footprints raised to their mapped height, as a fill-extrusion layer on OpenFreeMap's own vector source — the thing raster tiles could never do. Zoom 15 and above only, where the tilted ride view sits, at 75 % opacity in a colour matched to the chosen style. Honest caveat carried in the code: height coverage in India is patchy, so many areas stay flat.

extruded footprintszoom ≥ 15 · per-style colour
Positron · rgb(228, 228, 222)
05

Sheets

Nine sheets share one Sheet shell. Each is shown here at its body only — the scrim, handle, header and footer behave as Part 02 documents.

AddStopSheet

src/components/add-stop-sheet.tsx

draft StopDraft | null (null hides the sheet)  ·  onChange (changes)  ·  onClose ()  ·  onSubmit (stop, editingId?)

The draft doubles as the form state, so the sheet is fully controlled and there is no prop-to-state copy to resync when the user picks a different point. The presence of editingId is the only thing separating the two modes — it swaps the title, the submit label and its glyph. On submit an empty name falls back to “Category stop”, because reverse geocoding can come back blank and a nameless stop must never be saved.

addeditingId undefined · subtitle = coords
Add stop16.30670, 80.43650
NameBharat Petroleum, Nashik bypass
What is this stop for?
FuelFoodBreak
Note (optional)Fill up here — next pump is 90 km
CancelAdd to trip
editeditingId set · subtitle = address
Edit stopNH-44, Kurnool Bypass, Andhra Pradesh
NameIndian Oil, Kurnool
What is this stop for?
FuelFoodBreak
Note (optional)Last cheap pump before the ghat
CancelSave

ResultsSheet

src/components/results-sheet.tsx

visible title loading error results category origin  ·  onAdd onFocus onClose

Four mutually exclusive bodies — spinner, notice, empty copy, list — decided in that order, so a loading search never shows a stale empty state. The subtitle tracks the same branch: “Searching…”, nothing at all during an error, otherwise a singular/plural result count. The list caps at 340 dp so the map stays visible behind the sheet. Tapping a row focuses the map; only the plus-circle adds.

loadingsubtitle “Searching…”
Fuel near break 3Searching…
errornotice · subtitle withheld
Food near map centre
Search unavailable. Check your connection and try again.
emptyresults.length === 0
Stay near map centre0 results near the map centre
Nothing found here. Try zooming out, panning somewhere else, or searching for something more specific.
resultsrow default · row pressed · rating
Fuel near break 33 results near the map centre
Bharat PetroleumNH-44, Kurnool Bypass1.2 km straight-line distance · ★ 4.3
HP Petrol PumpKurnool–Nandyal Rd2.8 km straight-line distance
Indian Oil4.1 km straight-line distance · ★ 3.9

RideLibrarySheet

src/components/ride-library-sheet.tsx

mode 'new' | 'saved'  ·  onClose ()  ·  onNew ()

One sheet, two modes. new is a connected start/destination timeline over the shared endpoint picker, with an optional name, a distance estimate and an optional IST departure; the footer is Create ride, disabled until storage has hydrated. saved lists the rides on this phone as radios — the active one shows a check and cannot be deleted, every other one carries a destructive trash action behind a confirm. New rides inherit pace and break preferences but start with no stops, no checklist and no road route.

new · emptyendpoints unset
New rideA new route. Your own pace.
Ride name (optional)Weekend escape
StartSearch or drop a pin
DestinationSearch or drop a pin
Estimate (km)600
Departure · ISTOptional · 04:30
Road distance updates when you build the route. Add stops on the map. Break and speed preferences carry over; the checklist starts fresh.
Create ride
new · filled + errorvalidation alert
New rideA new route. Your own pace.
Ride name (optional)Coorg loop
StartGuntur, Andhra Pradesh
DestinationMadikeri, Karnataka
Estimate (km)4200
Departure · IST05:15
Enter an estimated distance from 1–3,000 km.
Create ride
savedactive + deletable rides
Your rides3 saved on this phone
Guntur → BangaloreGuntur → Bangalore5 stops · 4:30 AM
Coorg loopGuntur → Madikeri0 stops · Departure not set
Araku monsoon runVisakhapatnam → Araku3 stops · 6:00 AM
New ride
saved · not hydratedfooter disabled
Your rides0 saved on this phone
New ride

RidePreparation · sheets

src/components/ride-preparation.tsx

One sheet state variable — 'departure' | 'checks' | null — swaps both the title and the whole body, and only the departure branch gets a footer. The ten checks come from PRE_RIDE_CHECKS in two groups, Before ride day and Before departure, each row a checkbox whose detail is its accessibility hint. Copy is careful to frame the list as a record rather than a mechanical diagnosis, and links out to the bike manual and the MSF T-CLOCS guide.

sheet="departure"presets · Save time footer
Departure
Start time (IST · 24-hour)05:00
04:0004:3005:0006:00
India · IST (UTC+5:30). Estimates, not an alarm. Stop times include planned breaks; traffic and extra delays can change arrival. 4–5 AM may mean riding in darkness. Choose a time that allows enough sleep and good visibility; fuel up beforehand. Changing departure time resets your checklist for the new plan. Clear time
Save time
sheet="checks"checked · unchecked rows
Before you ride
2/10 checked for this plan. Reset before each new ride.
Before ride day
Petrol readyRefuel beforehand; choose a first fuel stop and confirm opening hours.
Tyres & wheelsCheck cold pressure for your load, tread, damage and wheel condition.
Brakes & padsCheck braking action, pad wear and fluid level. Have worn parts or faults repaired.
Before departure
Lights & controlsCheck headlight, brake light, indicators, horn, mirrors, throttle, clutch and side stand.
Rested & readyStart after enough sleep. Delay departure if tired or visibility is poor.
Reset checks Bike manual

EndpointPicker

src/components/endpoint-picker.tsx

title 'Start' | 'Destination'  ·  label string  ·  initial EndpointPin?  ·  onClose ()  ·  onSelect (label, pin)

A full-screen map modal rather than a sheet, with floating cards top and bottom. Search is debounced at 800 ms and needs three characters; the top four hits are reverse-geocoded for a readable address, and the crosshair swaps to a spinner while a search runs. Everything degrades to “tap the map to place a pin”: a failed search, a denied location permission and a basemap that will not load all land in the same error card, and the confirm button stays disabled until a pin exists. A map-tapped pin is saved as Pin 16.3067, 80.4365 rather than the placeholder “Map pin”.

no pinsearch idle · confirm disabled
Search start
Tap the map to place a pinUse as start
© OpenStreetMap contributors
searching + resultsspinner replaces crosshair
kurnool
Kurnool, Andhra Pradesh15.8281, 78.0373 · tap to preview
Kurnool Bus Station15.8300, 78.0450 · tap to preview
pin selectedconfirm enabled · coords shown
Search destination
B
Madikeri, Karnataka12.42381, 75.73993 · tap map to adjustUse as destination
errorsearch, permission or basemap
xyzzy
No matches. Try a nearby town, or tap the map.
Allow location access, or tap a pin on the map.

RideOverview · summary sheet

src/components/ride-overview.tsx

Behind the two progress bars of Part 03. Arrival is wall-clock IST rather than a countdown, and it is withheld entirely while paused — a paused ride has no meaningful arrival time. Remaining time takes the larger of the proportional route time and the planning-pace time, then adds every future planned break, so it can only ever be the more conservative of the two. Elevation is a GPS estimate kept for this ride only: never persisted, never simulated, and blanked the moment a reading goes stale.

live ridefresh altitude · next stop known
Ride summaryBangalore
412 km left · ~6h 20m Estimated arrival · 5:13 PM IST Approximate route time and planning pace, including 7 remaining planned breaks. No live traffic. Extra stops and delays can change arrival. Next: Planned fuel area · 38 km
Elevation · metres486 mLow 12 · High 604GPS estimate · valid readings from this ride only. Unavailable or inaccurate readings are ignored.
Route & stopsEnd ride
pausedarrival and altitude withheld
Ride summaryBangalore
412 km left · ~6h 20m Estimated arrival · — Approximate route time and planning pace, including 7 remaining planned breaks. No live traffic. Extra stops and delays can change arrival.
Elevation · metres—Low 12 · High 604GPS estimate · valid readings from this ride only. Unavailable or inaccurate readings are ignored.
Route & stopsEnd ride
off routenothing projectable
Ride summaryBangalore
Position unavailable · — Estimated arrival · — Approximate route time and planning pace, including 0 remaining planned breaks. No live traffic. Extra stops and delays can change arrival.
Elevation · metres—Low — · High —GPS estimate · valid readings from this ride only. Unavailable or inaccurate readings are ignored.
Route & stopsEnd ride
demoelevation not simulated · End demo
Ride summaryBangalore
228 km left · ~3h 40m Estimated arrival · 2:48 PM IST Demo preview. Approximate route time and planning pace, including 4 remaining planned breaks. No live traffic. Extra stops and delays can change arrival. Next: Planned rest · 12 km
Elevation · metres—Low — · High —Elevation is not simulated.
Route & stopsEnd demo

PastRideSheet

src/components/past-ride-sheet.tsx

origin destination string  ·  onClose ()  ·  onSaved ()

A ride done without GPS, typed from the odometer or memory. Mounted only while open, so the form starts clean every time. Validation is strict and the copy says the bound rather than “invalid”: the date must be a real past IST calendar day, distance 0.1–3,000 km, riding time 0.05–48 hours. Saved rides are placed at 06:00 IST on that day and carry manual: true, which is what keeps them out of the heatmap and lets Rides filter them apart.

prefilledendpoints from the active plan
Log a past rideFrom your odometer or memory
FromGuntur
ToBangalore
Date · YYYY-MM-DD2026-09-20
Distance · km0
Riding time · hourse.g. 11.5
Save ride
errorbound named, not “invalid”
Log a past rideFrom your odometer or memory
Distance · km4200
Riding time · hours11.5
Enter distance between 0.1 and 3,000 km.
Save ride

SensorWidgets · Phone sensors

src/components/sensor-widgets.tsx · src/lib/device-sensors.ts

Home tiles that only exist if the phone has the sensor behind them. A scan probes all seven through expo-sensors, and profileFromScan turns every supported widget on for a first scan while a rescan keeps the rider's choices where still supported. Readings run only while Home is focused, to spare the battery. On a phone without a magnetometer or barometer — the common budget case — two of the three tiles never appear at all, and the sheet explains why per row rather than hiding the row.

tilesall three sensors present
Heading142° sEPhone heading
Ambient light18,400 lxBright sun
Altitude~486 mFrom air pressure
Phone sensors · 5 of 7 found
tilesunread · permission denied
HeadingAllowTap to allow location
Ambient light—Reading…
Scanning phone sensors…
sheetwidget toggles · scan result
Phone sensorsScanned 24 Sep 2026
Home widgets
HeadingUses compass (magnetometer)
Ambient lightUses light sensor
AltitudeNeeds a barometer · not on this phone
Found on this phone
GPS · location on
Accelerometer
Gyroscope
Barometer
Android reports some sensors that are simulated from others (for example a virtual gyroscope). Readings are for reference, not safety decisions.
Scan again
Show ridesfilter radio sheet · Rides tab
Show rides
All rides
This month
This year
Recorded by GPS
Logged by hand
VehicleEditormode="fuel" · Trip F branch
Add fuel fill
Odometer · km20,412
Petrol · litres9.2
Total cost · ₹960
Filled to full tank
Trip F on the dash · km (optional)Only if the low-fuel warning was on
Hunter 350: Trip F counts km since the low-fuel warning (4 L left). With it, a partial fill still gives fuel left and range.
Save
VehicleEditormode="health" · six modes, one shell
Service reminders
2022 Hunter 350: 500 km / 45 days, then 5,000 km / 6-month milestones from purchase, whichever comes first.
Purchase date · YYYY-MM-DD2022-11-04
Last completed milestone · km (0 if none)15000
Workshop override · optional
Next service · km20000
Service reminders describe scheduled maintenance, not mechanical condition. Severe or dusty riding can require earlier maintenance.
Save

CityRangeSheet

src/components/city-range-sheet.tsx

visible range SpeedRange  ·  onClose ()  ·  onSaved (range)

Edits the band the City Ride colours by, from Profile. It re-seeds its draft from the saved range each time it opens, so a cancelled edit never leaks into the next one. The copy explains where the default came from — under 25 km/h is a traffic crawl, 50 km/h is a common city limit for two-wheelers in central Bangalore — and then says plainly that it is a preference, not the road limit.

default range25–50
City speed rangeBelow is low, inside is balanced, above is high
Balanced from (km/h)25
Balanced to (km/h)50
Default 25–50: under 25 is a traffic crawl, and 50 km/h is a common city limit for two-wheelers (for example, central Bangalore). A preference, not the road limit.
Save
errorneeds both bounds
City speed rangeBelow is low, inside is balanced, above is high
Balanced from (km/h)60
Balanced to (km/h)0
Enter a lower and a higher speed, e.g. 25 and 50.
Save
06

Screens

Four tabs assemble the parts above. Planning screens carry the wordmark and scroll under a floating dock; the riding map hides both so nothing competes with guidance.

Tab dock

src/app/(tabs)/_layout.tsx · src/hooks/use-floating-navigation.ts

Three tabs now — Home, Map and Ride — under a hand-written FloatingDock rather than the stock tab bar; the trip planner moved out to its own stack screen. A floating pill on dock, dark in both schemes, with inactive glyphs in dockIcon. One primary pill slides to the active tab over 250 ms, so position is shown by a shape and not by colour alone; the dock draws the light icons and the pill carries a dark copy clipped to itself, moving the opposite way, so each glyph keeps its contrast mid-slide. The Ride tab shows the hunter glyph when the rider's bike is a Hunter and the generic motorbike otherwise. In landscape the dock stands on the left edge and the pill slides vertically. It hides entirely while riding and whenever the keyboard is open. Inactive tabs stay attached so the map's GL surface is never torn down and rebuilt — that used to flash black on every visit.

indexHome activefixed
mapMap activefixed
ridesRide activefixed

Home

src/app/(tabs)/index.tsx

The vehicle is the hero: a static Hunter 350 illustration under a serif heading, explicitly captioned “Vehicle illustration” — nothing here is live telemetry. Everything below it is saved plan state. The distance line names its own provenance, “road route” or “planning estimate”, so an estimate is never mistaken for a measured one. Portrait stacks; landscape puts the vehicle and the planning panel side by side at half width each.

hydratedsaved plan present
YYamDrive
Guntur → Bangalore3 rides
YOUR MOTORCYCLERoyal Enfield
Hunter 350
2022 · 20,000 km reportedhunter-350.webp
static illustration
Vehicle illustration
Departure4:30 AMIndia · IST
Ride checks7 / 10Checked for this plan
YOUR NEXT RIDEGuntur → Bangalore598 km · road routeOpen map
hydrated=false“Loading…” · actions disabled
YYamDrive
DepartureLoading…India · IST
Ride checksLoading…Checked for this plan
YOUR NEXT RIDEGuntur → BangaloreLoading trip…Open map
storageErrorrole="alert" · persistence disabled
YYamDrive
Saved rides could not be loaded. Your stored rides are unchanged; changes will not be saved this session.
Guntur → Bangalore0 rides

Map · guidance bar

src/app/(tabs)/map.tsx

The idle map shows RouteSummary; the moment a ride starts it is replaced by this bar. guidanceTitle resolves through a fixed order of six conditions — no route, no fix, off route, no stored turn steps, arriving, then the turn itself — so the most fundamental problem always wins. Turn glyph at 48 portrait / 40 landscape in theme.navigation; the trailing chevron opens Route & stops.

turn aheadturn.distanceKm ≥ 0.025
In 450 mTurn right onto NH-44
turn nowdistance < 0.025 km
NowTurn left onto Kurnool Bypass
no routeroute === null · first check
Build a route to navigateOpen route tools to update
no fixnavPoint === null · crosshair
Waiting for GPSGuidance needs a fresh location fix
off routeprojection > 0.075 km · warning
Off routeReturn to the route · automatic rerouting is not enabled
no stepsroute built before turn data
Update route for turn guidanceOpen route tools to update
arriving< 0.025 km of geometry left
Arriving at destinationYou have arrived at Bangalore
demo arriveddemoKm ≥ route.distanceKm
Arriving at destinationRoute preview complete
on route, no turnfallback
Follow the routeContinue on NH-44

Map · transient overlays and controls

src/app/(tabs)/map.tsx

The bottom cluster changes shape three times: idle carries Start ride, an active ride swaps it for the ride-view toggle, and ride view drops everything but re-centre and exit. Idle now offers two named rides rather than one Start ride: Long Ride, which needs a planned trip and opens its summary sheet, and City Ride, which needs no route at all and is the yellow one because it is the everyday ride. Each explains itself through hint on long press rather than copy on the map. Attribution shows for five seconds on focus and then translates away, taking the controls down with it — disabled under reduced motion, and the credit stays reachable through Route & stops → Map credits.

busyrouting or searching
noticetap to dismiss
Trip changed. Build your route again.
Stop saved. Update your route to include it.
Map unavailable. Check your connection.
session.errorGPS · fixed position under the bar
Allow location access to show live speed.
break target selectedtarget !== null
Fuel target 3 · 150 km from startSuggested area · confirm a safe stop
Find stops
controls · idletwo named rides, one tap each
Long Ride · Bangalore City Ride
OpenFreeMap © OpenMapTiles · © OpenStreetMap contributors
controls · no trip yetLong Ride becomes Plan Long Ride
Plan Long Ride City Ride
controls · activemeter + ride-view toggle
412 km left
~6h 20m left
52 km/h
controls · ride viewdock hidden · re-centre + exit
412 km left
~6h 20m left
34 km/h
controls · demoExit demo pill
Exit demo
71 km/h

Map · Route & stops sheet

src/app/(tabs)/map.tsx

The map’s single command surface. The build action relabels itself Update route once one exists, and the copy underneath names why a route may be stale — “Trip changed. Update your route.” The five nearby categories exclude other, and the search origin follows the selected break rather than the map centre when a break pin is open.

route built · break selectedhelp collapsed
Route & stopsSearch near break 3
Demo ride Preview simulated speed and route movement. Real GPS is off during demo. Guntur → Bangalore 598 km · 9h 58m estimated · no live traffic
Update route
Town or address
Near break 3 · 5 km radius
FuelFoodBreakStaySight
Saved stops · 5
Map help
Map credits Report a map issue
no route · ridingdemo disabled · help expanded
Route & stopsRoute, breaks and nearby places
Demo ride End ride Preview simulated speed and route movement. Build a route first. Guntur → Bangalore Build a route to see roads and breaks.
Build route
Map help
Hold an icon for its label. R = rest · F = fuel · S = saved. Tap a break pin to find stops nearby. Exit focus, then hold the map to add a stop. Start a ride for live GPS speed. Pause freezes GPS and the timer. Turn banners work while the map is open; no voice or automatic rerouting.

SavedStops

src/components/saved-stops.tsx

Was the Trip tab's own screen; it is now a component embedded in the Trip tab, which freed the fourth tab slot for Rides. A numbered visit order on a connected rail, one stop expanded at a time. Reorder arrows disable at the ends rather than disappearing, so the row keeps its shape. Both destructive paths — one stop, all stops — go through a native confirm, and the options sheet warns that Google Maps takes only nine waypoints before offering to open the first ones anyway.

stops.length === 0empty state
YYamDrive
TripGuntur → Bangalore
Guntur → Bangalore0 saved stops
No saved stopsChoose places near break pins, or hold the map to add a stop.Find stops
listroute built · one row expanded
Guntur → Bangalore3 saved stops · 598 km9h 58m estimated · excludes breaks and traffic
View map Visit order · tap a stop for details
1
Indian Oil, Kurnool
Fuel
2
Hotel Bheema, Anantapur
Food
NH-44, Anantapur, Andhra Pradesh Breakfast stop — opens at 6 AM
3
Nandi Hills viewpoint
Sight
row endsfirst: up disabled · last: down disabled
Trip optionswith stops · route available
Trip options
Trip nameGuntur → Bangalore
Save name Open saved stops in Google Maps Saved stops only; trip endpoints are not included. Clear stops

Trip tab

src/app/(tabs)/plan.tsx

The whole screen is a single guard: until storage hydrates it renders nothing but “Loading ride plan…”, so the editor never mounts over defaults it would then overwrite. Metrics compact their durations (9 h 58 min → 9h 58m) and wrap as groups on narrow screens. Every estimate labels its own source — road distance or manual distance — and the notes are explicit that pace is a planning figure, not a speed recommendation, and that breaks measure neither engine temperature nor fuel level.

hydrated=falsethe whole screen
Loading ride plan…
route builtschedule collapsed
Ride planHunter 350 · 2022 · 20,000 km
Guntur → Bangalore
Distance598 km
Riding9h 58m
With breaks12h 43m
Estimated · road distance · no live traffic
15 min rest every 60 min
Refuel every 150 km
View map
Break schedule · 11
Planning notes
schedule expandedfuel · rest · fuel + rest
Break schedule · 11
Estimated times · IST · includes breaks
1. Rest · 60 km5:30 AM · 15 min break
2. Rest · 120 km6:45 AM · 15 min break
3. Fuel · 150 km7:30 AM · 15 min break
6. Fuel + rest · 300 km10:45 AM · 15 min break
Arrive · 5:13 PM
no fuel intervalfuelEveryKm === null
15 min rest every 60 min
Fuel interval not set
no breaksschedule.stops.length === 0
Break schedule · 0
No breaks planned before arrival.Arrive · 45 min from start
Ride settingsendpoints · speed colours · fields
Ride settings
Start
GunturPin selected · change
Destination
BangaloreSearch or choose a pin
Speed colours
Lower (km/h)40
Upper (km/h)65
Yellow below · green within · red above. Your preference, not a road limit or engine-safety rating. Yellow is not a prompt to speed up.
Distance estimate (km)600
Planning pace (km/h)60
Fuel interval (km, optional)150
Planning pace (km/h): enter a number from 10 to 120.
Save

Rides

src/app/(tabs)/rides.tsx

The ride log, grouped by IST week with “This week” and “Last week” named rather than dated. Totals use hero for distance and exclude demo rides, which are still listed but never counted. Each row pairs a RideThumbnail with a title derived from the hour of day — Morning, Afternoon, Evening or Night ride — for a GPS ride, or the endpoints for one logged by hand. Four empty-ish states, and the last two say different things on purpose.

listgrouped by week
RidesAll rides · India time (IST)
Heatmap
Total distance
4,182 km
Riding time
96:14:02
Rides
23
Log a past rideAll rides
This week612 km
Morning ride
20 Sep 2026 at 05:12
9:58:20598 km
Guntur → Vijayawada
18 Sep 2026 · logged by hand
1:40:0078 km
empty vs filtereddifferent copy on purpose
No rides yetStart a ride on the map and tap End ride when you arrive. The route, distance, time and elevation are saved here.
No rides match this filterStart a ride on the map and tap End ride when you arrive. The route, distance, time and elevation are saved here.
Loading rides… Could not load rides.

Ride detail · Heatmap

src/app/ride/[id].tsx · src/app/heatmap.tsx

A saved ride replays its own path: a city ride draws in speed-band runs with event pins, a touring ride draws one line in route. The time split and the five event counts come straight off the stored CitySummary, so the detail never recomputes what the ride already decided. The heatmap overlays every recorded path — a wide blurred heatGlow under a narrower heat line, both semi-transparent, so repeated roads darken where they overlap. Manual and demo rides are excluded, because neither has a route.

ride detailcity ride · where the time went
City ride · Bangalore
Where the time went
Moving 0:41:18Traffic 0:17:02Stops 0:07:06Breaks 0:05:40
9 traffic waits · 2 stops · 1 break · 3 hard accelerations · 5 hard braking
TrafficStopBreakHard accelerationHard braking
Loading ride…
heatmapglow under line · both translucentfixed
Heatmap
18 recorded rides · darker = ridden more
© OpenStreetMap contributors
heatmapnothing to draw
No recorded routes yet Routes are saved when you ride with the map and tap End ride. Rides logged by hand have no route.

Vehicle health · Profile

src/app/(tabs)/index.tsx · src/app/vehicle.tsx · src/app/profile.tsx

The health tile is the only place the health* grounds are used, and they are the only tokens below the surface palette that invert between schemes. serviceStatus resolves to four outcomes — null until a schedule exists, then good, soon (within 30 days or 500 km) or due — and the ground, the metric colour and the caption all change together.

healthnull · no schedule set
Vehicle healthSet upService reminders
healthgood · healthGood
Vehicle health2,140 kmOn track · by 2027-05-04
healthsoon · healthSoon
Vehicle health410 kmDue soon · by 2026-11-04
healthdue · healthDue
Vehicle healthService dueBook a service
fuel metricsmeasured vs not yet
31.4km/L · Last tank
₹3.24per km · Fuel cost
—km/L · Last tank
—per km · Fuel cost
Add two full-tank fills to calculate mileage. Include every partial fill between them.
profile rowschevron-right disclosure
Your bike
Royal Enfield Hunter 3502022 · 20,412 km
Account & sync Signed in · last synced 6:12 pm
Sync nowSign out

PasscodeScreen · SystemBarFills

src/components/passcode-screen.tsx · system-bar-fills.tsx

The passcode gate stands in front of the whole app until sync signs in. Six boxes render the digits as dots over a hidden 1 × 1 number field, so the system keyboard does the typing and the code itself never shows; the box under the caret takes tint, and an error turns every border danger at once rather than implying which digit was wrong. SystemBarFills is invisible by design — two solid strips the height of the top and bottom insets, drawn because Android renders the app edge to edge with transparent system bars and scrolled content would otherwise run under them. The dashed outlines below are this canvas's, not the app's.

passcode3 of 6 · caret on the next box
YamDriveEnter your passcode to back up and sync your bike, fuel and rides. You stay signed in on this phone.
Signing in…
passcoderejected · every border danger
YamDriveEnter your passcode to back up and sync your bike, fuel and rides. You stay signed in on this phone.
Could not sign in.
SystemBarFillsthe two strips, outlined here only
Scrolled content
would otherwise run under the transparent system bars

Widgets · TabReveal

src/app/widgets.tsx · src/components/tab-reveal.tsx

The sensor widgets moved off Home onto their own page, reached from Profile, and sensor-widgets.tsx split into SensorTiles, MissingWidgets and SensorList so the page can show supported and unsupported separately rather than silently hiding what the phone lacks. TabReveal is the other invisible one: tabs swap instantly and it lifts a background veil off the tab that just arrived over 220 ms, because animating a whole screen's opacity makes Android composite the page — map texture and all — off-screen and stall the first frames. Hidden tabs stay mounted but fully transparent.

Widgetssupported, then not on this phone
WidgetsLive from this phone's sensors · for reference
Ambient light18,400 lxBright sun
Heading142° sEPhone heading
Not on this phone
AltitudeNeeds a barometer · not on this phone
Phone sensorsScanned 24 Sep 2026
GPS · location on
Barometer
Scan again
Widgetsnever scanned · none supported
Scanning this phone… This phone has not been scanned yet.
This phone has none of the sensors these widgets need.
07

State machines

The states above are not free-floating — most of them are produced by four small pieces of logic. These are the rules that decide which specimen renders.

Ride clock

src/hooks/use-ride-session.ts · src/lib/ride-speed.ts

Three states and one derived flag. Pausing banks elapsedMs and clears startedAt, so elapsed time is always elapsedMs + (now − startedAt) and a pause loses nothing. Pause is also involuntary: leaving the foreground, blurring the Map tab, a GPS subscription error, or a demo reaching the destination all call it.

RideClock · status
StateClockGPSMeter readsLeaves via
idleelapsedMs 0, startedAt nullNot watching0 km/h · “Start”start(), startDemo()
activestartedAt set, ticks every 1 swatchPositionAsync at high accuracy, 1 sLive speed, or — until a fix passes the gatespause(), end(), background, tab blur, GPS error, demo arrival
pausedBanked elapsedMs, startedAt nullSubscription removed, fix cleared0 km/h · “Paused”start() resumes, end() resets
demoActive clock, playback ×100Never requested — simulated onlyCycles the four demo speedsend(), or auto-pause at the destination

Speed band

src/lib/ride-speed.ts

The band is a user preference — a comfort range the rider sets in Ride Plan — and explicitly not a road limit or an engine-safety rating. Below the range is yellow, which is why the copy in Ride settings says outright that yellow is not a prompt to speed up. With no range saved, Start ride routes to Ride Plan first rather than starting uncoloured.

speedBand(speed, range) → fill
ConditionBandFillAccessible word
speed === null || !rangeunknownbackgroundElementGPS…
speed < range.minbelow#F4C430 on #422006Below
min ≤ speed ≤ maxwithin#367923 on #fffIn range
speed > range.maxabove#B62E28 on #fffAbove
status !== 'active'not colouredbackgroundElementPaused · Start

Data gates

ride-speed · ride-overview · navigation-guidance · route-geometry

Every unavailable state on this canvas traces to one of these thresholds. They are the reason a stale or imprecise fix produces an em dash rather than a plausible-looking number.

Thresholds that turn data into an unavailable state
GateLimitRejected value shows as
Fix age≤ 8 000 ms, and no more than 2 000 ms in the futureSpeed — · “Waiting for GPS”
Horizontal accuracy0 – 30 mSpeed — · position not drawn
Reported speedFinite and ≥ 0; converted ×3.6 to km/hSpeed —
Heading0 – 360, and speed > 3 km/hCompass “—”, marker falls back to a dot
Off routeProjection > 0.075 km (75 m)“Off route” · distance, time and road label suppressed
Saved stop on routeWithin 0.075 km of the lineStop excluded from “Next”
Next stop lookaheadkm > travelled + 0.025 kmStop treated as passed
Arrival< 0.025 km of geometry remaining“Arriving at destination”
Altitude accuracy0 – 30 m, fresh within 8 000 msElevation —; session extrema kept
Elevation displayReading < 8 s old, not demo, not pausedElevation —
Route freshnessroadRoute.signature must equal the current origin, destination, pins and stops“Trip changed. Update your route.”

Validation ranges

src/lib/ride-plan.ts · ride-preparation.ts

Stored plans are re-parsed on every load rather than trusted, so a hand-edited or corrupted value falls back to its default instead of propagating. The same bounds drive the inline form errors shown in Parts 02 and 05.

parseRidePlan · form validation
FieldAcceptedDefaultError copy
distanceKm1 – 3 000600“Enter an estimated distance from 1–3,000 km.”
averageKph10 – 12060“Planning pace (km/h): enter a number from 10 to 120.”
breakEveryMinutes15 – 18060“Rest every (min): enter a number from 15 to 180.”
breakMinutes5 – 12015“Break length (min): enter a number from 5 to 120.”
fuelEveryKm20 – 500, or nullnull“Fuel interval (km, optional): enter a number from 20 to 500.”
speedRange0 – 200, max > min, both or neitherunset“Enter both speed bounds from 0–200, with upper greater than lower.”
departureTime/^([01]\d|2[0-3]):[0-5]\d$/unset“Use 24-hour time, for example 05:00 or 16:30.”
origin · destinationNon-empty, trimmed to 200 charsGuntur · Bangalore“Choose a start and destination.”
latitude · longitude|lat| ≤ 90, |lon| ≤ 180, finitepin dropped—
ride name≤ 100 chars; blank becomes “origin → destination”derived—
preRideChecksFiltered against PRE_RIDE_CHECKS ids[]—