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
+370 -1
View File
@@ -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",
+5 -2
View File
@@ -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
+64 -15
View File
@@ -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 <html> 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 <html>. */
--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 <html> 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 {
/* 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 {
.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,
+21 -7
View File
@@ -365,26 +365,40 @@
font-size: 0.88rem;
}
@media (max-width: 1000px) {
.row {
/*
* 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';
}
.identity {
:global([data-size='compact']) .identity,
:global([data-size='medium']) .identity {
grid-area: identity;
}
.signal {
:global([data-size='compact']) .signal,
:global([data-size='medium']) .signal {
grid-area: signal;
}
.states {
:global([data-size='compact']) .states,
:global([data-size='medium']) .states {
grid-area: states;
}
.controls {
:global([data-size='compact']) .controls,
:global([data-size='medium']) .controls {
grid-area: controls;
justify-content: flex-start;
}
flex-wrap: wrap;
}
</style>
+28 -1
View File
@@ -78,8 +78,10 @@
{#if started}
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
{/if}
<!-- The word, not just the key cap: on a touch device the cap is hidden
(see app.css) and a button with nothing in it is unpressable. -->
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
<span class="kbd">?</span>
Help <span class="kbd">?</span>
</button>
</div>
</div>
@@ -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;
+4 -3
View File
@@ -385,10 +385,11 @@
color: var(--ink-faint);
}
@media (max-width: 900px) {
.body {
/* 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;
}
}
</style>
+16 -5
View File
@@ -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;
}
+96 -11
View File
@@ -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 @@
</header>
<!-- The hero. -->
{#if show.routeChart}
<section class="route">
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</section>
{/if}
{#if preRide}
<!-- The whole screen before a ride begins: one obvious action. -->
@@ -289,6 +300,9 @@
<!-- Primary readouts. -->
<section class="primary">
<!-- On a two-column compact grid the hero takes the whole first row: at
40 px it does not share a row with anything and stay readable. -->
<div class="hero-cell">
<Readout
label={eta.label}
value={eta.value}
@@ -297,6 +311,7 @@
sub={eta.sub}
dim={eta.dim}
/>
</div>
<Readout
label="To go"
value={remaining != null ? km(remaining, 2) : '—'}
@@ -331,6 +346,7 @@
</section>
<!-- Route detail. -->
{#if show.detailRow}
<section class="detail">
<Readout
label="Elevation"
@@ -343,10 +359,19 @@
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
unit={d?.ascentRemainingM != null ? 'm' : ''}
/>
<!--
Compact keeps three of the five, and elapsed time is one of them: it is
the number a rider on an interval is actually watching. Covered and
ascended are the ones that go, because the primary row already answers
"how far" ("of 42.0 km") and the summary screen answers the rest.
-->
{#if !compact}
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
{/if}
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
</section>
{/if}
<!-- Effort: present, readable, subordinate. -->
<section class="effort">
@@ -371,6 +396,13 @@
unit="bpm"
size="mid"
/>
<!--
Averages, normalised power, work and calories are what the summary screen
exists to report (FR-9.13). Mid-ride they are the first thing a small
screen can do without — nothing about them changes what the rider does in
the next thirty seconds.
-->
{#if show.secondaryEffort}
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
<Readout
label="Normalised"
@@ -382,6 +414,8 @@
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
<div class="spacer"></div>
{/if}
{#if show.streamCharts}
<div class="charts">
<div class="chart">
<span class="label">Power</span>
@@ -404,6 +438,7 @@
/>
</div>
</div>
{/if}
</section>
<ControlBar />
@@ -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;
/*
* 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;
}
.detail {
grid-template-columns: repeat(3, 1fr);
:global([data-size='compact']) .chips {
margin-left: 0;
justify-content: flex-start;
}
.effort {
grid-template-columns: repeat(3, minmax(0, 1fr));
/* 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%;
}
</style>
+94
View File
@@ -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 `<html>` 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<ViewportMeasurement>({ width: 1440, height: 900, dpr: 1, touch: false });
/** The plan derived from it. Components read this; nothing writes it. */
plan = $state<LayoutPlan>(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();
+141
View File
@@ -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<string, ViewportMeasurement>;
/**
* 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$/);
}
}
});
});
+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`,
};
}
+6
View File
@@ -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 });