Measure the screen, then decide what fits on it

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>
This commit is contained in:
2026-08-05 19:53:00 +02:00
co-authored by Claude Opus 5
parent 06b4635470
commit 7a4e2be65e
12 changed files with 1166 additions and 127 deletions
+68 -19
View File
@@ -6,8 +6,17 @@
* from boxes.
* - No borders unless they carry meaning. Structure comes from space.
* - Numbers are tabular so they do not jitter as digits change.
* - Type scales with the viewport: the primary readouts must be legible from
* a riding position about a metre away (FR-9.5).
* - Type is sized from a *measurement*, not from `vw`: the primary readouts
* must stay legible from a riding position on a 6" phone clipped to the
* bars and on a 27" monitor a metre away (FR-9.5, FR-9.16).
*
* The `--type-*`, `--cols-*`, `--edge`, `--gap` and `--touch-min` properties
* and the `data-size` / `data-height` / `data-orientation` / `data-input`
* attributes on <html> are all written by ui/src/lib/viewport.svelte.ts. The
* values below are fallbacks for the first frame and for anything that renders
* this stylesheet without the script (a snapshot test, a plain `vite preview`).
* Do not restate a breakpoint here as a media query — one definition of
* "compact", in viewport.ts, is the whole point.
*/
:root {
@@ -30,8 +39,20 @@
--power: #dfe8f3;
--power-raw: #3f4d5d;
--gap: clamp(0.75rem, 1.4vw, 1.5rem);
--edge: clamp(1rem, 2.4vw, 2.75rem);
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
--gap: 24px;
--edge: 44px;
--touch-min: 32px;
--route-min: 130px;
--type-hero: 58px;
--type-big: 42px;
--type-mid: 26px;
--type-small: 17px;
--type-label: 11px;
--type-sub: 13px;
--cols-primary: 5;
--cols-detail: 5;
--cols-effort: 7;
color-scheme: dark;
font-synthesis: none;
@@ -47,6 +68,10 @@ body {
height: 100%;
margin: 0;
overflow: hidden;
/* Android's URL bar makes `100%` of <html> taller than what is painted at
rest. `dvh` tracks the bar; the `100%` above is the fallback for anything
that does not support it. */
height: 100dvh;
}
body {
@@ -64,6 +89,11 @@ body {
#app {
height: 100%;
/* Notches, punch-holes and the gesture bar. `viewport-fit=cover` in
index.html is what makes these non-zero; without the padding the control
bar's End button sits under the Android gesture handle. */
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
env(safe-area-inset-left);
}
button {
@@ -78,7 +108,7 @@ button {
/* ---------- shared primitives ---------------------------------------- */
.label {
font-size: clamp(0.6rem, 0.72vw, 0.78rem);
font-size: var(--type-label);
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
@@ -136,15 +166,22 @@ button {
color: var(--ink-soft);
font-size: 0.92rem;
font-weight: 600;
/* 48 px on touch (Android's accessibility floor), 32 elsewhere. A rider out
of the saddle in gloves is exactly the case that number exists for. */
min-height: var(--touch-min);
transition:
background 120ms ease,
color 120ms ease,
transform 90ms ease;
}
.btn:hover {
background: #131a24;
color: var(--ink);
/* Hover is a lie on a touchscreen: the style sticks after a tap and leaves a
button looking focused that is not. */
@media (hover: hover) {
.btn:hover {
background: #131a24;
color: var(--ink);
}
}
.btn:active {
@@ -156,22 +193,24 @@ button {
color: #04121a;
}
.btn.primary:hover {
background: #6cdcff;
color: #04121a;
}
.btn.ghost {
background: transparent;
}
.btn.ghost:hover {
background: var(--bg-lift);
}
@media (hover: hover) {
.btn.primary:hover {
background: #6cdcff;
color: #04121a;
}
.btn.danger:hover {
background: #2a1113;
color: var(--bad);
.btn.ghost:hover {
background: var(--bg-lift);
}
.btn.danger:hover {
background: #2a1113;
color: var(--bad);
}
}
.btn[disabled] {
@@ -192,6 +231,16 @@ button {
letter-spacing: 0.02em;
}
/*
* A keyboard shortcut is an instruction on a device with no keyboard. Hiding
* them also buys back the width the compact layouts need — which is why every
* control that carries one also carries a word. This is the stylesheet's half
* of `LayoutPlan.show.keyHints`.
*/
[data-input='touch'] .kbd {
display: none;
}
/* ---------- uPlot, restyled for the dark theme ------------------------ */
.uplot,