Skip to main content

max / makeover-build

0.22.0: fail the build when an app re-specifies a generated primitive look-wave-2 F3 specified this guard on 2026-08-09 and it was never built, which is why the same defect class kept being found by audit rather than by a build. Both sort-caret defects fixed on 2026-08-11 were live for months and both would have failed this check the first time it ran. check_vocabulary reads an app's hand-written stylesheets the same way makeover-webview reads its own, and fails on any class the two have in common. The reason it is a failure and not a report: the generated sheet sits in @layer makeover and app CSS is unlayered, so a shared class is not two rules merging, it is the app winning silently against the design system. check_vocabulary_use is the other half and is a seal rather than a rule. Dead generated vocabulary was 42% in goingson, 67% in the MNW server and 84% in Balanced Breakfast when it was measured, so failing on it today would fail every build. Sealing the number converts it from something somebody notices into something that fails when it worsens. One-sided, the same shape as the server's frontend_globals seal: over the mark fails, under it warns and asks for the seal to be lowered, because a build that broke on deleting dead CSS would teach the wrong lesson. Both read line-shaped rather than parsing CSS, following check_sortable_headers. A build script is the wrong place for a real parser.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 01:37 UTC
Commit: bb27edcb9ab78014ae5d1477e3bd3cf6a977c6e3
Parent: 28524fe
3 files changed, +232 insertions, -3 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-build"
3 - version = "0.21.0"
3 + version = "0.22.0"
4 4 edition = "2024"
5 5 description = "Build-script support for the make-family design system: materialise makeover's themes and makeover-webview's stylesheet into a Tauri app's frontend, once, instead of copying the same twenty lines into every consumer's build.rs."
6 6 license = "MIT"
@@ -27,7 +27,7 @@
27 27 # and HTML were generated by two crates that never agreed. quasi-webview takes
28 28 # 0.27.0, so a generated app resolved 0.26 through this build dependency and
29 29 # 0.27 through its renderer until this line moved.
30 - makeover-webview = "0.28.0"
30 + makeover-webview = "0.29.0"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/drift.rs +226
@@ -14,6 +14,7 @@
14 14 use std::path::{Path, PathBuf};
15 15
16 16 use makeover_geometry::{Density, SizeClass};
17 + use makeover_webview::Emit;
17 18
18 19 /// The declaration this check reads. Shared vocabulary, not a parameter: two
19 20 /// apps and a server naming the same string want the same name for it.
@@ -401,6 +402,158 @@
401 402 out
402 403 }
403 404
405 + /// Fail the build if a hand-written stylesheet re-specifies a class the
406 + /// generated one already defines.
407 + ///
408 + /// The generated sheet sits in `@layer makeover`. Unlayered app CSS beats a
409 + /// layer by construction, whatever the specificity, so an app rule naming a
410 + /// generated class does not merge with it: it wins, silently, and the design
411 + /// system's version of that component stops applying to the one app most likely
412 + /// to be treated as the reference. Both sort-caret defects found on 2026-08-11
413 + /// were this, and both were live for months because nothing looked.
414 + ///
415 + /// `frontend` is the directory holding `css/`. `generated` names the sheets
416 + /// this crate writes, relative to `frontend/css`, which are skipped: the
417 + /// generated file naming a generated class is the point.
418 + ///
419 + /// Emits `cargo:rerun-if-changed` for every file it read.
420 + ///
421 + /// # Panics
422 + ///
423 + /// If `frontend/css` cannot be read, or if any hand-written sheet defines a
424 + /// rule for a generated class. A build script has nowhere useful to return an
425 + /// error to, and an app quietly overriding its own design system is worse than
426 + /// a failed build.
427 + pub fn check_vocabulary(frontend: impl AsRef<Path>, opts: &Emit, generated: &[&str]) {
428 + let frontend = frontend.as_ref();
429 + let css = frontend.join("css");
430 + let files: Vec<PathBuf> = files_with_extension(&css, "css")
431 + .into_iter()
432 + .filter(|p| {
433 + let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
434 + !generated.contains(&name.as_str())
435 + })
436 + .collect();
437 + check_vocabulary_paths(&files, opts, Some(frontend));
438 + }
439 +
440 + /// [`check_vocabulary`] against a named list of files rather than a tree.
441 + ///
442 + /// For a frontend whose generated and hand-written sheets share a directory, so
443 + /// a directory scan has nothing to point at. Same trade as
444 + /// [`check_breakpoints_files`]: the list is hand-maintained, and a stylesheet
445 + /// nobody adds to it is unchecked rather than failing.
446 + ///
447 + /// # Panics
448 + ///
449 + /// If a listed file cannot be read, or if any of them re-specifies a generated
450 + /// class.
451 + pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit) {
452 + let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
453 + check_vocabulary_paths(&paths, opts, None);
454 + }
455 +
456 + /// The check itself. `root`, when given, is stripped from reported paths.
457 + fn check_vocabulary_paths(paths: &[PathBuf], opts: &Emit, root: Option<&Path>) {
458 + let generated = makeover_webview::vocabulary::vocabulary(opts);
459 + let mut clashes: Vec<String> = Vec::new();
460 +
461 + for path in paths {
462 + println!("cargo::rerun-if-changed={}", path.display());
463 + let raw = std::fs::read_to_string(path)
464 + .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
465 + let name = match root {
466 + Some(root) => display_name(root, path),
467 + None => path.display().to_string(),
468 + };
469 + // Read the app's sheet the same way the crate reads its own, or the two
470 + // sides are not comparable.
471 + let local = makeover_webview::vocabulary::classes_in_css(&raw);
472 + for class in local.intersection(&generated) {
473 + clashes.push(format!(" {name} .{class}"));
474 + }
475 + }
476 +
477 + assert!(
478 + clashes.is_empty(),
479 + "{} hand-written rule(s) re-specify a class the generated stylesheet already \
480 + defines. App CSS is unlayered and beats @layer makeover, so each of these wins \
481 + over the design system silently:\n{}\n\nDelete the local rule, or, if it adds \
482 + something makeover does not answer for, move the addition onto a class of the \
483 + app's own. Count the consumers before deciding it is a divergence worth keeping.",
484 + clashes.len(),
485 + clashes.join("\n")
486 + );
487 + }
488 +
489 + /// Warn when the generated vocabulary has grown dead, and fail when it grows
490 + /// deader than the recorded high-water mark.
491 + ///
492 + /// A generated class no markup emits is a rule shipped to every user for
493 + /// nothing, and the proportion was large when it was first measured: 42% of the
494 + /// vocabulary unused in goingson, 67% in the MNW server, 84% in Balanced
495 + /// Breakfast. Those are not failures on their own or no app would build. What
496 + /// this converts is the direction: dead vocabulary becoming a number in a build
497 + /// script means a change that worsens it stops being something somebody
498 + /// notices.
499 + ///
500 + /// One-sided, the same shape as the MNW server's `frontend_globals` seal:
501 + /// exceeding `high_water` fails, coming in under it warns and asks for the seal
502 + /// to be lowered. A build that fails because dead CSS was deleted would teach
503 + /// the wrong lesson.
504 + ///
505 + /// `markup` is every file that can carry a class: templates, `.js`, `.html`,
506 + /// and any Rust that writes markup. A class is counted as used if its name
507 + /// appears in any of them, which is deliberately generous. A stricter reading
508 + /// would need to know how each app builds its class strings, and a check that
509 + /// guesses wrong fails a correct build.
510 + ///
511 + /// # Panics
512 + ///
513 + /// If a listed file cannot be read, or if more classes are unused than
514 + /// `high_water`.
515 + pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
516 + let generated = makeover_webview::vocabulary::names(opts);
517 + let mut haystack = String::new();
518 + for path in markup {
519 + let path = path.as_ref();
520 + println!("cargo::rerun-if-changed={}", path.display());
521 + haystack.push_str(
522 + &std::fs::read_to_string(path)
523 + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
524 + );
525 + haystack.push('\n');
526 + }
527 +
528 + let unused: Vec<&String> = generated
529 + .iter()
530 + .filter(|class| !haystack.contains(class.as_str()))
531 + .collect();
532 +
533 + assert!(
534 + unused.len() <= high_water,
535 + "{} of {} generated classes are emitted by no markup, above the recorded {}. \
536 + The vocabulary grew or the markup stopped using it:\n{}",
537 + unused.len(),
538 + generated.len(),
539 + high_water,
540 + unused
541 + .iter()
542 + .map(|c| format!(" .{c}"))
543 + .collect::<Vec<_>>()
544 + .join("\n")
545 + );
546 +
547 + if unused.len() < high_water {
548 + println!(
549 + "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
550 + lower the seal so it cannot grow back",
551 + unused.len(),
552 + high_water
553 + );
554 + }
555 + }
556 +
404 557 #[cfg(test)]
405 558 mod tests {
406 559 use super::*;
@@ -628,6 +781,79 @@
628 781 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
629 782 }
630 783
784 + #[test]
785 + fn a_rule_restating_a_generated_class_fails_and_names_it() {
786 + let dir = scratch("vocab-clash");
787 + // `.card` is makeover's. An app rule for it beats the generated one,
788 + // because app CSS is unlayered and the generated sheet is not.
789 + write(
790 + &dir,
791 + "css/styles.css",
792 + "body { color: red; }\n.card { box-shadow: none; }\n",
793 + );
794 + let err =
795 + std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[])).unwrap_err();
796 + let msg = err
797 + .downcast_ref::<String>()
798 + .expect("panic payload is a String");
799 + assert!(msg.contains(".card"), "got: {msg}");
800 + assert!(msg.contains("css/styles.css"), "got: {msg}");
801 + }
802 +
803 + #[test]
804 + fn an_app_class_of_its_own_is_left_alone() {
805 + let dir = scratch("vocab-clean");
806 + write(
807 + &dir,
808 + "css/styles.css",
809 + ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
810 + );
811 + check_vocabulary(&dir, &Emit::default(), &[]);
812 + }
813 +
814 + #[test]
815 + fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
816 + let dir = scratch("vocab-generated");
817 + let opts = Emit::default();
818 + write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
819 + // Without the skip this is the loudest failure possible: every class in
820 + // the vocabulary, reported as a clash with the vocabulary.
821 + check_vocabulary(&dir, &opts, &["layout.css"]);
822 + }
823 +
824 + #[test]
825 + fn a_prefixed_app_is_checked_against_its_own_prefix() {
826 + let dir = scratch("vocab-prefix");
827 + let opts = Emit {
828 + class_prefix: "mo-",
829 + ..Emit::default()
830 + };
831 + // Bare `.card` is the app's own class once the generated sheet writes
832 + // `.mo-card`, so this has to pass.
833 + write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
834 + check_vocabulary(&dir, &opts, &[]);
835 +
836 + let dir = scratch("vocab-prefix-clash");
837 + write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
838 + assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[])).is_err());
839 + }
840 +
841 + #[test]
842 + fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
843 + let dir = scratch("vocab-seal");
844 + let opts = Emit::default();
845 + let all = makeover_webview::vocabulary::names(&opts).len();
846 + // Markup naming nothing: every class is unused.
847 + write(&dir, "index.html", "<div></div>\n");
848 + let markup = [dir.join("index.html")];
849 +
850 + check_vocabulary_use(&markup, &opts, all);
851 + assert!(
852 + std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, all - 1)).is_err(),
853 + "a vocabulary deader than the seal has to fail"
854 + );
855 + }
856 +
631 857 #[test]
632 858 fn both_quote_styles_read() {
633 859 let want = Density::Touch.media_condition();
M src/lib.rs +4 -1
@@ -45,7 +45,10 @@
45 45
46 46 use std::path::Path;
47 47
48 - pub use drift::{check_breakpoints, check_breakpoints_files, check_touch_density};
48 + pub use drift::{
49 + check_breakpoints, check_breakpoints_files, check_touch_density, check_vocabulary,
50 + check_vocabulary_files, check_vocabulary_use,
51 + };
49 52
50 53 /// Re-exported so a consumer's `build.rs` needs one dependency rather than
51 54 /// three. Nothing here wraps it; the emitter's options are the emitter's.