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:
Generated
+370
-1
@@ -19,7 +19,8 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -923,6 +924,13 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@sveltejs/acorn-typescript": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
|
||||||
@@ -1009,6 +1017,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1023,6 +1049,119 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
@@ -1046,6 +1185,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1056,6 +1205,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -1082,6 +1241,13 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1117,6 +1283,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"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": "^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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1412,6 +1626,13 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1422,6 +1643,20 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/svelte": {
|
||||||
"version": "5.56.8",
|
"version": "5.56.8",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
|
||||||
@@ -1475,6 +1710,23 @@
|
|||||||
"typescript": "^5.0.0 || ^6.0.0"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -1492,6 +1744,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
|
|||||||
+5
-2
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.9.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
@@ -21,7 +23,8 @@
|
|||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.11"
|
"vite": "^6.0.11",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.25.12": true
|
"esbuild@0.25.12": true
|
||||||
|
|||||||
+68
-19
@@ -6,8 +6,17 @@
|
|||||||
* from boxes.
|
* from boxes.
|
||||||
* - No borders unless they carry meaning. Structure comes from space.
|
* - No borders unless they carry meaning. Structure comes from space.
|
||||||
* - Numbers are tabular so they do not jitter as digits change.
|
* - Numbers are tabular so they do not jitter as digits change.
|
||||||
* - Type scales with the viewport: the primary readouts must be legible from
|
* - Type is sized from a *measurement*, not from `vw`: the primary readouts
|
||||||
* a riding position about a metre away (FR-9.5).
|
* 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 {
|
:root {
|
||||||
@@ -30,8 +39,20 @@
|
|||||||
--power: #dfe8f3;
|
--power: #dfe8f3;
|
||||||
--power-raw: #3f4d5d;
|
--power-raw: #3f4d5d;
|
||||||
|
|
||||||
--gap: clamp(0.75rem, 1.4vw, 1.5rem);
|
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
|
||||||
--edge: clamp(1rem, 2.4vw, 2.75rem);
|
--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;
|
color-scheme: dark;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
@@ -47,6 +68,10 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
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 {
|
body {
|
||||||
@@ -64,6 +89,11 @@ body {
|
|||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
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 {
|
button {
|
||||||
@@ -78,7 +108,7 @@ button {
|
|||||||
/* ---------- shared primitives ---------------------------------------- */
|
/* ---------- shared primitives ---------------------------------------- */
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: clamp(0.6rem, 0.72vw, 0.78rem);
|
font-size: var(--type-label);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -136,15 +166,22 @@ button {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 600;
|
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:
|
transition:
|
||||||
background 120ms ease,
|
background 120ms ease,
|
||||||
color 120ms ease,
|
color 120ms ease,
|
||||||
transform 90ms ease;
|
transform 90ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
/* Hover is a lie on a touchscreen: the style sticks after a tap and leaves a
|
||||||
background: #131a24;
|
button looking focused that is not. */
|
||||||
color: var(--ink);
|
@media (hover: hover) {
|
||||||
|
.btn:hover {
|
||||||
|
background: #131a24;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
.btn:active {
|
||||||
@@ -156,22 +193,24 @@ button {
|
|||||||
color: #04121a;
|
color: #04121a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary:hover {
|
|
||||||
background: #6cdcff;
|
|
||||||
color: #04121a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn.ghost {
|
.btn.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.ghost:hover {
|
@media (hover: hover) {
|
||||||
background: var(--bg-lift);
|
.btn.primary:hover {
|
||||||
}
|
background: #6cdcff;
|
||||||
|
color: #04121a;
|
||||||
|
}
|
||||||
|
|
||||||
.btn.danger:hover {
|
.btn.ghost:hover {
|
||||||
background: #2a1113;
|
background: var(--bg-lift);
|
||||||
color: var(--bad);
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover {
|
||||||
|
background: #2a1113;
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn[disabled] {
|
.btn[disabled] {
|
||||||
@@ -192,6 +231,16 @@ button {
|
|||||||
letter-spacing: 0.02em;
|
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, restyled for the dark theme ------------------------ */
|
||||||
|
|
||||||
.uplot,
|
.uplot,
|
||||||
|
|||||||
@@ -365,26 +365,40 @@
|
|||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
/*
|
||||||
.row {
|
* Narrow: a device row becomes three stacked bands instead of four columns.
|
||||||
grid-template-columns: 1fr auto;
|
* Driven by the measured size class rather than a `max-width` of its own —
|
||||||
grid-template-areas:
|
* see ui/src/lib/viewport.ts for why there is exactly one definition of
|
||||||
'identity signal'
|
* "narrow" in this codebase.
|
||||||
'states states'
|
*/
|
||||||
'controls controls';
|
:global([data-size='compact']) .row,
|
||||||
}
|
:global([data-size='medium']) .row {
|
||||||
.identity {
|
grid-template-columns: 1fr auto;
|
||||||
grid-area: identity;
|
grid-template-areas:
|
||||||
}
|
'identity signal'
|
||||||
.signal {
|
'states states'
|
||||||
grid-area: signal;
|
'controls controls';
|
||||||
}
|
}
|
||||||
.states {
|
|
||||||
grid-area: states;
|
:global([data-size='compact']) .identity,
|
||||||
}
|
:global([data-size='medium']) .identity {
|
||||||
.controls {
|
grid-area: identity;
|
||||||
grid-area: controls;
|
}
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
: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;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -78,8 +78,10 @@
|
|||||||
{#if started}
|
{#if started}
|
||||||
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
<button class="btn danger" onclick={() => app.run(() => api.stop())}>End</button>
|
||||||
{/if}
|
{/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">
|
<button class="btn ghost" onclick={() => (app.showHelp = !app.showHelp)} title="Keyboard shortcuts">
|
||||||
<span class="kbd">?</span>
|
Help <span class="kbd">?</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +106,31 @@
|
|||||||
margin-left: auto;
|
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 {
|
.glyph {
|
||||||
font-size: 1.15em;
|
font-size: 1.15em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -385,10 +385,11 @@
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
/* One column below 900 px, from the measured size class rather than a
|
||||||
.body {
|
`max-width` of its own — see ui/src/lib/viewport.ts. */
|
||||||
grid-template-columns: 1fr;
|
:global([data-size='compact']) .body,
|
||||||
overflow-y: auto;
|
:global([data-size='medium']) .body {
|
||||||
}
|
grid-template-columns: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,24 +60,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sub {
|
.sub {
|
||||||
font-size: 0.8rem;
|
font-size: var(--type-sub);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ink-dim);
|
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;
|
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 {
|
.hero .value {
|
||||||
font-size: clamp(3.4rem, 7vw, 7.2rem);
|
font-size: var(--type-hero);
|
||||||
}
|
}
|
||||||
.big .value {
|
.big .value {
|
||||||
font-size: clamp(2.4rem, 4.6vw, 4.8rem);
|
font-size: var(--type-big);
|
||||||
}
|
}
|
||||||
.mid .value {
|
.mid .value {
|
||||||
font-size: clamp(1.6rem, 2.6vw, 2.8rem);
|
font-size: var(--type-mid);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.small .value {
|
.small .value {
|
||||||
font-size: clamp(1.05rem, 1.5vw, 1.65rem);
|
font-size: var(--type-small);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,20 @@
|
|||||||
signed,
|
signed,
|
||||||
targetText,
|
targetText,
|
||||||
} from '../lib/format';
|
} from '../lib/format';
|
||||||
|
import { viewport } from '../lib/viewport.svelte';
|
||||||
import ControlBar from './ControlBar.svelte';
|
import ControlBar from './ControlBar.svelte';
|
||||||
import Readout from './Readout.svelte';
|
import Readout from './Readout.svelte';
|
||||||
import RouteChart from './RouteChart.svelte';
|
import RouteChart from './RouteChart.svelte';
|
||||||
import StreamChart from './StreamChart.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 frame = $derived(app.frame);
|
||||||
const snap = $derived(frame?.snapshot ?? null);
|
const snap = $derived(frame?.snapshot ?? null);
|
||||||
const d = $derived(frame?.derived ?? null);
|
const d = $derived(frame?.derived ?? null);
|
||||||
@@ -256,9 +265,11 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- The hero. -->
|
<!-- The hero. -->
|
||||||
<section class="route">
|
{#if show.routeChart}
|
||||||
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
<section class="route">
|
||||||
</section>
|
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if preRide}
|
{#if preRide}
|
||||||
<!-- The whole screen before a ride begins: one obvious action. -->
|
<!-- The whole screen before a ride begins: one obvious action. -->
|
||||||
@@ -289,14 +300,18 @@
|
|||||||
|
|
||||||
<!-- Primary readouts. -->
|
<!-- Primary readouts. -->
|
||||||
<section class="primary">
|
<section class="primary">
|
||||||
<Readout
|
<!-- On a two-column compact grid the hero takes the whole first row: at
|
||||||
label={eta.label}
|
40 px it does not share a row with anything and stay readable. -->
|
||||||
value={eta.value}
|
<div class="hero-cell">
|
||||||
size="hero"
|
<Readout
|
||||||
colour="var(--route)"
|
label={eta.label}
|
||||||
sub={eta.sub}
|
value={eta.value}
|
||||||
dim={eta.dim}
|
size="hero"
|
||||||
/>
|
colour="var(--route)"
|
||||||
|
sub={eta.sub}
|
||||||
|
dim={eta.dim}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Readout
|
<Readout
|
||||||
label="To go"
|
label="To go"
|
||||||
value={remaining != null ? km(remaining, 2) : '—'}
|
value={remaining != null ? km(remaining, 2) : '—'}
|
||||||
@@ -331,22 +346,32 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Route detail. -->
|
<!-- Route detail. -->
|
||||||
<section class="detail">
|
{#if show.detailRow}
|
||||||
<Readout
|
<section class="detail">
|
||||||
label="Elevation"
|
<Readout
|
||||||
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
label="Elevation"
|
||||||
unit={d?.elevationM != null ? 'm' : ''}
|
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
|
||||||
colour="var(--climb)"
|
unit={d?.elevationM != null ? 'm' : ''}
|
||||||
/>
|
colour="var(--climb)"
|
||||||
<Readout
|
/>
|
||||||
label="Climbing left"
|
<Readout
|
||||||
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
label="Climbing left"
|
||||||
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
|
||||||
/>
|
unit={d?.ascentRemainingM != null ? 'm' : ''}
|
||||||
<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" />
|
<!--
|
||||||
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
|
Compact keeps three of the five, and elapsed time is one of them: it is
|
||||||
</section>
|
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. -->
|
<!-- Effort: present, readable, subordinate. -->
|
||||||
<section class="effort">
|
<section class="effort">
|
||||||
@@ -371,39 +396,49 @@
|
|||||||
unit="bpm"
|
unit="bpm"
|
||||||
size="mid"
|
size="mid"
|
||||||
/>
|
/>
|
||||||
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
|
<!--
|
||||||
<Readout
|
Averages, normalised power, work and calories are what the summary screen
|
||||||
label="Normalised"
|
exists to report (FR-9.13). Mid-ride they are the first thing a small
|
||||||
value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
|
screen can do without — nothing about them changes what the rider does in
|
||||||
unit="W"
|
the next thirty seconds.
|
||||||
size="small"
|
-->
|
||||||
/>
|
{#if show.secondaryEffort}
|
||||||
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
|
<Readout label="Avg power" value={num(d?.avgPowerW ?? 0, 0)} unit="W" size="small" />
|
||||||
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
|
<Readout
|
||||||
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
|
label="Normalised"
|
||||||
<div class="spacer"></div>
|
value={d?.normalisedPowerW != null ? num(d.normalisedPowerW, 0) : '—'}
|
||||||
<div class="charts">
|
unit="W"
|
||||||
<div class="chart">
|
size="small"
|
||||||
<span class="label">Power</span>
|
/>
|
||||||
<StreamChart
|
<Readout label="Work" value={num(d?.energyKj ?? 0, 0)} unit="kJ" size="small" />
|
||||||
history={app.power}
|
<!-- An estimate, not a measurement — see `bikecontrol_core::energy`. -->
|
||||||
revision={app.revision}
|
<Readout label="Burned" value={num(d?.caloriesKcal ?? 0, 0)} unit="kcal" size="small" />
|
||||||
series={[
|
<div class="spacer"></div>
|
||||||
{ stroke: 'var(--power-raw)', width: 1 },
|
{/if}
|
||||||
{ stroke: 'var(--power)', width: 2 },
|
{#if show.streamCharts}
|
||||||
]}
|
<div class="charts">
|
||||||
/>
|
<div class="chart">
|
||||||
|
<span class="label">Power</span>
|
||||||
|
<StreamChart
|
||||||
|
history={app.power}
|
||||||
|
revision={app.revision}
|
||||||
|
series={[
|
||||||
|
{ stroke: 'var(--power-raw)', width: 1 },
|
||||||
|
{ stroke: 'var(--power)', width: 2 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="chart">
|
||||||
|
<span class="label">Gradient</span>
|
||||||
|
<StreamChart
|
||||||
|
history={app.grade}
|
||||||
|
revision={app.revision}
|
||||||
|
zeroLine
|
||||||
|
series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart">
|
{/if}
|
||||||
<span class="label">Gradient</span>
|
|
||||||
<StreamChart
|
|
||||||
history={app.grade}
|
|
||||||
revision={app.revision}
|
|
||||||
zeroLine
|
|
||||||
series={[{ stroke: 'var(--climb)', width: 2, fill: 'rgba(255, 154, 60, 0.14)' }]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ControlBar />
|
<ControlBar />
|
||||||
@@ -489,7 +524,7 @@
|
|||||||
|
|
||||||
.route {
|
.route {
|
||||||
flex: 2 1 0;
|
flex: 2 1 0;
|
||||||
min-height: 130px;
|
min-height: var(--route-min);
|
||||||
padding: 0 var(--edge);
|
padding: 0 var(--edge);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -562,17 +597,34 @@
|
|||||||
padding-left: 0;
|
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 {
|
.primary {
|
||||||
display: grid;
|
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);
|
gap: var(--gap);
|
||||||
padding: 1.1rem var(--edge) 0.9rem;
|
padding: 1.1rem var(--edge) 0.9rem;
|
||||||
align-items: end;
|
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 {
|
.detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(var(--cols-detail), minmax(0, 1fr));
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
padding: 0 var(--edge) 1rem;
|
padding: 0 var(--edge) 1rem;
|
||||||
border-bottom: 1px solid var(--hairline);
|
border-bottom: 1px solid var(--hairline);
|
||||||
@@ -581,7 +633,7 @@
|
|||||||
.effort {
|
.effort {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
display: grid;
|
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);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
@@ -591,6 +643,13 @@
|
|||||||
overflow: hidden;
|
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 {
|
.spacer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -617,15 +676,41 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1150px) {
|
/*
|
||||||
.primary {
|
* Header, on a phone. The chip row and the two buttons cannot sit beside a
|
||||||
grid-template-columns: 1fr 1fr;
|
* 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
|
||||||
.detail {
|
* the right-hand gutter.
|
||||||
grid-template-columns: repeat(3, 1fr);
|
*/
|
||||||
}
|
:global([data-size='compact']) header {
|
||||||
.effort {
|
flex-direction: column;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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$/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { mount } from 'svelte';
|
import { mount } from 'svelte';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
|
import { viewport } from './lib/viewport.svelte';
|
||||||
|
|
||||||
const target = document.getElementById('app');
|
const target = document.getElementById('app');
|
||||||
if (!target) throw new Error('#app mount point missing from index.html');
|
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 });
|
export default mount(App, { target });
|
||||||
|
|||||||
Reference in New Issue
Block a user