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
+158 -73
View File
@@ -19,11 +19,20 @@
signed,
targetText,
} from '../lib/format';
import { viewport } from '../lib/viewport.svelte';
import ControlBar from './ControlBar.svelte';
import Readout from './Readout.svelte';
import RouteChart from './RouteChart.svelte';
import StreamChart from './StreamChart.svelte';
/**
* What this screen can afford to show, measured rather than assumed. See
* ui/src/lib/viewport.ts for the reasoning behind each drop — the short
* version is that a phone gets fewer numbers, not smaller ones.
*/
const show = $derived(viewport.plan.show);
const compact = $derived(viewport.plan.sizeClass === 'compact');
const frame = $derived(app.frame);
const snap = $derived(frame?.snapshot ?? null);
const d = $derived(frame?.derived ?? null);
@@ -256,9 +265,11 @@
</header>
<!-- The hero. -->
<section class="route">
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</section>
{#if show.routeChart}
<section class="route">
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</section>
{/if}
{#if preRide}
<!-- The whole screen before a ride begins: one obvious action. -->
@@ -289,14 +300,18 @@
<!-- Primary readouts. -->
<section class="primary">
<Readout
label={eta.label}
value={eta.value}
size="hero"
colour="var(--route)"
sub={eta.sub}
dim={eta.dim}
/>
<!-- On a two-column compact grid the hero takes the whole first row: at
40 px it does not share a row with anything and stay readable. -->
<div class="hero-cell">
<Readout
label={eta.label}
value={eta.value}
size="hero"
colour="var(--route)"
sub={eta.sub}
dim={eta.dim}
/>
</div>
<Readout
label="To go"
value={remaining != null ? km(remaining, 2) : '—'}
@@ -331,22 +346,32 @@
</section>
<!-- Route detail. -->
<section class="detail">
<Readout
label="Elevation"
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
unit={d?.elevationM != null ? 'm' : ''}
colour="var(--climb)"
/>
<Readout
label="Climbing left"
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
unit={d?.ascentRemainingM != null ? 'm' : ''}
/>
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
</section>
{#if show.detailRow}
<section class="detail">
<Readout
label="Elevation"
value={d?.elevationM != null ? num(d.elevationM, 0) : ''}
unit={d?.elevationM != null ? 'm' : ''}
colour="var(--climb)"
/>
<Readout
label="Climbing left"
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : ''}
unit={d?.ascentRemainingM != null ? 'm' : ''}
/>
<!--
Compact keeps three of the five, and elapsed time is one of them: it is
the number a rider on an interval is actually watching. Covered and
ascended are the ones that go, because the primary row already answers
"how far" ("of 42.0 km") and the summary screen answers the rest.
-->
{#if !compact}
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
{/if}
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
</section>
{/if}
<!-- Effort: present, readable, subordinate. -->
<section class="effort">
@@ -371,39 +396,49 @@
unit="bpm"
size="mid"
/>
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
<Readout
label="Normalised"
value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
unit="W"
size="small"
/>
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
<div class="spacer"></div>
<div class="charts">
<div class="chart">
<span class="label">Power</span>
<StreamChart
history={app.power}
revision={app.revision}
series={[
{ stroke: 'var(--power-raw)', width: 1 },
{ stroke: 'var(--power)', width: 2 },
]}
/>
<!--
Averages, normalised power, work and calories are what the summary screen
exists to report (FR-9.13). Mid-ride they are the first thing a small
screen can do without — nothing about them changes what the rider does in
the next thirty seconds.
-->
{#if show.secondaryEffort}
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
<Readout
label="Normalised"
value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
unit="W"
size="small"
/>
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
<div class="spacer"></div>
{/if}
{#if show.streamCharts}
<div class="charts">
<div class="chart">
<span class="label">Power</span>
<StreamChart
history={app.power}
revision={app.revision}
series={[
{ stroke: 'var(--power-raw)', width: 1 },
{ stroke: 'var(--power)', width: 2 },
]}
/>
</div>
<div class="chart">
<span class="label">Gradient</span>
<StreamChart
history={app.grade}
revision={app.revision}
zeroLine
series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
/>
</div>
</div>
<div class="chart">
<span class="label">Gradient</span>
<StreamChart
history={app.grade}
revision={app.revision}
zeroLine
series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
/>
</div>
</div>
{/if}
</section>
<ControlBar />
@@ -489,7 +524,7 @@
.route {
flex: 2 1 0;
min-height: 130px;
min-height: var(--route-min);
padding: 0 var(--edge);
overflow: hidden;
}
@@ -562,17 +597,34 @@
padding-left: 0;
}
/*
* Column counts come from the measurement (ui/src/lib/viewport.ts) rather
* than from breakpoints restated here. Two places deciding what "narrow"
* means is how a layout ends up correct on a laptop and broken on a phone.
*/
.primary {
display: grid;
grid-template-columns: 1.15fr 1fr 1fr 1fr 0.8fr;
grid-template-columns: repeat(var(--cols-primary), minmax(0, 1fr));
gap: var(--gap);
padding: 1.1rem var(--edge) 0.9rem;
align-items: end;
}
/* The hero owns its own row wherever the grid is too narrow to give it a
column of its own — below three columns, sharing a row with a `big`
readout clips one or the other. */
.hero-cell {
grid-column: span 1;
min-width: 0;
}
:global([data-size='compact']) .hero-cell {
grid-column: 1 / -1;
}
.detail {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(var(--cols-detail), minmax(0, 1fr));
gap: var(--gap);
padding: 0 var(--edge) 1rem;
border-bottom: 1px solid var(--hairline);
@@ -581,7 +633,7 @@
.effort {
flex: 1 1 0;
display: grid;
grid-template-columns: repeat(7, minmax(0, auto)) 1fr;
grid-template-columns: repeat(var(--cols-effort), minmax(0, 1fr));
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: var(--gap);
@@ -591,6 +643,13 @@
overflow: hidden;
}
/* With the sparklines gone there is no second row to reserve, and the effort
numbers should sit against the control bar rather than float above a gap. */
:global([data-size='compact']) .effort {
flex: 0 0 auto;
grid-template-rows: auto;
}
.spacer {
display: none;
}
@@ -617,15 +676,41 @@
overflow: hidden;
}
@media (max-width: 1150px) {
.primary {
grid-template-columns: 1fr 1fr;
}
.detail {
grid-template-columns: repeat(3, 1fr);
}
.effort {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/*
* Header, on a phone. The chip row and the two buttons cannot sit beside a
* route title on 412 px, so the title takes the first line and the chips wrap
* under it, left-aligned — `margin-left: auto` would push a wrapped row into
* the right-hand gutter.
*/
:global([data-size='compact']) header {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.6rem var(--edge) 0.4rem;
}
:global([data-size='compact']) .chips {
margin-left: 0;
justify-content: flex-start;
}
/* The route summary line is the first thing to go: it repeats what the route
chart shows, and on a phone it costs a whole line of the ride screen. */
:global([data-size='compact']) .who p {
display: none;
}
:global([data-size='compact']) .launch {
flex-direction: column;
align-items: stretch;
margin: 0.6rem var(--edge) 0.2rem;
padding: 0.9rem 1rem;
}
/* Full width, because on a phone this is the only thing on screen worth
tapping and a thumb should not have to find it. */
:global([data-size='compact']) .start {
justify-content: center;
width: 100%;
}
</style>