Skip to main content

max / goingson

Ask what is pointing at the screen, not what hardware exists touch.js decided density with ('ontouchstart' in window) || navigator.maxTouchPoints > 0. That asks the hardware inventory, so a laptop with a touchscreen and a mouse came back touch, lost its hover affordances and got gesture handlers nothing would ever perform. It is the same sniff bootstrap-uimode.js was rewritten to stop doing, still living on the behaviour side after the layout side had moved. It now asks matchMedia with Density::Touch's own condition, which is the query the generated geometry.css already keys the touch gap overrides on, so spacing and gestures agree by construction instead of by coincidence. One line feeds every consumer: the ~35 call sites reading GoingsOn.touch.isTouchDevice needed no edits. haptics.js asks the query itself rather than reading it off the namespace, so a buzz no longer depends on the gesture module having loaded. Two files state the string, so build.rs checks them, the way it already checks hand-written breakpoints against SizeClass. Every TOUCH_DENSITY const must equal the crate's string, one must exist, and neither sniff token may appear in frontend/js again. An assertion and not a substitution: a JS file that has to be generated to be correct stops being readable on its own.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 22:15 UTC
Signed with PGP, not checked
Commit: 2b4c87c916e8fb0d55bccb3ce3afc8663bb105ac
Parent: 9dd3774
4 files changed, +126 insertions, -4 deletions
M CONTRIBUTING.md +1 -1
@@ -258,7 +258,7 @@
258 258 @media (hover: none), (pointer: coarse) { .foo { ... } }
259 259 ```
260 260
261 - The same condition the generated `geometry.css` keys touch density on. Use it for hover suppression, touch-target sizing, and gestures that need a pointer to perform. `GoingsOn.touch.isTouchDevice` is the JS side. Never use capability for visibility or layout that is really about room.
261 + The same condition the generated `geometry.css` keys touch density on. Use it for hover suppression, touch-target sizing, and gestures that need a pointer to perform. `GoingsOn.touch.isTouchDevice` is the JS side, and it is this query run through `matchMedia` rather than a separate answer: `build.rs` fails the build if the string in `js/` drifts from `makeover_geometry::Density::Touch`. Never use capability for visibility or layout that is really about room.
262 262
263 263 **`.ui-mode-mobile` is the explicit override and nothing else.** Set from `?ui=mobile` or `localStorage.goingson.uiMode`, and `geometry.css` layers it last so a deliberate choice beats detection. Nothing sniffs a user agent any more; with no override asked for, no class is added. Do not reach for the class to mean "small".
264 264
M src-tauri/build.rs +107 -1
@@ -2,7 +2,7 @@
2 2 use std::fs;
3 3 use std::path::Path;
4 4
5 - use makeover_geometry::SizeClass;
5 + use makeover_geometry::{Density, SizeClass};
6 6 use makeover_layout::{Column, Priority, Width};
7 7 use makeover_webview::Emit;
8 8 use makeover_webview::list::{Sizing, narrowing_css};
@@ -463,6 +463,111 @@
463 463 }
464 464 }
465 465
466 + /// Fail the build if a JS copy of the touch-density query has drifted from
467 + /// [`Density::Touch`].
468 + ///
469 + /// The sibling of [`check_breakpoints`], for the other axis and for the same
470 + /// reason. Density is a capability question -- what is pointing at the screen
471 + /// -- and until 2026-08-10 touch.js answered it with
472 + /// `('ontouchstart' in window) || navigator.maxTouchPoints > 0`, which asks the
473 + /// hardware instead and says yes to a touchscreen laptop driving a mouse. It
474 + /// now asks `matchMedia` with the crate's own condition, which is the string
475 + /// the generated geometry.css already keys the touch gap overrides on, so the
476 + /// gestures and the spacing agree by construction.
477 + ///
478 + /// Two files state the string rather than one, so this is what keeps them
479 + /// honest. An assertion and not a substitution, same as the breakpoints: a JS
480 + /// file that has to be generated to be correct stops being readable on its own.
481 + fn check_touch_density(frontend: &Path) {
482 + let want = Density::Touch.media_condition();
483 + let mut wrong: Vec<String> = Vec::new();
484 + let mut found = 0usize;
485 +
486 + let js_dir = frontend.join("js");
487 + let mut js_files: Vec<_> = fs::read_dir(&js_dir)
488 + .expect("read js/")
489 + .filter_map(Result::ok)
490 + .map(|e| e.path())
491 + .filter(|p| p.extension().is_some_and(|x| x == "js"))
492 + .collect();
493 + js_files.sort();
494 +
495 + for path in &js_files {
496 + let src = fs::read_to_string(path).expect("read js file");
497 + let name = path.file_name().unwrap().to_string_lossy();
498 +
499 + for (offset, literal) in touch_density_literals(&src) {
500 + found += 1;
501 + if literal != want {
502 + wrong.push(format!(
503 + " js/{name}:{} TOUCH_DENSITY = '{literal}'",
504 + line_of(&src, offset)
505 + ));
506 + }
507 + }
508 +
509 + // The sniff this replaced, so it cannot come back by copy-paste.
510 + for needle in ["ontouchstart", "maxTouchPoints"] {
511 + if let Some(offset) = src.find(needle) {
512 + wrong.push(format!(
513 + " js/{name}:{} {needle} -- device sniff, not a density question",
514 + line_of(&src, offset)
515 + ));
516 + }
517 + }
518 + }
519 +
520 + assert!(
521 + found > 0,
522 + "no TOUCH_DENSITY literal found in frontend/js.\n\n\
523 + touch.js and haptics.js each state makeover_geometry::Density::Touch's\n\
524 + media condition in a const of that name, and this check exists to keep\n\
525 + them equal to it. If the const was renamed, rename it here too rather\n\
526 + than dropping the check."
527 + );
528 +
529 + assert!(
530 + wrong.is_empty(),
531 + "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
532 + Density::Touch.media_condition() is: {want}\n\n\
533 + Wrong:\n{}\n\n\
534 + Fix the JS to state the crate's string. Never widen it to catch a\n\
535 + device the query misses: density is what is pointing at the screen,\n\
536 + and a laptop with a touchscreen and a mouse is a pointer device.",
537 + wrong.join("\n")
538 + );
539 +
540 + for path in &js_files {
541 + println!("cargo:rerun-if-changed={}", path.display());
542 + }
543 + }
544 +
545 + /// `(byte offset of the literal, its contents)` for every
546 + /// `const TOUCH_DENSITY = '...'` in a JS source.
547 + fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
548 + let mut out = Vec::new();
549 + let mut at = 0;
550 + while let Some(i) = src[at..].find("TOUCH_DENSITY") {
551 + let start = at + i;
552 + at = start + "TOUCH_DENSITY".len();
553 + // Only the declaration states the string; a use site reads the const.
554 + let Some(rest) = src[at..].strip_prefix(" = ") else {
555 + continue;
556 + };
557 + let open = at + " = ".len();
558 + let quote = match rest.chars().next() {
559 + Some(q @ ('\'' | '"')) => q,
560 + _ => continue,
561 + };
562 + let body = open + 1;
563 + if let Some(j) = src[body..].find(quote) {
564 + out.push((start, &src[body..body + j]));
565 + at = body + j + 1;
566 + }
567 + }
568 + out
569 + }
570 +
466 571 /// `(byte offset of the `@media`, the condition text before the `{`)`.
467 572 fn media_conditions(css: &str) -> Vec<(usize, &str)> {
468 573 let mut out = Vec::new();
@@ -553,6 +658,7 @@
553 658 // The generated files above cannot drift from SizeClass. The hand-written
554 659 // ones can, so they are checked rather than trusted.
555 660 check_breakpoints(&frontend);
661 + check_touch_density(&frontend);
556 662
557 663 println!("cargo:rerun-if-changed=build.rs");
558 664
@@ -24,13 +24,20 @@
24 24 // Set false by the first rejection: no plugin here, stop asking.
25 25 let supported = true;
26 26
27 + // makeover_geometry::Density::Touch's media condition, the same string
28 + // touch.js and the generated geometry.css key off. Asked here rather than
29 + // read off GoingsOn.touch so a haptic does not depend on the gesture
30 + // module having loaded; build.rs fails if the two copies drift from the
31 + // crate.
32 + const TOUCH_DENSITY = '(hover: none), (pointer: coarse)';
33 +
27 34 /**
28 35 * A fingertip is the only thing that can feel this. A pointer device is
29 36 * checked as well as the plugin because a rejection costs a round trip and
30 37 * a log line, and a mouse-driven desktop should not pay either.
31 38 */
32 39 function offered() {
33 - return supported && GoingsOn.touch && GoingsOn.touch.isTouchDevice;
40 + return supported && window.matchMedia(TOUCH_DENSITY).matches;
34 41 }
35 42
36 43 function fire(call) {
@@ -9,7 +9,16 @@
9 9
10 10 // Touch Detection
11 11
12 - const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
12 + // The density question, asked the way geometry.css asks it. This was a
13 + // capability sniff off the window and the navigator until 2026-08-10, the
14 + // same kind bootstrap-uimode.js was rewritten to stop doing: it asked what
15 + // hardware exists rather than what is pointing at the screen, so a laptop
16 + // with a touchscreen and a mouse came back touch and lost its hover
17 + // affordances. The string is makeover_geometry::Density::Touch's own media
18 + // condition, and build.rs fails the build if it drifts from the crate.
19 + const TOUCH_DENSITY = '(hover: none), (pointer: coarse)';
20 +
21 + const isTouchDevice = window.matchMedia(TOUCH_DENSITY).matches;
13 22
14 23 // Long Press
15 24