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>
717 lines
22 KiB
Svelte
717 lines
22 KiB
Svelte
<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>
|