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.tsForty-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.
| Token | Light | Night | Role | ||
|---|---|---|---|---|---|
| text | #242523 | #F5F5F1 | Primary copy | ||
| background | #F5F5F1 | #141715 | Screen canvas, sheet body | ||
| backgroundElement | #EAEAE5 | #30342F | Secondary button, input, chip | ||
| backgroundSelected | #DEDFD8 | #3C4039 | Pressed row, active saved ride | ||
| textSecondary | #636760 | #B2B7AD | Captions, hints, placeholders | ||
| tint | #665000 | #F4C430 | Icon / link accent | ||
| onTint | #242523 | #242523 | Copy on the yellow fill | ||
| border | #DCDDD6 | #41453E | Hairlines, dividers, sheet handle | ||
| primary | #F4C430 | #F4C430 | Primary action fill | ||
| surface | #FFFFFF | #252825 | Raised card | ||
| navigation | #246BAC | #70B4EF | Turn glyph in the guidance bar | ||
| rest | #E3DCEB | #3B3345 | Break / checklist tile | ||
| fuel | #F0DFC4 | #443B2A | Refuel tile | ||
| positive | #367923 | #86CD65 | Checklist progress | ||
| departure | #E8C2A6 | #E8C2A6 | Departure card ground | ||
| onFeature | #242523 | #242523 | Copy on departure / tiles | ||
| danger | #C22C24 | #F87171 | Destructive action, form error | ||
| healthGood | #DCEBD3 | #28381F | Vehicle health tile · on track | ||
| healthSoon | #F3E3B5 | #43391D | Due within 30 days or 500 km | ||
| healthDue | #F4D3CF | #4A2523 | Service due | ||
| route | #368DDD | #368DDD | Route line, ride thumbnail | ||
| heat | #5B21B6 | #5B21B6 | Heatmap line | ||
| heatGlow | #8B5CF6 | #8B5CF6 | Heatmap glow, blurred under it | ||
| speedLow | #D9A52C | #D9A52C | Speed band · below range | ||
| onSpeedLow | #3A2A05 | #3A2A05 | Copy on the low band | ||
| speedBalanced | #3F7A50 | #3F7A50 | Speed band · within range | ||
| speedHigh | #B4502F | #B4502F | Speed band · above range | ||
| onSpeed | #FFFFFF | #FFFFFF | Copy on the green and red bands | ||
| eventTraffic | #D98B52 | #D98B52 | City event · traffic wait | ||
| eventStop | #6B6F68 | #6B6F68 | City event · stop | ||
| eventBreak | #8A72B8 | #8A72B8 | City event · break | ||
| eventAccel | #368DDD | #368DDD | City event · hard acceleration | ||
| eventBrake | #8C3A24 | #8C3A24 | City event · hard braking | ||
| fuelLevel | #E0A12E | #E8B04A | Energy tile · the fuel in the tank | ||
| fuelBody | #FBF4E8 | #2E2819 | Energy tile · the tank body | ||
| fuelOutline | #5C4424 | #F0DFC4 | Energy tile · the tank outline | ||
| reliefWall | #CFE2F4 | #34506B | Route relief · the wall under the ribbon | ||
| reliefSteep | #1F5E99 | #8CC4F2 | Route relief · the colour at a 10 % grade | ||
| toggleThumb | #FFFFFF | #FFFFFF | Toggle · the thumb, white on either track | ||
| dock | #202220 | #202220 | Floating dock ground | ||
| dockIcon | #D3D5D1 | #D3D5D1 | Floating dock · inactive icon | ||
| scrim | 11 18 21 / 45% | 0 0 0 / 60% | Dim behind a modal sheet | ||
Stop categories
src/lib/categories.tsSix 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.
| Key | Colour | Label | Icon | nearbyQuery | Nearby search |
|---|---|---|---|---|---|
| fuel | #EA580C | Fuel | gas-station | petrol pump | Yes |
| food | #DC2626 | Food | silverware-fork-knife | restaurant | Yes |
| rest | #0891B2 | Break | coffee-outline | rest stop cafe | Yes |
| stay | #7C3AED | Stay | bed-outline | hotel | Yes |
| sight | #059669 | Sight | camera-outline | tourist attraction | Yes |
| other | #475569 | Other | map-marker-outline | point of interest | No |
Literal colours outside the theme
ride-widget · map-markers · map.tsx · use-floating-navigationMost 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.
| Use | Fill | Foreground | Where |
|---|---|---|---|
| origin pin “A” | #166534 | #fff | MapPin |
| destination pin “B” | #991b1b | #fff | MapPin |
| break · rest | #0369a1 | #fff | MapPin Rn |
| break · fuel | #c2410c | #fff | MapPin Fn |
| break · selected | #6d28d9 | #fff | MapPin, target set |
| search result | #475569 | #fff | MapPin +n |
| your position | #2563eb | #fff ring | NavigationMarker |
| planned fuel badge | #E9C2A8 | #242523 | PetrolMarker planned |
| road label | #1769AA | #fff | “Toward …” |
| compass north / travel | #F4C430 #367923 | #242523 label | MapCompass |
| tab dock | #202220 | #F4C430 / #D3D5D1 | useFloatingNavigation |
| progress bars | #D5DDBB #E3D9EC | on #F5F5F1 | RideOverview |
ThemedText · 12 roles
src/components/themed-text.tsxtype 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.
Spacing, radius, metrics
src/constants/theme.tsPlanning 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.
| Scale | Key | dp | Applied to |
|---|---|---|---|
| Spacing | half / one / two | 2 / 4 / 8 | Icon gaps, label stacks, chip rows |
| three / four | 16 / 24 | Card padding, screen gutters, group gap | |
| five / six | 32 / 64 | Empty-state padding, tall spacers | |
| Radius | sm / md | 8 / 16 | Handle, toast · button, input, flat card |
| lg / xl | 24 / 28 | Screen card, route summary · sheet top, dock | |
| pill | 999 | Chip, icon button, speed meter, marker | |
| Layout | MinTouchSize | 48 | Every shared control |
| BottomTabInset | 80 Android · 50 iOS | Material tab bar is the taller one | |
| MaxContentWidth | 800 | Reading measure cap | |
| dock clearance | inset + 84 | Scroll padding under the floating dock |
Icon set
src/components/icon.tsxname 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-outlinehomeclockchecklistcheck-squaresquareturn-leftturn-rightturn-uturnturn-roundaboutturn-arriveplaypausestopmap-outlinemotorbikeformat-list-numberedmap-marker-pathrouteschevron-upchevron-downchevron-rightcrosshairs-gpsfit-to-screen-outlinearrow-expandarrow-collapsetune-variantclosepencil-outlinemagnifyarrow-leftarrow-uparrow-downnavigation-variantnavigation-variant-outlinealias · NavigationArrowtrash-can-outlinedelete-outlinealias · Trashalert-circle-outlineplus-circlecheckplusaccountcodetrafficcity event · traffichandcity event · stoptrend-upcity event · acceltrend-downcity event · brakedistancecompasssensor widgetsunsensor widgetmountainssensor widgetcpuscanhunterthe rider's own bikeheartchevron-leftfireheatmaplong-ridecity-ridegas-stationsilverware-fork-knifecoffee-outlinebed-outlinecamera-outlinemap-marker-outlineBasemap
src/lib/map-style.tsBasemapId 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.
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.tsxlabel 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.
IconButton
src/components/icon-button.tsxname 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.
TextField
src/components/text-field.tsxlabel 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.
CategoryPicker
src/components/category-picker.tsxvalue 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.
Brand · ThemedView
src/components/brand.tsx · themed-view.tsxBrand 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'].
Sheet
src/components/sheet.tsxvisible 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.
Toggle · PageHeader
src/components/toggle.tsx · page-header.tsxTwo 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.
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.tsxsession 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.
MapCompass
src/components/map-compass.tsxheading 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.
RideOverview · progress bars
src/components/ride-overview.tsxtotalKm 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.
RidePreparation · cards
src/components/ride-preparation.tsxplan 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.
ElevationChart
src/components/elevation-chart.tsxpoints 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.
RideThumbnail
src/components/ride-thumbnail.tsxpath 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.
City dashboard
src/components/city-dashboard.tsx · src/lib/city-ride.tsspeed 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.
TankGauge · RangeRing · BikeAvatar
src/components/tank-gauge.tsx · range-ring.tsx · bike-avatar.tsxThree 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.
RouteRelief · RecentRides
src/components/route-relief.tsx · recent-rides.tsxpath 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.
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.tsxid 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.
NavigationMarker
src/components/map-markers.tsxpoint 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.
PetrolMarker
src/components/map-markers.tsxid 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.
Buildings3D
src/components/buildings-3d.tsxOSM 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.
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.tsxdraft 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.
ResultsSheet
src/components/results-sheet.tsxvisible 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.
RideLibrarySheet
src/components/ride-library-sheet.tsxmode '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.
RidePreparation · sheets
src/components/ride-preparation.tsxOne 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.
EndpointPicker
src/components/endpoint-picker.tsxtitle '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”.
RideOverview · summary sheet
src/components/ride-overview.tsxBehind 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.
PastRideSheet
src/components/past-ride-sheet.tsxorigin 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.
SensorWidgets · Phone sensors
src/components/sensor-widgets.tsx · src/lib/device-sensors.tsHome 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.
CityRangeSheet
src/components/city-range-sheet.tsxvisible 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.
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.tsThree 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.
Home
src/app/(tabs)/index.tsxThe 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.
Hunter 3502022 · 20,000 km reportedhunter-350.webp
static illustrationVehicle illustration
Map · guidance bar
src/app/(tabs)/map.tsxThe 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.
Map · transient overlays and controls
src/app/(tabs)/map.tsxThe 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.
Map · Route & stops sheet
src/app/(tabs)/map.tsxThe 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.
SavedStops
src/components/saved-stops.tsxWas 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.
Trip tab
src/app/(tabs)/plan.tsxThe 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.
Rides
src/app/(tabs)/rides.tsxThe 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.
Ride detail · Heatmap
src/app/ride/[id].tsx · src/app/heatmap.tsxA 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.
Vehicle health · Profile
src/app/(tabs)/index.tsx · src/app/vehicle.tsx · src/app/profile.tsxThe 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.
PasscodeScreen · SystemBarFills
src/components/passcode-screen.tsx · system-bar-fills.tsxThe 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.
Widgets · TabReveal
src/app/widgets.tsx · src/components/tab-reveal.tsxThe 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.
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.tsThree 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.
| State | Clock | GPS | Meter reads | Leaves via |
|---|---|---|---|---|
| idle | elapsedMs 0, startedAt null | Not watching | 0 km/h · “Start” | start(), startDemo() |
| active | startedAt set, ticks every 1 s | watchPositionAsync at high accuracy, 1 s | Live speed, or — until a fix passes the gates | pause(), end(), background, tab blur, GPS error, demo arrival |
| paused | Banked elapsedMs, startedAt null | Subscription removed, fix cleared | 0 km/h · “Paused” | start() resumes, end() resets |
| demo | Active clock, playback ×100 | Never requested — simulated only | Cycles the four demo speeds | end(), or auto-pause at the destination |
Speed band
src/lib/ride-speed.tsThe 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.
| Condition | Band | Fill | Accessible word |
|---|---|---|---|
speed === null || !range | unknown | backgroundElement | GPS… |
speed < range.min | below | #F4C430 on #422006 | Below |
min ≤ speed ≤ max | within | #367923 on #fff | In range |
speed > range.max | above | #B62E28 on #fff | Above |
status !== 'active' | not coloured | backgroundElement | Paused · Start |
Data gates
ride-speed · ride-overview · navigation-guidance · route-geometryEvery 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.
| Gate | Limit | Rejected value shows as |
|---|---|---|
| Fix age | ≤ 8 000 ms, and no more than 2 000 ms in the future | Speed — · “Waiting for GPS” |
| Horizontal accuracy | 0 – 30 m | Speed — · position not drawn |
| Reported speed | Finite and ≥ 0; converted ×3.6 to km/h | Speed — |
| Heading | 0 – 360, and speed > 3 km/h | Compass “—”, marker falls back to a dot |
| Off route | Projection > 0.075 km (75 m) | “Off route” · distance, time and road label suppressed |
| Saved stop on route | Within 0.075 km of the line | Stop excluded from “Next” |
| Next stop lookahead | km > travelled + 0.025 km | Stop treated as passed |
| Arrival | < 0.025 km of geometry remaining | “Arriving at destination” |
| Altitude accuracy | 0 – 30 m, fresh within 8 000 ms | Elevation —; session extrema kept |
| Elevation display | Reading < 8 s old, not demo, not paused | Elevation — |
| Route freshness | roadRoute.signature must equal the current origin, destination, pins and stops | “Trip changed. Update your route.” |
Validation ranges
src/lib/ride-plan.ts · ride-preparation.tsStored 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.
| Field | Accepted | Default | Error copy |
|---|---|---|---|
| distanceKm | 1 – 3 000 | 600 | “Enter an estimated distance from 1–3,000 km.” |
| averageKph | 10 – 120 | 60 | “Planning pace (km/h): enter a number from 10 to 120.” |
| breakEveryMinutes | 15 – 180 | 60 | “Rest every (min): enter a number from 15 to 180.” |
| breakMinutes | 5 – 120 | 15 | “Break length (min): enter a number from 5 to 120.” |
| fuelEveryKm | 20 – 500, or null | null | “Fuel interval (km, optional): enter a number from 20 to 500.” |
| speedRange | 0 – 200, max > min, both or neither | unset | “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 · destination | Non-empty, trimmed to 200 chars | Guntur · Bangalore | “Choose a start and destination.” |
| latitude · longitude | |lat| ≤ 90, |lon| ≤ 180, finite | pin dropped | — |
| ride name | ≤ 100 chars; blank becomes “origin → destination” | derived | — |
| preRideChecks | Filtered against PRE_RIDE_CHECKS ids | [] | — |