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:
2026-08-05 19:53:00 +02:00
co-authored by Claude Opus 5
parent 06b4635470
commit 7a4e2be65e
12 changed files with 1166 additions and 127 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* Screen size as a measured input, not a guess (FR-9.5, G-4).
*
* The desktop layout was built for a 1440×900 window and expressed its type
* scale in `vw`. On a phone that falls apart twice over: `7vw` of a 412 px
* viewport is 29 px, well under what is readable from the bars, and five
* side-by-side readouts do not fit across 412 px at *any* type size. Shrinking
* is not the answer to a small screen — showing less is.
*
* So this module turns a measurement into an explicit plan: type sizes in
* pixels, column counts, and which sections earn their space. It is a pure
* function of the measurement, which is what makes the behaviour testable
* without a browser — see viewport.test.ts. `viewport.svelte.ts` does the
* measuring and publishes the result to CSS.
*
* ## Why the sizes are absolute, not relative
*
* A number has to subtend enough visual angle to be read from the riding
* position. Physical size on screen is `cssPx / pxPerInch`, and the angle is
* that over the viewing distance. Desktop is ~96 CSS px per inch at about a
* metre; Android's CSS pixel is the density-independent pixel, ~160 per inch,
* and a phone clipped to the bars sits at roughly 0.6 m. The two corrections
* very nearly cancel:
*
* (160/96) × (0.6/1.0) ≈ 1.0
*
* which means the *same CSS pixel size* is about as readable on a bar-mounted
* phone as on a desktop monitor. That is the justification for the floors
* below being plain pixel numbers with no per-platform correction — and for
* treating a small screen as a content problem rather than a scaling one.
*/
/** What we actually measure. */
export interface ViewportMeasurement {
/** Viewport width in CSS pixels. */
width: number;
/** Viewport height in CSS pixels. */
height: number;
/** `devicePixelRatio` — recorded for diagnostics; the plan does not use it. */
dpr: number;
/** Whether the primary input is touch. Drives minimum hit-target size. */
touch: boolean;
}
/**
* Width bands, on the Material window-size-class boundaries. They are stated in
* CSS pixels, which on Android *is* the dp the boundaries were defined in.
*/
export type SizeClass = 'compact' | 'medium' | 'expanded' | 'large';
/**
* Height bands. The ride screen is a vertical stack of six sections, so height
* decides what survives far more often than width does.
*/
export type HeightClass = 'short' | 'medium' | 'tall';
export type Orientation = 'portrait' | 'landscape';
export interface LayoutPlan {
sizeClass: SizeClass;
heightClass: HeightClass;
orientation: Orientation;
/** Type sizes in CSS pixels. */
type: {
hero: number;
big: number;
mid: number;
small: number;
label: number;
sub: number;
};
/** Column counts for the ride screen's three readout grids. */
columns: {
primary: number;
detail: number;
effort: number;
};
/**
* Sections that are dropped rather than squeezed. Each is a deliberate
* judgement about what a rider still needs when there is no room.
*/
show: {
/** The route profile — the hero of the screen, and the last thing to go. */
routeChart: boolean;
/** Elevation / climbing / covered / ascended / elapsed. */
detailRow: boolean;
/** Average, normalised, work, burned — post-ride numbers, mid-ride noise. */
secondaryEffort: boolean;
/** The rolling power and gradient sparklines. */
streamCharts: boolean;
/** The keyboard hints on buttons: meaningless without a keyboard. */
keyHints: boolean;
};
/** Minimum interactive target, in CSS pixels. */
touchTargetPx: number;
/** Horizontal page padding. */
edgePx: number;
/** Grid gutter. */
gapPx: number;
/** Floor for the route chart, below which it is not worth drawing. */
routeChartMinPx: number;
}
/** The window the desktop layout was designed against. */
const REFERENCE = { width: 1440, height: 900 };
/**
* Legibility floors, in CSS pixels: the point below which a readout stops
* doing its job from a riding position. Nothing here scales past them — when
* the space is not there, `show` gives something up instead.
*/
const FLOOR = { hero: 40, big: 28, mid: 18, small: 14, label: 10, sub: 11 };
const BASE = { hero: 58, big: 42, mid: 26, small: 17, label: 11, sub: 13 };
const CEILING = { hero: 116, big: 78, mid: 45, small: 27, label: 13, sub: 15 };
export function sizeClassFor(width: number): SizeClass {
if (width < 600) return 'compact';
if (width < 900) return 'medium';
if (width < 1280) return 'expanded';
return 'large';
}
export function heightClassFor(height: number): HeightClass {
if (height < 480) return 'short';
if (height < 760) return 'medium';
return 'tall';
}
const clamp = (lo: number, v: number, hi: number) => Math.min(hi, Math.max(lo, v));
/**
* Turn a measurement into a layout.
*
* Deliberately total: every input, including a zero-sized viewport during the
* first frame, produces a usable plan rather than a NaN that would propagate
* into a CSS custom property and blank the screen.
*/
export function planLayout(m: ViewportMeasurement): LayoutPlan {
const width = Math.max(1, Math.round(m.width));
const height = Math.max(1, Math.round(m.height));
const sizeClass = sizeClassFor(width);
const heightClass = heightClassFor(height);
const orientation: Orientation = height >= width ? 'portrait' : 'landscape';
// Scale on whichever axis is tighter: a tall narrow phone is constrained by
// width, a laptop in a short window by height, and taking the minimum means
// neither can push type past the space that exists for it.
const fit = Math.min(width / REFERENCE.width, height / REFERENCE.height);
const scale = clamp(0.5, fit, 2);
const sized = (key: keyof typeof BASE) =>
Math.round(clamp(FLOOR[key], BASE[key] * scale, CEILING[key]));
const compact = sizeClass === 'compact';
const portrait = orientation === 'portrait';
const short = heightClass === 'short';
// Columns. On compact the primary readouts go two-up: three would put the
// hero ETA under 40 px to fit, and the floors are not negotiable.
const primary = compact ? 2 : sizeClass === 'medium' ? 3 : sizeClass === 'expanded' ? 4 : 5;
const detail = compact ? 3 : sizeClass === 'medium' ? 4 : 5;
const effort = compact ? 3 : sizeClass === 'medium' ? 4 : 7;
/*
* What gets dropped, and in what order. The rule is that anything the rider
* cannot act on mid-ride goes before anything they can.
*
* - The sparklines are history; the live numbers above them are not. They
* go first, and on any short viewport, because a 60 px chart is a smear.
* - The secondary effort block (average / normalised / work / burned) is
* what the summary screen is for. It goes second.
* - The detail row survives longer: "climbing left" is the question a rider
* on a hill is actually asking.
* - The route chart is the screen's whole point and only goes when there is
* genuinely no room — a phone held in landscape, where it would leave
* nothing for the numbers.
* - Key hints are noise on a touch device: there is no keyboard to press.
*/
const show = {
routeChart: !(short && compact),
detailRow: !short && !(compact && portrait && height < 700),
secondaryEffort: !compact && !short,
streamCharts: !compact && !short && height >= 700,
keyHints: !m.touch,
};
return {
sizeClass,
heightClass,
orientation,
type: {
hero: sized('hero'),
big: sized('big'),
mid: sized('mid'),
small: sized('small'),
label: sized('label'),
sub: sized('sub'),
},
columns: { primary, detail, effort },
show,
// Android's own accessibility guidance is a 48 dp minimum, and a CSS pixel
// is a dp there. A rider wearing gloves, out of the saddle, is exactly the
// case that number exists for.
touchTargetPx: m.touch ? 48 : 32,
edgePx: compact ? 14 : Math.round(clamp(16, 44 * scale, 44)),
gapPx: compact ? 10 : Math.round(clamp(12, 24 * scale, 24)),
routeChartMinPx: short ? 90 : compact ? 110 : 130,
};
}
/**
* The plan as CSS custom properties, for the stylesheets to consume.
*
* Publishing the numbers rather than re-deriving them in `@media` rules is the
* point: a media query cannot see the touch flag, and two rules that disagree
* about where "compact" starts is a bug waiting to be found on a phone.
*/
export function cssVariables(plan: LayoutPlan): Record<string, string> {
return {
'--type-hero': `${plan.type.hero}px`,
'--type-big': `${plan.type.big}px`,
'--type-mid': `${plan.type.mid}px`,
'--type-small': `${plan.type.small}px`,
'--type-label': `${plan.type.label}px`,
'--type-sub': `${plan.type.sub}px`,
'--cols-primary': String(plan.columns.primary),
'--cols-detail': String(plan.columns.detail),
'--cols-effort': String(plan.columns.effort),
'--touch-min': `${plan.touchTargetPx}px`,
'--edge': `${plan.edgePx}px`,
'--gap': `${plan.gapPx}px`,
'--route-min': `${plan.routeChartMinPx}px`,
};
}