From 7a4e2be65e9f2a952f065aba5fcbe557494feb71 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 5 Aug 2026 19:53:00 +0200 Subject: [PATCH] Measure the screen, then decide what fits on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/package-lock.json | 371 +++++++++++++++++++++- ui/package.json | 7 +- ui/src/app.css | 87 +++-- ui/src/components/ConnectionScreen.svelte | 56 ++-- ui/src/components/ControlBar.svelte | 29 +- ui/src/components/ProfileDrawer.svelte | 11 +- ui/src/components/Readout.svelte | 21 +- ui/src/components/RideScreen.svelte | 231 +++++++++----- ui/src/lib/viewport.svelte.ts | 94 ++++++ ui/src/lib/viewport.test.ts | 141 ++++++++ ui/src/lib/viewport.ts | 239 ++++++++++++++ ui/src/main.ts | 6 + 12 files changed, 1166 insertions(+), 127 deletions(-) create mode 100644 ui/src/lib/viewport.svelte.ts create mode 100644 ui/src/lib/viewport.test.ts create mode 100644 ui/src/lib/viewport.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index e377db4..82f576b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -19,7 +19,8 @@ "svelte-check": "^4.1.4", "tslib": "^2.8.1", "typescript": "^5.7.3", - "vite": "^6.0.11" + "vite": "^6.0.11", + "vitest": "^4.1.10" } }, "node_modules/@esbuild/aix-ppc64": { @@ -923,6 +924,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", @@ -1009,6 +1017,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1023,6 +1049,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1046,6 +1185,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1056,6 +1205,16 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1082,6 +1241,13 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1117,6 +1283,13 @@ "dev": true, "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1184,6 +1357,26 @@ } } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1290,6 +1483,27 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1412,6 +1626,13 @@ "node": ">=6" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1422,6 +1643,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.56.8", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", @@ -1475,6 +1710,23 @@ "typescript": "^5.0.0 || ^6.0.0" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1492,6 +1744,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1614,6 +1876,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/ui/package.json b/ui/package.json index 94b0956..290f7c2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "check": "svelte-check --tsconfig ./tsconfig.json", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tauri-apps/api": "^2.9.0", @@ -21,7 +23,8 @@ "svelte-check": "^4.1.4", "tslib": "^2.8.1", "typescript": "^5.7.3", - "vite": "^6.0.11" + "vite": "^6.0.11", + "vitest": "^4.1.10" }, "allowScripts": { "esbuild@0.25.12": true diff --git a/ui/src/app.css b/ui/src/app.css index 5b384e9..df9741d 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -6,8 +6,17 @@ * from boxes. * - No borders unless they carry meaning. Structure comes from space. * - Numbers are tabular so they do not jitter as digits change. - * - Type scales with the viewport: the primary readouts must be legible from - * a riding position about a metre away (FR-9.5). + * - 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 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 { @@ -30,8 +39,20 @@ --power: #dfe8f3; --power-raw: #3f4d5d; - --gap: clamp(0.75rem, 1.4vw, 1.5rem); - --edge: clamp(1rem, 2.4vw, 2.75rem); + /* Fallbacks; viewport.svelte.ts overwrites all of these on . */ + --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; @@ -47,6 +68,10 @@ body { height: 100%; margin: 0; overflow: hidden; + /* Android's URL bar makes `100%` of 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 { @@ -64,6 +89,11 @@ body { #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 { @@ -78,7 +108,7 @@ button { /* ---------- shared primitives ---------------------------------------- */ .label { - font-size: clamp(0.6rem, 0.72vw, 0.78rem); + font-size: var(--type-label); font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; @@ -136,15 +166,22 @@ button { 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; } -.btn:hover { - background: #131a24; - color: var(--ink); +/* 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 { @@ -156,22 +193,24 @@ button { color: #04121a; } -.btn.primary:hover { - background: #6cdcff; - color: #04121a; -} - .btn.ghost { background: transparent; } -.btn.ghost:hover { - background: var(--bg-lift); -} +@media (hover: hover) { + .btn.primary:hover { + background: #6cdcff; + color: #04121a; + } -.btn.danger:hover { - background: #2a1113; - color: var(--bad); + .btn.ghost:hover { + background: var(--bg-lift); + } + + .btn.danger:hover { + background: #2a1113; + color: var(--bad); + } } .btn[disabled] { @@ -192,6 +231,16 @@ button { 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, diff --git a/ui/src/components/ConnectionScreen.svelte b/ui/src/components/ConnectionScreen.svelte index 6b17263..d1ebbe1 100644 --- a/ui/src/components/ConnectionScreen.svelte +++ b/ui/src/components/ConnectionScreen.svelte @@ -365,26 +365,40 @@ font-size: 0.88rem; } - @media (max-width: 1000px) { - .row { - grid-template-columns: 1fr auto; - grid-template-areas: - 'identity signal' - 'states states' - 'controls controls'; - } - .identity { - grid-area: identity; - } - .signal { - grid-area: signal; - } - .states { - grid-area: states; - } - .controls { - grid-area: controls; - justify-content: flex-start; - } + /* + * Narrow: a device row becomes three stacked bands instead of four columns. + * Driven by the measured size class rather than a `max-width` of its own — + * see ui/src/lib/viewport.ts for why there is exactly one definition of + * "narrow" in this codebase. + */ + :global([data-size='compact']) .row, + :global([data-size='medium']) .row { + grid-template-columns: 1fr auto; + grid-template-areas: + 'identity signal' + 'states states' + 'controls controls'; + } + + :global([data-size='compact']) .identity, + :global([data-size='medium']) .identity { + grid-area: identity; + } + + :global([data-size='compact']) .signal, + :global([data-size='medium']) .signal { + grid-area: signal; + } + + :global([data-size='compact']) .states, + :global([data-size='medium']) .states { + grid-area: states; + } + + :global([data-size='compact']) .controls, + :global([data-size='medium']) .controls { + grid-area: controls; + justify-content: flex-start; + flex-wrap: wrap; } diff --git a/ui/src/components/ControlBar.svelte b/ui/src/components/ControlBar.svelte index cbc8363..bfa8366 100644 --- a/ui/src/components/ControlBar.svelte +++ b/ui/src/components/ControlBar.svelte @@ -78,8 +78,10 @@ {#if started} {/if} + @@ -104,6 +106,31 @@ margin-left: auto; } + /* + * On a phone this is the only way to control the ride — there is no keyboard + * behind it — so the buttons grow to fill the row rather than clustering at + * 30 px each. `flex: 1` inside each group, plus the 48 px `--touch-min` from + * app.css, is what makes them thumb-sized. + */ + :global([data-size='compact']) .bar { + gap: 0.4rem; + padding: 0.5rem var(--edge) 0.6rem; + } + + :global([data-size='compact']) .group { + flex: 1 1 100%; + } + + :global([data-size='compact']) .group.right { + margin-left: 0; + } + + :global([data-size='compact']) .group :global(.btn) { + flex: 1 1 0; + min-width: 0; + padding-inline: 0.5em; + } + .glyph { font-size: 1.15em; line-height: 1; diff --git a/ui/src/components/ProfileDrawer.svelte b/ui/src/components/ProfileDrawer.svelte index 0ff8cec..a6f06ba 100644 --- a/ui/src/components/ProfileDrawer.svelte +++ b/ui/src/components/ProfileDrawer.svelte @@ -385,10 +385,11 @@ color: var(--ink-faint); } - @media (max-width: 900px) { - .body { - grid-template-columns: 1fr; - overflow-y: auto; - } + /* One column below 900 px, from the measured size class rather than a + `max-width` of its own — see ui/src/lib/viewport.ts. */ + :global([data-size='compact']) .body, + :global([data-size='medium']) .body { + grid-template-columns: 1fr; + overflow-y: auto; } diff --git a/ui/src/components/Readout.svelte b/ui/src/components/Readout.svelte index 9187f22..14c180c 100644 --- a/ui/src/components/Readout.svelte +++ b/ui/src/components/Readout.svelte @@ -60,24 +60,35 @@ } .sub { - font-size: 0.8rem; + font-size: var(--type-sub); font-weight: 600; color: var(--ink-dim); + /* Nowrap was right when a readout had a fifth of a 1440 px window. In a + two-column compact grid the subtitle is longer than its column, and a + nowrap overflow paints over the number beside it. */ + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } + /* + * Sizes come from the measurement (ui/src/lib/viewport.ts), not from `vw`. + * `7vw` of a phone viewport is 29 px — under the floor at which the hero + * number stops being readable from the bars — while `7vw` of a 4K monitor is + * 190 px. Neither is a size anybody chose. + */ .hero .value { - font-size: clamp(3.4rem, 7vw, 7.2rem); + font-size: var(--type-hero); } .big .value { - font-size: clamp(2.4rem, 4.6vw, 4.8rem); + font-size: var(--type-big); } .mid .value { - font-size: clamp(1.6rem, 2.6vw, 2.8rem); + font-size: var(--type-mid); font-weight: 400; } .small .value { - font-size: clamp(1.05rem, 1.5vw, 1.65rem); + font-size: var(--type-small); font-weight: 500; } diff --git a/ui/src/components/RideScreen.svelte b/ui/src/components/RideScreen.svelte index 70d6405..65b032d 100644 --- a/ui/src/components/RideScreen.svelte +++ b/ui/src/components/RideScreen.svelte @@ -19,11 +19,20 @@ signed, targetText, } from '../lib/format'; + import { viewport } from '../lib/viewport.svelte'; import ControlBar from './ControlBar.svelte'; import Readout from './Readout.svelte'; import RouteChart from './RouteChart.svelte'; import StreamChart from './StreamChart.svelte'; + /** + * What this screen can afford to show, measured rather than assumed. See + * ui/src/lib/viewport.ts for the reasoning behind each drop — the short + * version is that a phone gets fewer numbers, not smaller ones. + */ + const show = $derived(viewport.plan.show); + const compact = $derived(viewport.plan.sizeClass === 'compact'); + const frame = $derived(app.frame); const snap = $derived(frame?.snapshot ?? null); const d = $derived(frame?.derived ?? null); @@ -256,9 +265,11 @@ -
- -
+ {#if show.routeChart} +
+ +
+ {/if} {#if preRide} @@ -289,14 +300,18 @@
- + +
+ +
-
- - - - - -
+ {#if show.detailRow} +
+ + + + {#if !compact} + + + {/if} + +
+ {/if}
@@ -371,39 +396,49 @@ unit="bpm" size="mid" /> - - - - - -
-
-
- Power - + + {#if show.secondaryEffort} + + + + + +
+ {/if} + {#if show.streamCharts} +
+
+ Power + +
+
+ Gradient + +
-
- Gradient - -
-
+ {/if}
@@ -489,7 +524,7 @@ .route { flex: 2 1 0; - min-height: 130px; + min-height: var(--route-min); padding: 0 var(--edge); overflow: hidden; } @@ -562,17 +597,34 @@ padding-left: 0; } + /* + * Column counts come from the measurement (ui/src/lib/viewport.ts) rather + * than from breakpoints restated here. Two places deciding what "narrow" + * means is how a layout ends up correct on a laptop and broken on a phone. + */ .primary { display: grid; - grid-template-columns: 1.15fr 1fr 1fr 1fr 0.8fr; + grid-template-columns: repeat(var(--cols-primary), minmax(0, 1fr)); gap: var(--gap); padding: 1.1rem var(--edge) 0.9rem; align-items: end; } + /* The hero owns its own row wherever the grid is too narrow to give it a + column of its own — below three columns, sharing a row with a `big` + readout clips one or the other. */ + .hero-cell { + grid-column: span 1; + min-width: 0; + } + + :global([data-size='compact']) .hero-cell { + grid-column: 1 / -1; + } + .detail { display: grid; - grid-template-columns: repeat(5, 1fr); + grid-template-columns: repeat(var(--cols-detail), minmax(0, 1fr)); gap: var(--gap); padding: 0 var(--edge) 1rem; border-bottom: 1px solid var(--hairline); @@ -581,7 +633,7 @@ .effort { flex: 1 1 0; display: grid; - grid-template-columns: repeat(7, minmax(0, auto)) 1fr; + grid-template-columns: repeat(var(--cols-effort), minmax(0, 1fr)); grid-template-rows: auto minmax(0, 1fr); align-items: start; gap: var(--gap); @@ -591,6 +643,13 @@ overflow: hidden; } + /* With the sparklines gone there is no second row to reserve, and the effort + numbers should sit against the control bar rather than float above a gap. */ + :global([data-size='compact']) .effort { + flex: 0 0 auto; + grid-template-rows: auto; + } + .spacer { display: none; } @@ -617,15 +676,41 @@ overflow: hidden; } - @media (max-width: 1150px) { - .primary { - grid-template-columns: 1fr 1fr; - } - .detail { - grid-template-columns: repeat(3, 1fr); - } - .effort { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } + /* + * Header, on a phone. The chip row and the two buttons cannot sit beside a + * route title on 412 px, so the title takes the first line and the chips wrap + * under it, left-aligned — `margin-left: auto` would push a wrapped row into + * the right-hand gutter. + */ + :global([data-size='compact']) header { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + padding: 0.6rem var(--edge) 0.4rem; + } + + :global([data-size='compact']) .chips { + margin-left: 0; + justify-content: flex-start; + } + + /* The route summary line is the first thing to go: it repeats what the route + chart shows, and on a phone it costs a whole line of the ride screen. */ + :global([data-size='compact']) .who p { + display: none; + } + + :global([data-size='compact']) .launch { + flex-direction: column; + align-items: stretch; + margin: 0.6rem var(--edge) 0.2rem; + padding: 0.9rem 1rem; + } + + /* Full width, because on a phone this is the only thing on screen worth + tapping and a thumb should not have to find it. */ + :global([data-size='compact']) .start { + justify-content: center; + width: 100%; } diff --git a/ui/src/lib/viewport.svelte.ts b/ui/src/lib/viewport.svelte.ts new file mode 100644 index 0000000..bef1661 --- /dev/null +++ b/ui/src/lib/viewport.svelte.ts @@ -0,0 +1,94 @@ +/** + * The measuring half of the screen-size work; `viewport.ts` holds the rules. + * + * One store, mounted once from `main.ts`, that watches the viewport and + * publishes the resulting plan two ways: as reactive state for the components + * that need to branch in markup, and as CSS custom properties plus data + * attributes on `` for everything that can be expressed in a stylesheet. + */ +import { cssVariables, planLayout, type LayoutPlan, type ViewportMeasurement } from './viewport'; + +/** + * Read the viewport. + * + * `visualViewport` rather than `innerWidth`/`innerHeight` where it exists, + * because on Android those two lie: they report the layout viewport, which + * ignores the on-screen keyboard and the collapsing URL bar. The rider never + * sees a keyboard mid-ride, but they do see the bar, and a layout planned + * against a height 60 px taller than the one being painted puts the control + * bar under the edge of the screen. + */ +export function measure(): ViewportMeasurement { + const vv = typeof window !== 'undefined' ? window.visualViewport : null; + return { + width: vv?.width ?? window.innerWidth, + height: vv?.height ?? window.innerHeight, + dpr: window.devicePixelRatio || 1, + // `pointer: coarse` is the honest question — "is the primary input a + // finger" — where a user-agent sniff is a guess. A touchscreen laptop + // reports `fine`, correctly: it has a keyboard and a trackpad. + touch: window.matchMedia?.('(pointer: coarse)').matches ?? false, + }; +} + +class ViewportStore { + /** The last measurement taken, published for diagnostics and tests. */ + measurement = $state({ width: 1440, height: 900, dpr: 1, touch: false }); + /** The plan derived from it. Components read this; nothing writes it. */ + plan = $state(planLayout({ width: 1440, height: 900, dpr: 1, touch: false })); + + private detach: (() => void) | null = null; + + /** + * Start watching. Returns a teardown so a test (or a future multi-window + * shell) can stop it; the app itself never does. + */ + observe(): () => void { + this.detach?.(); + this.apply(); + + // A frame's grace after a rotation: Android reports the new orientation + // before the webview has been resized to match, so measuring on the event + // itself yields the *old* dimensions with the new orientation — briefly a + // portrait layout on a landscape screen. + let frame = 0; + const schedule = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => this.apply()); + }; + + window.addEventListener('resize', schedule); + window.addEventListener('orientationchange', schedule); + window.visualViewport?.addEventListener('resize', schedule); + + this.detach = () => { + cancelAnimationFrame(frame); + window.removeEventListener('resize', schedule); + window.removeEventListener('orientationchange', schedule); + window.visualViewport?.removeEventListener('resize', schedule); + this.detach = null; + }; + return this.detach; + } + + /** Measure, plan, publish. */ + apply(): void { + const m = measure(); + const plan = planLayout(m); + this.measurement = m; + this.plan = plan; + + const root = document.documentElement; + for (const [name, value] of Object.entries(cssVariables(plan))) { + root.style.setProperty(name, value); + } + // Data attributes rather than classes: a stylesheet can then say + // `[data-size='compact']` and read like the plan it came from. + root.dataset.size = plan.sizeClass; + root.dataset.height = plan.heightClass; + root.dataset.orientation = plan.orientation; + root.dataset.input = m.touch ? 'touch' : 'pointer'; + } +} + +export const viewport = new ViewportStore(); diff --git a/ui/src/lib/viewport.test.ts b/ui/src/lib/viewport.test.ts new file mode 100644 index 0000000..c9ce914 --- /dev/null +++ b/ui/src/lib/viewport.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { cssVariables, heightClassFor, planLayout, sizeClassFor } from './viewport'; +import type { ViewportMeasurement } from './viewport'; + +/** + * Real devices, in CSS pixels. These are the measurements the layout has to + * survive, so they are the test cases — the point of `planLayout` being pure is + * that "does BikeControl fit on a Pixel 7" is a question answerable in CI, on a + * machine with no phone attached (G-4). + */ +const DEVICES = { + pixel7Portrait: { width: 412, height: 915, dpr: 2.625, touch: true }, + pixel7Landscape: { width: 915, height: 412, dpr: 2.625, touch: true }, + smallPhone: { width: 360, height: 640, dpr: 3, touch: true }, + tablet: { width: 1280, height: 800, dpr: 2, touch: true }, + desktop: { width: 1440, height: 900, dpr: 1, touch: false }, + desktop4k: { width: 2560, height: 1440, dpr: 2, touch: false }, + shortWindow: { width: 1280, height: 420, dpr: 1, touch: false }, +} satisfies Record; + +/** + * The legibility contract from FR-9.5, restated as numbers. Nothing the planner + * does may put a readout under these, on any device. + */ +const FLOOR = { hero: 40, big: 28, mid: 18, small: 14, label: 10, sub: 11 }; + +describe('sizeClassFor', () => { + it('puts every phone width in the compact band', () => { + expect(sizeClassFor(360)).toBe('compact'); + expect(sizeClassFor(412)).toBe('compact'); + expect(sizeClassFor(599)).toBe('compact'); + }); + + it('bands the rest on the Material boundaries', () => { + expect(sizeClassFor(600)).toBe('medium'); + expect(sizeClassFor(899)).toBe('medium'); + expect(sizeClassFor(900)).toBe('expanded'); + expect(sizeClassFor(1279)).toBe('expanded'); + expect(sizeClassFor(1280)).toBe('large'); + }); +}); + +describe('heightClassFor', () => { + it('bands on the heights that change what fits', () => { + expect(heightClassFor(412)).toBe('short'); + expect(heightClassFor(640)).toBe('medium'); + expect(heightClassFor(915)).toBe('tall'); + }); +}); + +describe('planLayout', () => { + it('never puts a readout below its legibility floor', () => { + for (const [name, m] of Object.entries(DEVICES)) { + const { type } = planLayout(m); + for (const key of Object.keys(FLOOR) as (keyof typeof FLOOR)[]) { + expect(type[key], `${name}.${key}`).toBeGreaterThanOrEqual(FLOOR[key]); + } + } + }); + + it('keeps the primary readouts inside the viewport width', () => { + // A readout is roughly `hero` wide per digit-pair plus its gutter. The + // check that matters is the coarse one: columns × floor width must fit, + // or the row wraps and the hero number is clipped. + for (const [name, m] of Object.entries(DEVICES)) { + const plan = planLayout(m); + const needed = + plan.columns.primary * plan.type.hero + (plan.columns.primary - 1) * plan.gapPx + 2 * plan.edgePx; + expect(needed, `${name} primary row`).toBeLessThanOrEqual(m.width); + } + }); + + it('drops the sparklines on a phone rather than drawing a smear', () => { + expect(planLayout(DEVICES.pixel7Portrait).show.streamCharts).toBe(false); + expect(planLayout(DEVICES.pixel7Landscape).show.streamCharts).toBe(false); + expect(planLayout(DEVICES.desktop).show.streamCharts).toBe(true); + }); + + it('keeps the route chart on a phone in portrait — it is the screen', () => { + expect(planLayout(DEVICES.pixel7Portrait).show.routeChart).toBe(true); + }); + + it('gives up the route chart only when both axes are gone', () => { + // A phone in landscape is 412 px tall: the route chart plus the numbers do + // not both fit, and the numbers win. + expect(planLayout({ width: 568, height: 320, dpr: 3, touch: true }).show.routeChart).toBe(false); + // A short *wide* window still has room for it. + expect(planLayout(DEVICES.shortWindow).show.routeChart).toBe(true); + }); + + it('hides keyboard hints when there is no keyboard', () => { + expect(planLayout(DEVICES.pixel7Portrait).show.keyHints).toBe(false); + expect(planLayout(DEVICES.desktop).show.keyHints).toBe(true); + }); + + it('meets the 48 px touch target minimum on touch devices', () => { + expect(planLayout(DEVICES.pixel7Portrait).touchTargetPx).toBeGreaterThanOrEqual(48); + expect(planLayout(DEVICES.tablet).touchTargetPx).toBeGreaterThanOrEqual(48); + }); + + it('narrows the primary grid as the screen narrows, monotonically', () => { + const widths = [360, 412, 600, 900, 1280, 1920]; + const columns = widths.map((width) => planLayout({ width, height: 900, dpr: 2, touch: false }).columns.primary); + for (let i = 1; i < columns.length; i++) { + expect(columns[i], `${widths[i]}px`).toBeGreaterThanOrEqual(columns[i - 1]); + } + }); + + it('reports orientation from the measurement, not from the platform', () => { + expect(planLayout(DEVICES.pixel7Portrait).orientation).toBe('portrait'); + expect(planLayout(DEVICES.pixel7Landscape).orientation).toBe('landscape'); + // Square is portrait by the >= rule; the point is that it is defined. + expect(planLayout({ width: 800, height: 800, dpr: 1, touch: false }).orientation).toBe('portrait'); + }); + + it('survives a zero-sized viewport on the first frame', () => { + const plan = planLayout({ width: 0, height: 0, dpr: 1, touch: false }); + expect(Number.isFinite(plan.type.hero)).toBe(true); + expect(plan.type.hero).toBeGreaterThanOrEqual(FLOOR.hero); + expect(plan.columns.primary).toBeGreaterThan(0); + }); + + it('does not grow type without bound on a very large display', () => { + const huge = planLayout({ width: 7680, height: 4320, dpr: 1, touch: false }); + expect(huge.type.hero).toBeLessThanOrEqual(116); + }); +}); + +describe('cssVariables', () => { + it('emits every value with a unit or as a bare count, never NaN', () => { + const vars = cssVariables(planLayout(DEVICES.pixel7Portrait)); + for (const [name, value] of Object.entries(vars)) { + expect(value, name).not.toContain('NaN'); + if (name.startsWith('--cols-')) { + expect(Number(value), name).toBeGreaterThan(0); + } else { + expect(value, name).toMatch(/^\d+px$/); + } + } + }); +}); diff --git a/ui/src/lib/viewport.ts b/ui/src/lib/viewport.ts new file mode 100644 index 0000000..3bc1465 --- /dev/null +++ b/ui/src/lib/viewport.ts @@ -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 { + 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`, + }; +} diff --git a/ui/src/main.ts b/ui/src/main.ts index 646be01..7fdddf8 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,8 +1,14 @@ import { mount } from 'svelte'; import './app.css'; import App from './App.svelte'; +import { viewport } from './lib/viewport.svelte'; const target = document.getElementById('app'); if (!target) throw new Error('#app mount point missing from index.html'); +// Before the first paint: the stylesheet's type sizes and column counts are +// custom properties this fills in, so mounting first would flash a desktop +// layout on a phone. +viewport.observe(); + export default mount(App, { target });