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",
|
||||
"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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 './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 });
|
||||
|
||||
Reference in New Issue
Block a user