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
+35 -21
View File
@@ -365,26 +365,40 @@
font-size: 0.88rem;
}
@media (max-width: 1000px) {
.row {
grid-template-columns: 1fr auto;
grid-template-areas:
'identity signal'
'states states'
'controls controls';
}
.identity {
grid-area: identity;
}
.signal {
grid-area: signal;
}
.states {
grid-area: states;
}
.controls {
grid-area: controls;
justify-content: flex-start;
}
/*
* Narrow: a device row becomes three stacked bands instead of four columns.
* Driven by the measured size class rather than a `max-width` of its own —
* see ui/src/lib/viewport.ts for why there is exactly one definition of
* "narrow" in this codebase.
*/
:global([data-size='compact']) .row,
:global([data-size='medium']) .row {
grid-template-columns: 1fr auto;
grid-template-areas:
'identity signal'
'states states'
'controls controls';
}
:global([data-size='compact']) .identity,
:global([data-size='medium']) .identity {
grid-area: identity;
}
:global([data-size='compact']) .signal,
:global([data-size='medium']) .signal {
grid-area: signal;
}
:global([data-size='compact']) .states,
:global([data-size='medium']) .states {
grid-area: states;
}
:global([data-size='compact']) .controls,
:global([data-size='medium']) .controls {
grid-area: controls;
justify-content: flex-start;
flex-wrap: wrap;
}
</style>