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:
@@ -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>
|
||||
|
||||
@@ -78,8 +78,10 @@
|
||||
{#if started}
|
||||
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
||||
{/if}
|
||||
<!-- The word, not just the key cap: on a touch device the cap is hidden
|
||||
(see app.css) and a button with nothing in it is unpressable. -->
|
||||
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
|
||||
<span class="kbd">?</span>
|
||||
Help <span class="kbd">?</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,6 +106,31 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* On a phone this is the only way to control the ride — there is no keyboard
|
||||
* behind it — so the buttons grow to fill the row rather than clustering at
|
||||
* 30 px each. `flex: 1` inside each group, plus the 48 px `--touch-min` from
|
||||
* app.css, is what makes them thumb-sized.
|
||||
*/
|
||||
:global([data-size='compact']) .bar {
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem var(--edge) 0.6rem;
|
||||
}
|
||||
|
||||
:global([data-size='compact']) .group {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
:global([data-size='compact']) .group.right {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:global([data-size='compact']) .group :global(.btn) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding-inline: 0.5em;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-size: 1.15em;
|
||||
line-height: 1;
|
||||
|
||||
@@ -385,10 +385,11 @@
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* One column below 900 px, from the measured size class rather than a
|
||||
`max-width` of its own — see ui/src/lib/viewport.ts. */
|
||||
:global([data-size='compact']) .body,
|
||||
:global([data-size='medium']) .body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,24 +60,35 @@
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--type-sub);
|
||||
font-weight: 600;
|
||||
color: var(--ink-dim);
|
||||
/* Nowrap was right when a readout had a fifth of a 1440 px window. In a
|
||||
two-column compact grid the subtitle is longer than its column, and a
|
||||
nowrap overflow paints over the number beside it. */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sizes come from the measurement (ui/src/lib/viewport.ts), not from `vw`.
|
||||
* `7vw` of a phone viewport is 29 px — under the floor at which the hero
|
||||
* number stops being readable from the bars — while `7vw` of a 4K monitor is
|
||||
* 190 px. Neither is a size anybody chose.
|
||||
*/
|
||||
.hero .value {
|
||||
font-size: clamp(3.4rem, 7vw, 7.2rem);
|
||||
font-size: var(--type-hero);
|
||||
}
|
||||
.big .value {
|
||||
font-size: clamp(2.4rem, 4.6vw, 4.8rem);
|
||||
font-size: var(--type-big);
|
||||
}
|
||||
.mid .value {
|
||||
font-size: clamp(1.6rem, 2.6vw, 2.8rem);
|
||||
font-size: var(--type-mid);
|
||||
font-weight: 400;
|
||||
}
|
||||
.small .value {
|
||||
font-size: clamp(1.05rem, 1.5vw, 1.65rem);
|
||||
font-size: var(--type-small);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -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