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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user