/** * 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 { 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`, }; }