A watt is a fact; a zone is what it costs you. The biggest number on the
ride screen was the same shade of white at 90 W and at 400 W, which is a
thing no training app has done in fifteen years.
Zones, with two rules:
- **No reference, no zone.** An unset FTP draws the plain number. A zone
measured against a guessed threshold would paint every ride with a
confident lie.
- **Colour never carries it alone.** "Z4" renders beside the swatch, so
the meaning survives a colour-blind rider, a phone in direct sun and a
black-and-white screenshot.
Read off the rolling average, not the instantaneous watts: at 4 Hz the
raw figure crosses two boundaries every pedal stroke, and a colour that
strobes is worse than no colour. Not pedalling is not zone 1.
Units are a display preference applied at the last step before the
glass. Everything computed, stored and recorded stays SI, so a FIT file
never depends on what the screen was set to. `format.ts` takes the unit
system as an argument rather than reading a module-level setting — pure
functions are what let every readout redraw the moment it changes. The
`km` helper is gone rather than left beside `dist`, so there is no
second way to format a distance that ignores the preference.
Rust's block labels lose their baked-in kilometres. The block already
carries start_x and end_x and the frontend renders that span in the
rider's units; a kilometre in the text sat inside a sentence saying
miles everywhere else.
Also on the ride screen:
- The gradient gets a wedge beside the number. A signed decimal has to
be read; a slope is seen. Exaggerated and clamped, because a true-scale
6% is indistinguishable from 3% at 40 px wide.
- What is coming, from the profile's own block list — "2.1 km at 12% in
460 m". The chart says where the rider is; what is about to happen is
what decides whether to shift now. The data was already computed
Rust-side and thrown away here. Close in, the small unit reads better
than a fraction of the big one.
- Mode and target merge into one chip. They are a single fact, and
splitting them spent a chip of header width repeating the word
"target".
- The pod chip no longer reports a missing `+` pod while the `−` pod is
connected. The `−` pod relays its twin, so that is the intended
configuration — the ride screen was calling it a fault, contradicting
the device screen two keystrokes away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ride screen was built for a 1440x900 window and expressed its type
scale in vw. On a phone that fails twice over: 7vw of a 412px viewport is
29px, well under what is readable from the bars, and five side-by-side
readouts do not fit across 412px at any type size. Shrinking is not the
answer to a small screen — showing less is (FR-9.17, FR-9.18).
So screen size becomes a measured input. viewport.ts is a pure function
from a measurement — width, height, DPR, whether the pointer is coarse —
to a layout plan: type sizes in pixels, column counts, and which sections
earn their space. viewport.svelte.ts measures and publishes it as CSS
custom properties and data-* attributes; the stylesheets read those. The
three max-width media queries are gone, so there is now exactly one
definition of "narrow" in the codebase rather than four that can disagree
about where a phone starts.
The sizes are absolute rather than relative, and that is a physical
argument, not a preference. A number has to subtend enough visual angle
to read from the riding position. Desktop is ~96 CSS px per inch at about
a metre; Android's CSS pixel is the dp, ~160 per inch, and a bar-mounted
phone sits at roughly 0.6 m. (160/96) x (0.6/1.0) is almost exactly 1, so
the same pixel size is about as readable in both places — which is why
the floors are plain numbers with no per-platform correction, and why a
small screen is a content problem.
What gets dropped, and in what order: anything the rider cannot act on
mid-ride goes before anything they can. Sparklines first — they are
history, and a 60px chart is a smear. Then average / normalised / work /
burned, which is what the summary screen is for. The detail row survives
longer, because "climbing left" is the question a rider on a hill is
actually asking, and elapsed time stays on a phone while covered and
ascended go. The route profile is the screen's whole point (FR-9.7) and
goes only in landscape on a phone, where keeping it would leave nothing
for the numbers.
Touch is treated as an input, not a narrower mouse (FR-9.19): 48px
targets, hover styling suppressed so it does not stick after a tap, and
keyboard hints hidden — with a word added to the help button, which
carried only a key cap and would otherwise have become unpressable.
Being a pure function is the point: "does this fit on a Pixel 7" is now
answerable in CI on a machine with no phone attached. 15 tests, run by
`npm --prefix ui test`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>