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>
280 lines
6.0 KiB
CSS
280 lines
6.0 KiB
CSS
/*
|
|
* Dark theme for a screen someone stares at while suffering (FR-9.12).
|
|
*
|
|
* Rules this stylesheet follows:
|
|
* - Near-black ground, bright data. Contrast comes from the numbers, not
|
|
* from boxes.
|
|
* - No borders unless they carry meaning. Structure comes from space.
|
|
* - Numbers are tabular so they do not jitter as digits change.
|
|
* - Type is sized from a *measurement*, not from `vw`: the primary readouts
|
|
* must stay legible from a riding position on a 6" phone clipped to the
|
|
* bars and on a 27" monitor a metre away (FR-9.5, FR-9.16).
|
|
*
|
|
* The `--type-*`, `--cols-*`, `--edge`, `--gap` and `--touch-min` properties
|
|
* and the `data-size` / `data-height` / `data-orientation` / `data-input`
|
|
* attributes on <html> are all written by ui/src/lib/viewport.svelte.ts. The
|
|
* values below are fallbacks for the first frame and for anything that renders
|
|
* this stylesheet without the script (a snapshot test, a plain `vite preview`).
|
|
* Do not restate a breakpoint here as a media query — one definition of
|
|
* "compact", in viewport.ts, is the whole point.
|
|
*/
|
|
|
|
:root {
|
|
--bg: #05070a;
|
|
--bg-lift: #0b0f15;
|
|
--hairline: #161c25;
|
|
|
|
--ink: #f4f7fb;
|
|
--ink-soft: #9fb0c2;
|
|
--ink-dim: #5d6c7d;
|
|
--ink-faint: #313d4a;
|
|
|
|
--route: #45d0ff;
|
|
--route-deep: #123a4d;
|
|
--climb: #ff9a3c;
|
|
--climb-deep: #3d2712;
|
|
--ok: #35d9a0;
|
|
--warn: #ffcf4a;
|
|
--bad: #ff5a52;
|
|
--power: #dfe8f3;
|
|
--power-raw: #3f4d5d;
|
|
|
|
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
|
--gap: 24px;
|
|
--edge: 44px;
|
|
--touch-min: 32px;
|
|
--route-min: 130px;
|
|
--type-hero: 58px;
|
|
--type-big: 42px;
|
|
--type-mid: 26px;
|
|
--type-small: 17px;
|
|
--type-label: 11px;
|
|
--type-sub: 13px;
|
|
--cols-primary: 5;
|
|
--cols-detail: 5;
|
|
--cols-effort: 7;
|
|
|
|
color-scheme: dark;
|
|
font-synthesis: none;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
/* Android's URL bar makes `100%` of <html> taller than what is painted at
|
|
rest. `dvh` tracks the bar; the `100%` above is the fallback for anything
|
|
that does not support it. */
|
|
height: 100dvh;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
font-family:
|
|
'Inter var', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
'Helvetica Neue', system-ui, sans-serif;
|
|
font-variant-numeric: tabular-nums;
|
|
font-feature-settings: 'tnum' 1, 'cv01' 1;
|
|
letter-spacing: -0.01em;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
#app {
|
|
height: 100%;
|
|
/* Notches, punch-holes and the gesture bar. `viewport-fit=cover` in
|
|
index.html is what makes these non-zero; without the padding the control
|
|
bar's End button sits under the Android gesture handle. */
|
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
|
|
env(safe-area-inset-left);
|
|
}
|
|
|
|
button {
|
|
font: inherit;
|
|
color: inherit;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
}
|
|
|
|
/* ---------- shared primitives ---------------------------------------- */
|
|
|
|
.label {
|
|
font-size: var(--type-label);
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--ink-dim);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.hairline {
|
|
border-top: 1px solid var(--hairline);
|
|
}
|
|
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45em;
|
|
padding: 0.3em 0.7em;
|
|
border-radius: 999px;
|
|
background: var(--bg-lift);
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
color: var(--ink-soft);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dot {
|
|
width: 0.5em;
|
|
height: 0.5em;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
flex: none;
|
|
}
|
|
|
|
.tone-ok {
|
|
color: var(--ok);
|
|
}
|
|
.tone-warn {
|
|
color: var(--warn);
|
|
}
|
|
.tone-bad {
|
|
color: var(--bad);
|
|
}
|
|
.tone-idle {
|
|
color: var(--ink-dim);
|
|
}
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5em;
|
|
padding: 0.62em 1.05em;
|
|
border-radius: 0.5rem;
|
|
background: var(--bg-lift);
|
|
color: var(--ink-soft);
|
|
font-size: 0.92rem;
|
|
font-weight: 600;
|
|
/* 48 px on touch (Android's accessibility floor), 32 elsewhere. A rider out
|
|
of the saddle in gloves is exactly the case that number exists for. */
|
|
min-height: var(--touch-min);
|
|
transition:
|
|
background 120ms ease,
|
|
color 120ms ease,
|
|
transform 90ms ease;
|
|
}
|
|
|
|
/* Hover is a lie on a touchscreen: the style sticks after a tap and leaves a
|
|
button looking focused that is not. */
|
|
@media (hover: hover) {
|
|
.btn:hover {
|
|
background: #131a24;
|
|
color: var(--ink);
|
|
}
|
|
}
|
|
|
|
.btn:active {
|
|
transform: translateY(1px);
|
|
}
|
|
|
|
.btn.primary {
|
|
background: var(--route);
|
|
color: #04121a;
|
|
}
|
|
|
|
.btn.ghost {
|
|
background: transparent;
|
|
}
|
|
|
|
@media (hover: hover) {
|
|
.btn.primary:hover {
|
|
background: #6cdcff;
|
|
color: #04121a;
|
|
}
|
|
|
|
.btn.ghost:hover {
|
|
background: var(--bg-lift);
|
|
}
|
|
|
|
.btn.danger:hover {
|
|
background: #2a1113;
|
|
color: var(--bad);
|
|
}
|
|
}
|
|
|
|
.btn[disabled] {
|
|
opacity: 0.35;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.kbd {
|
|
display: inline-block;
|
|
min-width: 1.5em;
|
|
padding: 0.1em 0.35em;
|
|
border-radius: 0.28em;
|
|
background: #10161f;
|
|
color: var(--ink-dim);
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
/*
|
|
* A keyboard shortcut is an instruction on a device with no keyboard. Hiding
|
|
* them also buys back the width the compact layouts need — which is why every
|
|
* control that carries one also carries a word. This is the stylesheet's half
|
|
* of `LayoutPlan.show.keyHints`.
|
|
*/
|
|
[data-input='touch'] .kbd {
|
|
display: none;
|
|
}
|
|
|
|
/* ---------- uPlot, restyled for the dark theme ------------------------ */
|
|
|
|
.uplot,
|
|
.u-wrap {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.u-title,
|
|
.u-legend {
|
|
display: none;
|
|
}
|
|
|
|
.u-axis {
|
|
color: var(--ink-dim);
|
|
}
|
|
|
|
.u-select {
|
|
background: rgba(69, 208, 255, 0.12);
|
|
}
|
|
|
|
.u-cursor-x,
|
|
.u-cursor-y {
|
|
border-color: var(--ink-faint) !important;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: #1b232e;
|
|
border-radius: 6px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|