Skip to main content

max / goingson

Take the breakpoint check from makeover-build rather than carrying it build.rs loses ~300 lines: the width check, its two parsers and the comment stripper are makeover-build 0.18's now, where balanced_breakfast reaches the same code rather than a copy. TUNING_WIDTHS stays here and is passed in, since 1024 and 1400 tune the inside of the wide shell and belong to this app. Also lands the manifest half of the 0.17 touch-density adoption, which was committed in build.rs and not in Cargo.toml, and takes makeover-webview 0.23.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 15:55 UTC
Signed with PGP, not checked
Commit: 092aec025010529e543a918d8875674c7877cc2e
Parent: 4eba494
3 files changed, +44 insertions, -307 deletions
M Cargo.lock +30 -13
@@ -2234,8 +2234,8 @@
2234 2234 "makeover",
2235 2235 "makeover-build",
2236 2236 "makeover-geometry",
2237 - "makeover-layout",
2238 - "makeover-webview",
2237 + "makeover-layout 0.12.0",
2238 + "makeover-webview 0.23.0",
2239 2239 "notify",
2240 2240 "notify-debouncer-mini",
2241 2241 "open",
@@ -3398,13 +3398,13 @@
3398 3398
3399 3399 [[package]]
3400 3400 name = "makeover-build"
3401 - version = "0.15.0"
3401 + version = "0.18.0"
3402 3402 source = "registry+https://github.com/rust-lang/crates.io-index"
3403 - checksum = "b517a2d67aba7766eca3b8ee174cef4100fb51f64455c22fe804dac9f5b64c6c"
3403 + checksum = "31befafd278832b811b0b7ffa30d4caaae4397178268010f796a05d0c0934554"
3404 3404 dependencies = [
3405 3405 "makeover",
3406 3406 "makeover-geometry",
3407 - "makeover-webview",
3407 + "makeover-webview 0.23.0",
3408 3408 ]
3409 3409
3410 3410 [[package]]
@@ -3419,6 +3419,12 @@
3419 3419 source = "registry+https://github.com/rust-lang/crates.io-index"
3420 3420 checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
3421 3421
3422 + [[package]]
3423 + name = "makeover-layout"
3424 + version = "0.13.0"
3425 + source = "registry+https://github.com/rust-lang/crates.io-index"
3426 + checksum = "3e503a11485fe2e17d9e7b0326cb7fef8a9ff0968e1d37d101b45ea08298b0b9"
3427 +
3422 3428 [[package]]
3423 3429 name = "makeover-touch"
3424 3430 version = "0.8.1"
@@ -3426,7 +3432,7 @@
3426 3432 checksum = "d9427a7a6890849f5c815a0e77a63faa4791d7a37e7efaff4802bcafa3b8235a"
3427 3433 dependencies = [
3428 3434 "makeover-geometry",
3429 - "makeover-layout",
3435 + "makeover-layout 0.12.0",
3430 3436 ]
3431 3437
3432 3438 [[package]]
@@ -3436,18 +3442,29 @@
3436 3442 checksum = "88982f3e29d40336e9748e9adafa88278d123c42875aad178e0cea640a128d57"
3437 3443 dependencies = [
3438 3444 "makeover",
3439 - "makeover-layout",
3445 + "makeover-layout 0.12.0",
3440 3446 "ratatui",
3441 3447 ]
3442 3448
3443 3449 [[package]]
3444 3450 name = "makeover-webview"
3445 - version = "0.22.1"
3451 + version = "0.23.0"
3446 3452 source = "registry+https://github.com/rust-lang/crates.io-index"
3447 - checksum = "9110b5a405a2378d2ef7d93efedc2b65f4bdd32a3de902ee8192b20a8dc3f7f5"
3453 + checksum = "0303225f8e8be6ef237ed268652e7fbfd501bd17843323680a18a9df30b38fe3"
3448 3454 dependencies = [
3449 3455 "makeover-geometry",
3450 - "makeover-layout",
3456 + "makeover-layout 0.12.0",
3457 + "makeover-touch",
3458 + ]
3459 +
3460 + [[package]]
3461 + name = "makeover-webview"
3462 + version = "0.24.0"
3463 + source = "registry+https://github.com/rust-lang/crates.io-index"
3464 + checksum = "6e88bf1eafa0806bbef3002435b91bfb28ce25a989fe484f19eb1e16c58eafc6"
3465 + dependencies = [
3466 + "makeover-geometry",
3467 + "makeover-layout 0.13.0",
3451 3468 "makeover-touch",
3452 3469 ]
3453 3470
@@ -4662,7 +4679,7 @@
4662 4679 name = "quasi-router"
4663 4680 version = "0.1.0"
4664 4681 dependencies = [
4665 - "makeover-layout",
4682 + "makeover-layout 0.13.0",
4666 4683 ]
4667 4684
4668 4685 [[package]]
@@ -4680,8 +4697,8 @@
4680 4697 version = "0.1.0"
4681 4698 dependencies = [
4682 4699 "docengine",
4683 - "makeover-layout",
4684 - "makeover-webview",
4700 + "makeover-layout 0.13.0",
4701 + "makeover-webview 0.24.0",
4685 4702 "quasi-http",
4686 4703 "quasi-router",
4687 4704 ]
@@ -15,12 +15,14 @@
15 15
16 16 [build-dependencies]
17 17 tauri-build = { workspace = true }
18 - # Materialises all three generated files: themes/, geometry.css, layout.css.
19 - makeover-build = "0.15"
18 + # Materialises all three generated files: themes/, geometry.css, layout.css,
19 + # and holds both drift checks this build.rs used to carry itself: touch density
20 + # as of 0.17, breakpoints as of 0.18.
21 + makeover-build = "0.18"
20 22 # The table CSS is generated here too: the columns are this app's, so the
21 23 # shared helper cannot know them, but the tracks and the narrowing rules come
22 24 # from the description rather than from hand-written nth-child cuts.
23 - makeover-webview = "0.22.1"
25 + makeover-webview = "0.23.0"
24 26 makeover-layout = "0.12.0"
25 27 # Width. Direct rather than through makeover-webview, because the narrow table
26 28 # pass keys off SizeClass::Compact and a boundary reached transitively is a
@@ -110,7 +112,7 @@
110 112 # The forms emitter and the description it renders. Runtime deps, not build:
111 113 # form markup depends on the value, the error and the options at the moment a
112 114 # modal opens, so it cannot be materialised the way the stylesheet is.
113 - makeover-webview = "0.22.1"
115 + makeover-webview = "0.23.0"
114 116 makeover-layout = "0.12.0"
115 117 # Browser opening
116 118 open = { workspace = true }
M src-tauri/build.rs +8 -290
@@ -2,7 +2,7 @@
2 2 use std::fs;
3 3 use std::path::Path;
4 4
5 - use makeover_geometry::{Density, SizeClass};
5 + use makeover_geometry::SizeClass;
6 6 use makeover_layout::{Column, Priority, Width};
7 7 use makeover_webview::Emit;
8 8 use makeover_webview::list::{Sizing, narrowing_css};
@@ -350,293 +350,6 @@
350 350 /// when a size class does, and neither should be derived from one.
351 351 const TUNING_WIDTHS: &[u16] = &[1024, 1400];
352 352
353 - /// Every width a hand-written media query is allowed to name.
354 - ///
355 - /// Read out of [`SizeClass::media_condition`] rather than typed, which is the
356 - /// whole point: this is the one place the numbers come from, and a bump in
357 - /// makeover-geometry has to reach the stylesheet through here.
358 - fn allowed_widths() -> Vec<u16> {
359 - let mut widths: Vec<u16> = SizeClass::all()
360 - .iter()
361 - .flat_map(|c| media_widths(&c.media_condition()))
362 - .collect();
363 - widths.extend_from_slice(TUNING_WIDTHS);
364 - widths.sort_unstable();
365 - widths.dedup();
366 - widths
367 - }
368 -
369 - /// The pixel values in a media condition, in the order they appear.
370 - fn media_widths(condition: &str) -> Vec<u16> {
371 - let mut out = Vec::new();
372 - let mut rest = condition;
373 - while let Some(i) = rest.find("-width:") {
374 - rest = &rest[i + "-width:".len()..];
375 - let digits: String = rest
376 - .trim_start()
377 - .chars()
378 - .take_while(char::is_ascii_digit)
379 - .collect();
380 - if let Ok(px) = digits.parse() {
381 - out.push(px);
382 - }
383 - }
384 - out
385 - }
386 -
387 - /// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
388 - ///
389 - /// The generated files cannot drift: they ask makeover-geometry for the
390 - /// number. The hand-written ones state it, and until 2026-08-01 they stated a
391 - /// different thing entirely -- a `.ui-mode-mobile` class off a user-agent
392 - /// sniff -- so the boundary living in one place is new and worth keeping.
393 - ///
394 - /// Without this, moving `SizeClass::Medium::min_px` regenerates tables.css and
395 - /// silently leaves 16 media queries and one `matchMedia` call behind, and what
396 - /// you get is not an error but a stylesheet that disagrees with itself at the
397 - /// old boundary. Cheaper to read a panic naming the line.
398 - ///
399 - /// Deliberately an assertion and not a substitution. Generating the queries
400 - /// would mean styles.css became a template, and it is worth something that you
401 - /// can still open it in a browser and have it work.
402 - fn check_breakpoints(frontend: &Path) {
403 - let allowed = allowed_widths();
404 - let mut stale: Vec<String> = Vec::new();
405 -
406 - let css_path = frontend.join("css/styles.css");
407 - let css = fs::read_to_string(&css_path).expect("read styles.css");
408 - // Comments first: a note about the 768px breakpoint that used to be here
409 - // is prose, not a rule, and should not fail a build.
410 - let css = strip_block_comments(&css);
411 - for (offset, condition) in media_conditions(&css) {
412 - for px in media_widths(condition) {
413 - if !allowed.contains(&px) {
414 - stale.push(format!(
415 - " css/styles.css:{} @media{condition} ({px}px)",
416 - line_of(&css, offset)
417 - ));
418 - }
419 - }
420 - }
421 -
422 - let js_dir = frontend.join("js");
423 - let mut js_files: Vec<_> = fs::read_dir(&js_dir)
424 - .expect("read js/")
425 - .filter_map(Result::ok)
426 - .map(|e| e.path())
427 - .filter(|p| p.extension().is_some_and(|x| x == "js"))
428 - .collect();
429 - js_files.sort();
430 - for path in &js_files {
431 - let src = fs::read_to_string(path).expect("read js file");
432 - // No declarations in JS, so any width condition is a media query.
433 - for (offset, px) in js_widths(&src) {
434 - if !allowed.contains(&px) {
435 - stale.push(format!(
436 - " js/{}:{} ({px}px)",
437 - path.file_name().unwrap().to_string_lossy(),
438 - line_of(&src, offset)
439 - ));
440 - }
441 - }
442 - }
443 -
444 - assert!(
445 - stale.is_empty(),
446 - "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
447 - Allowed: {allowed:?}\n\
448 - ({:?} come from SizeClass; {TUNING_WIDTHS:?} are TUNING_WIDTHS in build.rs.)\n\n\
449 - Stale:\n{}\n\n\
450 - If a size class moved, update these to match. If one of these is a new\n\
451 - tuning width inside the wide shell rather than a shell boundary, add it\n\
452 - to TUNING_WIDTHS with a note saying what it tunes.",
453 - allowed
454 - .iter()
455 - .filter(|px| !TUNING_WIDTHS.contains(px))
456 - .collect::<Vec<_>>(),
457 - stale.join("\n")
458 - );
459 -
460 - println!("cargo:rerun-if-changed={}", css_path.display());
461 - for path in &js_files {
462 - println!("cargo:rerun-if-changed={}", path.display());
463 - }
464 - }
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 -
571 - /// `(byte offset of the `@media`, the condition text before the `{`)`.
572 - fn media_conditions(css: &str) -> Vec<(usize, &str)> {
573 - let mut out = Vec::new();
574 - let mut at = 0;
575 - while let Some(i) = css[at..].find("@media") {
576 - let start = at + i;
577 - let after = start + "@media".len();
578 - match css[after..].find('{') {
579 - Some(j) => {
580 - out.push((start, &css[after..after + j]));
581 - at = after + j;
582 - }
583 - None => break,
584 - }
585 - }
586 - out
587 - }
588 -
589 - /// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
590 - ///
591 - /// The parentheses are the whole test, and they have to be: a media condition
592 - /// is always parenthesized and a CSS declaration never is, so `'max-width:
593 - /// 320px'` in an inline-style string is not a breakpoint and must not read as
594 - /// one. shared-updater.js builds exactly that, and the first version of this
595 - /// check failed the build on it.
596 - fn js_widths(src: &str) -> Vec<(usize, u16)> {
597 - let mut out = Vec::new();
598 - for pat in ["(max-width:", "(min-width:"] {
599 - let mut at = 0;
600 - while let Some(i) = src[at..].find(pat) {
601 - let start = at + i;
602 - let rest = src[start + pat.len()..].trim_start();
603 - let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
604 - if let Ok(px) = digits.parse()
605 - && rest[digits.len()..].starts_with("px)")
606 - {
607 - out.push((start, px));
608 - }
609 - at = start + pat.len();
610 - }
611 - }
612 - out
613 - }
614 -
615 - /// Replace every `/* ... */` with spaces, so byte offsets still line up.
616 - fn strip_block_comments(css: &str) -> String {
617 - let bytes = css.as_bytes();
618 - let mut out = String::with_capacity(css.len());
619 - let mut i = 0;
620 - while i < bytes.len() {
621 - if bytes[i..].starts_with(b"/*") {
622 - let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
623 - for c in css[i..end].chars() {
624 - out.push(if c == '\n' { '\n' } else { ' ' });
625 - }
626 - i = end;
627 - } else {
628 - let c = css[i..].chars().next().unwrap();
629 - out.push(c);
630 - i += c.len_utf8();
631 - }
632 - }
633 - out
634 - }
635 -
636 - fn line_of(src: &str, offset: usize) -> usize {
637 - src[..offset].matches('\n').count() + 1
638 - }
639 -
640 353 fn main() {
641 354 // All three generated files: themes/, geometry.css, layout.css. The
642 355 // geometry emitter moved out too once density selection was settled:
@@ -657,8 +370,13 @@
657 370
658 371 // The generated files above cannot drift from SizeClass. The hand-written
659 372 // ones can, so they are checked rather than trusted.
660 - check_breakpoints(&frontend);
661 - check_touch_density(&frontend);
373 + //
374 + // Both checks live in makeover-build now, where balanced_breakfast and the
375 + // server reach the same check rather than a copy of it. The width one took
376 + // a parameter to get there: TUNING_WIDTHS is this app's own list, where the
377 + // touch string is makeover-geometry's and no app gets a say.
378 + makeover_build::check_breakpoints(&frontend, TUNING_WIDTHS);
379 + makeover_build::check_touch_density(frontend.join("js"));
662 380
663 381 println!("cargo:rerun-if-changed=build.rs");
664 382