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:
@@ -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`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user