Files
BikeControl/ui/src/components/RideScreen.svelte
T
dtourolleandClaude Opus 5 7a4e2be65e 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>
2026-08-05 19:53:00 +02:00

717 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
/**
* The ride screen, route-led.
*
* The hierarchy is deliberate and is the whole design: the *route* is the
* hero, then the numbers that answer "how much longer" — ETA, distance
* remaining, speed, gradient, elevation. Power, cadence and heart rate are
* real but subordinate; they live in a single quiet strip.
*/
import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge';
import {
clock,
duration,
finishAt,
km,
MODE_LABEL,
num,
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);
const ride = $derived(app.ride);
const profile = $derived(ride?.profile ?? null);
/**
* The trainer chip (FR-1.8, FR-9.3). The rider must never be left reading
* zeros without being told why, so every non-controlling state gets words.
*/
const trainerChip = $derived.by(() => {
const t = ride?.trainer;
if (!t) return null;
const state = t.state;
if (typeof state === 'object' && 'Lost' in state) {
return { tone: 'tone-bad', label: `Trainer unavailable — ${state.Lost.reason}` };
}
switch (state) {
case 'Idle':
return { tone: 'tone-warn', label: 'No trainer — open Devices to connect' };
case 'Scanning':
return { tone: 'tone-warn', label: 'Scanning for the trainer…' };
case 'Connecting':
return { tone: 'tone-warn', label: 'Connecting to the trainer…' };
case 'Reconnecting':
return { tone: 'tone-warn', label: 'Trainer lost — reconnecting' };
case 'Connected':
return { tone: 'tone-warn', label: 'Connected, control not acquired' };
case 'Controlling':
if (t.stale) return { tone: 'tone-warn', label: 'Trainer silent — pedal to wake it' };
if (t.error) return { tone: 'tone-bad', label: t.error };
return null;
default:
return null;
}
});
/**
* The Click pods, mid-ride (FR-1.4).
*
* Silent when both are connected — the chip row is for things that need
* attention, and two working pods do not. A pod that has dropped or was
* never connected is named individually, because they do different jobs:
* lose the `` pod and the D-pad and shift-down go with it.
*/
const podChip = $derived.by(() => {
const c = app.controller;
if (!c) return null;
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
if (missing.length === 0) return null;
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
return {
tone: searching ? 'tone-warn' : 'tone-idle',
label: searching ? `Looking for the ${names}…` : `No ${names}`,
// The keyboard is always the fallback, which is what keeps a missing pod
// an annoyance rather than the end of the ride.
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
};
});
/**
* The selected gear (FR-4.1).
*
* Read from the snapshot rather than from `ride`, so what is shown is the
* gear the engine actually rode this tick, not the intent the shell recorded.
* Development is the subtitle because "8 of 12" alone says nothing about how
* hard the pedals will be, whereas 6.5 m per crank turn does.
*/
const gear = $derived.by(() => {
const count = snap?.gear_count ?? 0;
if (!snap || count === 0) return { value: '—', sub: null, dim: true };
return {
value: `${snap.gear}`,
// Development says how far the gear carries you; pedal force says what it
// costs to turn it. The second is the one that tells you, without
// pedalling, whether the gear you just selected is rideable.
sub: `of ${count} · ${num(snap.development_m, 1)} m · ${num(snap.pedal_force_n, 0)} N`,
dim: false,
};
});
/**
* A speed the engine could not compute is not a slow ride, it is a broken
* one, and saying "0.0 km/h" without saying why would be the readout lying by
* omission. Cadence arrives on the trainer's Zwift channel, not over FTMS.
*/
const speedFault = $derived(
snap?.speed_source === 'NoCadence' ? 'No cadence from the trainer — speed unavailable' : null,
);
/**
* Cadence, with what the selected gear is asking for. The difference is the
* whole feedback the rider gets on whether they are in the right gear:
* turning well above it is spinning out, well below it is grinding.
*/
const cadenceSub = $derived.by(() => {
const target = snap?.target_cadence_rpm ?? 0;
if (!target || target < 20) return null;
return `gear wants ${num(target, 0)}`;
});
const gradient = $derived(snap?.gradient_pct ?? 0);
const gradeColour = $derived(
gradient > 0.4 ? 'var(--climb)' : gradient < -0.4 ? 'var(--route)' : 'var(--ink)',
);
/** ETA presentation, driven entirely by the kind Rust reported (FR-9.15). */
const eta = $derived.by(() => {
if (!d) return { label: 'Time to go', value: '—', sub: null, dim: true };
switch (d.etaKind) {
case 'exact':
return {
label: 'Time to go',
value: duration(d.timeRemainingS),
sub: `ends ${finishAt(d.timeRemainingS)}`,
dim: false,
};
case 'estimated':
return {
label: 'ETA',
value: duration(d.timeRemainingS),
sub: `arrive ${finishAt(d.timeRemainingS)}`,
dim: false,
};
case 'held':
return {
label: 'ETA',
value: duration(d.timeRemainingS),
sub: 'held — not moving',
dim: true,
};
case 'looping':
return {
label: 'Lap',
value: String(d.loopIndex ?? 1),
sub: 'looping profile',
dim: false,
};
default:
return { label: 'ETA', value: '—', sub: 'no route loaded', dim: true };
}
});
const remaining = $derived.by(() => {
if (!d || d.distanceRemainingM == null) return null;
return d.distanceRemainingM;
});
/**
* FR-7.5 — the trainer's own speed reading is diagnostic, so it sits beside
* the virtual speed rather than replacing it. A trainer that reports nothing
* (or, as on the D100, in units we have not confirmed) is then visible as a
* mismatch instead of silently absent.
*/
const speedSub = $derived.by(() => {
if (speedFault) return speedFault;
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
const trainer = snap?.telemetry.speed_kph;
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
});
const statusChip = $derived.by(() => {
switch (ride?.status) {
case 'running':
return { label: 'Riding', tone: 'tone-ok' };
case 'paused':
return { label: 'Paused', tone: 'tone-warn' };
case 'finished':
return { label: 'Finished', tone: 'tone-idle' };
default:
return { label: 'Ready', tone: 'tone-idle' };
}
});
/**
* A ride that has not started yet. Before the first pedal stroke almost
* everything on this screen reads zero, so the screen's job is not to show
* numbers — it is to show the one action that matters.
*/
const preRide = $derived(ride?.status !== 'running' && ride?.status !== 'paused');
/** Route length and climbing, said plainly, so "what is loaded" is obvious. */
const routeSummary = $derived.by(() => {
if (!profile) return null;
const bits: string[] = [];
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
if (profile.looping) bits.push('loops');
return bits.join(' · ');
});
const openRoutes = () => (app.showProfiles = true);
</script>
<div class="ride">
<!-- Header: what is loaded, what mode, what target (FR-9.8). -->
<header>
<div class="who">
<h1>{profile?.name ?? 'No route loaded'}</h1>
{#if routeSummary}
<p>{routeSummary}{profile?.description ? ` — ${profile.description}` : ''}</p>
{:else}
<p>Manual control — choose a route to ride real terrain.</p>
{/if}
</div>
<div class="chips">
{#if trainerChip}
<span class="chip {trainerChip.tone}"><span class="dot"></span>{trainerChip.label}</span>
{/if}
<span class="chip {statusChip.tone}"><span class="dot"></span>{statusChip.label}</span>
<!--
Both pods, mid-ride, in one chip. A Click that has quietly dropped is
indistinguishable from a Click nobody has touched, and the first press
that does nothing is a bad moment to find out (FR-1.4, FR-9.2).
-->
{#if podChip}
<span class="chip {podChip.tone}" title={podChip.title}>
<span class="dot"></span>{podChip.label}
</span>
{/if}
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
</div>
</header>
<!-- The hero. -->
{#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. -->
<section class="launch">
{#if profile}
<button class="start" onclick={() => app.run(() => api.start())}>
{ride?.status === 'finished' ? 'Ride again' : 'Start ride'}
<span class="kbd">␣</span>
</button>
<div class="launch-aside">
<span class="launch-route">{profile.name}</span>
<span class="launch-sub">{routeSummary}</span>
<button class="btn ghost" onclick={openRoutes}>Choose a different route</button>
</div>
{:else}
<button class="start" onclick={openRoutes}>Choose a route</button>
<div class="launch-aside">
<span class="launch-sub">
Pick a bundled route or open your own GPX. Or start now and ride on manual gradient.
</span>
<button class="btn ghost" onclick={() => app.run(() => api.start())}>
Start without a route
</button>
</div>
{/if}
</section>
{/if}
<!-- Primary readouts. -->
<section class="primary">
<!-- 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) : '—'}
unit={remaining != null ? 'km' : ''}
size="big"
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
dim={remaining == null}
/>
<Readout
label="Speed"
value={num(d?.displaySpeedKph ?? 0, 1)}
unit="km/h"
size="big"
sub={speedSub}
/>
<Readout
label="Gradient"
value={signed(gradient, 1)}
unit="%"
size="big"
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
<Readout
label="Gear"
value={gear.value}
size="big"
colour="var(--power)"
sub={gear.sub}
dim={gear.dim}
/>
</section>
<!-- Route detail. -->
{#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">
<Readout
label={`Power · ${num(d?.rollingPowerWindowS ?? 10, 0)}s`}
value={num(d?.rollingPowerW ?? 0, 0)}
unit="W"
size="mid"
colour="var(--power)"
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
/>
<Readout
label="Cadence"
value={num(snap?.telemetry.cadence_rpm ?? 0, 0)}
unit="rpm"
size="mid"
sub={cadenceSub}
/>
<Readout
label="Heart rate"
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
unit="bpm"
size="mid"
/>
<!--
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>
{/if}
</section>
<ControlBar />
</div>
<style>
/*
* A column, not a fixed grid.
*
* This used to be `grid-template-rows` with pixel minimums that together
* exceeded a short window. When they did, the tracks overflowed and the
* chart section was painted straight over the control bar — and because
* uPlot positions its canvas and its `.u-over` overlay, both of which are
* `position: relative/absolute`, they painted *above* the unpositioned
* buttons and swallowed every click on them. The Start ride button was
* visible, looked enabled, and did nothing.
*
* Flex items cannot overlap, the flexible sections absorb the slack, and
* `overflow: hidden` on the chart area means a canvas that has not yet been
* resized cannot escape it either. The control bar keeps its own stacking
* context as a final guarantee.
*/
.ride {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ride > :global(*) {
flex: none;
}
header {
display: flex;
align-items: flex-start;
gap: var(--gap);
padding: 0.9rem var(--edge) 0.6rem;
}
.who {
min-width: 0;
}
h1 {
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.015em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.who p {
margin: 0.15rem 0 0;
font-size: 0.85rem;
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chips {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.chip.mode {
color: var(--ink);
background: #131b26;
}
.chip.target {
color: var(--route);
background: rgba(69, 208, 255, 0.1);
}
.route {
flex: 2 1 0;
min-height: var(--route-min);
padding: 0 var(--edge);
overflow: hidden;
}
/* ---- pre-ride launch panel ---------------------------------------- */
.launch {
display: flex;
align-items: center;
gap: var(--gap);
flex-wrap: wrap;
padding: 1.1rem var(--edge);
margin: 0.9rem var(--edge) 0.2rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
/* The single most important control on the screen before a ride, and it must
look like it. */
.start {
display: inline-flex;
align-items: center;
gap: 0.7em;
padding: 0.85em 2em;
border-radius: 0.6rem;
background: var(--route);
color: #04121a;
font-size: clamp(1.1rem, 1.5vw, 1.45rem);
font-weight: 700;
letter-spacing: -0.01em;
transition:
background 120ms ease,
transform 90ms ease;
}
.start:hover {
background: #6cdcff;
}
.start:active {
transform: translateY(1px);
}
.start :global(.kbd) {
background: rgba(0, 0, 0, 0.2);
color: #04121a;
}
.launch-aside {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
min-width: 0;
}
.launch-route {
font-size: 1.05rem;
font-weight: 600;
}
.launch-sub {
font-size: 0.88rem;
color: var(--ink-dim);
max-width: 34rem;
}
.launch-aside .btn {
margin-top: 0.3rem;
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: 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(var(--cols-detail), minmax(0, 1fr));
gap: var(--gap);
padding: 0 var(--edge) 1rem;
border-bottom: 1px solid var(--hairline);
}
.effort {
flex: 1 1 0;
display: grid;
grid-template-columns: repeat(var(--cols-effort), minmax(0, 1fr));
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: var(--gap);
padding: 0.9rem var(--edge) 0.4rem;
min-height: 0;
/* Nothing inside may be painted outside: see the note on `.ride`. */
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;
}
.charts {
grid-column: 1 / -1;
/* `align-items: start` above would otherwise leave this at its content
height — which is whatever size uPlot happened to pick — instead of the
height the track actually has. That is what let the canvas grow past the
bottom of the section. */
align-self: stretch;
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: var(--gap);
min-height: 0;
overflow: hidden;
}
.chart {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.15rem;
min-height: 0;
overflow: hidden;
}
/*
* 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>