Files
BikeControl/ui/src/lib/viewport.ts
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

240 lines
8.9 KiB
TypeScript
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.
/**
* 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`,
};
}