Skip to main content

max / makeover-build

0.23.0: check the property a class carries, not the class Running 0.22.0's check against goingson found the grain was wrong, which is the argument for building a gate before believing a measurement. Nine classes are shared between goingson's stylesheet and the generated one and every one is deliberate: `.badge` sets shape in the app and colour in the generated sheet, with the app's own comment reading "Fill, edge and text colour come from the generated .badge in layout.css. Do not add background, border or box-shadow here." A check on names would have demanded deleting that. On properties the comment becomes the check, and the thing that actually goes wrong is what fails: app CSS is unlayered, so a shared property is taken off the design system silently. Some collisions are still correct. The sort caret's reserved gap is `content` on `.table-heading`'s unsorted arm and the generated caret is `content` on the sorted arm, a pairing rather than a clash. Separating those needs a selector matcher, and a check that guesses wrong about specificity fails correct builds, so the app declares the pair instead. A declared pair that stops colliding fails too: an exception nobody uses is where the next real collision lands and reads as company.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 01:45 UTC
Commit: e7535ee4c4bb060ce8a0c92ac28bca707c3c7e0e
Parent: bb27edc
2 files changed, +129 insertions, -36 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-build"
3 - version = "0.22.0"
3 + version = "0.23.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.29.0"
30 + makeover-webview = "0.30.1"
31 31 makeover-geometry = "0.7"
32 32
33 33 [lints.rust]
M src/drift.rs +127 -34
@@ -402,29 +402,58 @@
402 402 out
403 403 }
404 404
405 - /// Fail the build if a hand-written stylesheet re-specifies a class the
406 - /// generated one already defines.
405 + /// Fail the build if a hand-written stylesheet takes a property the generated
406 + /// one already sets on the same class.
407 407 ///
408 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.
409 + /// layer by construction, whatever the specificity, so an app declaration for a
410 + /// property makeover already sets does not merge with it: it wins, silently,
411 + /// and the design system's version of that component stops applying. Both
412 + /// sort-caret defects found on 2026-08-11 were this, and both were live for
413 + /// months because nothing looked.
414 + ///
415 + /// # Why properties and not class names
416 + ///
417 + /// A shared class name is not by itself a divergence, and the first run of this
418 + /// check against goingson is what settled it: nine classes are shared and every
419 + /// one is deliberate. `.badge` sets shape in the app and colour in the
420 + /// generated sheet, and the app's own comment beside it reads "Fill, edge and
421 + /// text colour come from the generated .badge in layout.css. Do not add
422 + /// background, border or box-shadow here." That arrangement is correct, so a
423 + /// check on names would have asked for it to be deleted. On properties, the
424 + /// comment becomes the check.
425 + ///
426 + /// # The exception list
427 + ///
428 + /// `allowed` is `(class, property)` pairs this app has reviewed and kept. There
429 + /// are legitimate ones: the sort caret's reserved gap is `content` on
430 + /// `.table-heading`'s unsorted arm and the generated caret is `content` on the
431 + /// sorted arm, which is a pairing rather than a collision. Deciding that here
432 + /// would need a selector matcher, and a check that guesses wrong about
433 + /// specificity fails correct builds -- so the app declares it instead, the same
434 + /// shape as quasi-webview's `RENDERER_OWN`.
435 + ///
436 + /// A pair that stops colliding fails too. A licence nobody is using is where
437 + /// the next real collision lands and reads as company.
414 438 ///
415 439 /// `frontend` is the directory holding `css/`. `generated` names the sheets
416 440 /// this crate writes, relative to `frontend/css`, which are skipped: the
417 - /// generated file naming a generated class is the point.
441 + /// generated file setting a generated property is the point.
418 442 ///
419 443 /// Emits `cargo:rerun-if-changed` for every file it read.
420 444 ///
421 445 /// # Panics
422 446 ///
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]) {
447 + /// If `frontend/css` cannot be read, if any hand-written sheet takes a
448 + /// generated property without declaring it, or if a declared pair no longer
449 + /// collides. A build script has nowhere useful to return an error to, and an
450 + /// app quietly overriding its own design system is worse than a failed build.
451 + pub fn check_vocabulary(
452 + frontend: impl AsRef<Path>,
453 + opts: &Emit,
454 + generated: &[&str],
455 + allowed: &[(&str, &str)],
456 + ) {
428 457 let frontend = frontend.as_ref();
429 458 let css = frontend.join("css");
430 459 let files: Vec<PathBuf> = files_with_extension(&css, "css")
@@ -434,7 +463,7 @@
434 463 !generated.contains(&name.as_str())
435 464 })
436 465 .collect();
437 - check_vocabulary_paths(&files, opts, Some(frontend));
466 + check_vocabulary_paths(&files, opts, Some(frontend), allowed);
438 467 }
439 468
440 469 /// [`check_vocabulary`] against a named list of files rather than a tree.
@@ -446,17 +475,23 @@
446 475 ///
447 476 /// # Panics
448 477 ///
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) {
478 + /// As [`check_vocabulary`].
479 + pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit, allowed: &[(&str, &str)]) {
452 480 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
453 - check_vocabulary_paths(&paths, opts, None);
481 + check_vocabulary_paths(&paths, opts, None, allowed);
454 482 }
455 483
456 484 /// 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);
485 + fn check_vocabulary_paths(
486 + paths: &[PathBuf],
487 + opts: &Emit,
488 + root: Option<&Path>,
489 + allowed: &[(&str, &str)],
490 + ) {
491 + let generated =
492 + makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
459 493 let mut clashes: Vec<String> = Vec::new();
494 + let mut seen: Vec<(String, String)> = Vec::new();
460 495
461 496 for path in paths {
462 497 println!("cargo::rerun-if-changed={}", path.display());
@@ -468,22 +503,46 @@
468 503 };
469 504 // Read the app's sheet the same way the crate reads its own, or the two
470 505 // 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}"));
506 + let local = makeover_webview::vocabulary::declarations_by_class(&raw);
507 + for (class, properties) in &local {
508 + let Some(theirs) = generated.get(class) else {
509 + continue;
510 + };
511 + for property in properties.intersection(theirs) {
512 + seen.push((class.clone(), property.clone()));
513 + if allowed.contains(&(class.as_str(), property.as_str())) {
514 + continue;
515 + }
516 + clashes.push(format!(" {name} .{class} {{ {property} }}"));
517 + }
474 518 }
475 519 }
476 520
477 521 assert!(
478 522 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.",
523 + "{} hand-written declaration(s) take a property the generated stylesheet \
524 + already sets on the same class. App CSS is unlayered and beats \
525 + @layer makeover, so each of these wins over the design system \
526 + silently:\n{}\n\nDelete the declaration, or, if it is a deliberate pairing \
527 + on a different selector arm, add (class, property) to this check's \
528 + allowed list and say why beside it. Count the consumers before deciding \
529 + a divergence is worth keeping.",
484 530 clashes.len(),
485 531 clashes.join("\n")
486 532 );
533 +
534 + let stale: Vec<&(&str, &str)> = allowed
535 + .iter()
536 + .filter(|(class, property)| {
537 + !seen.contains(&((*class).to_string(), (*property).to_string()))
538 + })
539 + .collect();
540 + assert!(
541 + stale.is_empty(),
542 + "the allowed list declares {stale:?}, which no longer collides with \
543 + anything. Delete the entries: an exception nobody is using is where the \
544 + next real collision lands and reads as company."
545 + );
487 546 }
488 547
489 548 /// Warn when the generated vocabulary has grown dead, and fail when it grows
@@ -791,12 +850,13 @@
791 850 "css/styles.css",
792 851 "body { color: red; }\n.card { box-shadow: none; }\n",
793 852 );
794 - let err =
795 - std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[])).unwrap_err();
853 + let err = std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[]))
854 + .unwrap_err();
796 855 let msg = err
797 856 .downcast_ref::<String>()
798 857 .expect("panic payload is a String");
799 858 assert!(msg.contains(".card"), "got: {msg}");
859 + assert!(msg.contains("box-shadow"), "got: {msg}");
800 860 assert!(msg.contains("css/styles.css"), "got: {msg}");
801 861 }
802 862
@@ -808,7 +868,7 @@
808 868 "css/styles.css",
809 869 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
810 870 );
811 - check_vocabulary(&dir, &Emit::default(), &[]);
871 + check_vocabulary(&dir, &Emit::default(), &[], &[]);
812 872 }
813 873
814 874 #[test]
@@ -818,7 +878,7 @@
818 878 write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
819 879 // Without the skip this is the loudest failure possible: every class in
820 880 // the vocabulary, reported as a clash with the vocabulary.
821 - check_vocabulary(&dir, &opts, &["layout.css"]);
881 + check_vocabulary(&dir, &opts, &["layout.css"], &[]);
822 882 }
823 883
824 884 #[test]
@@ -831,11 +891,44 @@
831 891 // Bare `.card` is the app's own class once the generated sheet writes
832 892 // `.mo-card`, so this has to pass.
833 893 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
834 - check_vocabulary(&dir, &opts, &[]);
894 + check_vocabulary(&dir, &opts, &[], &[]);
835 895
836 896 let dir = scratch("vocab-prefix-clash");
837 897 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
838 - assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[])).is_err());
898 + assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[])).is_err());
899 + }
900 +
901 + #[test]
902 + fn a_class_shared_without_a_shared_property_is_left_alone() {
903 + let dir = scratch("vocab-additive");
904 + // What goingson actually does: the generated `.badge` sets the text
905 + // colour and the app sets the shape. Same class, no argument.
906 + write(
907 + &dir,
908 + "css/styles.css",
909 + ".badge { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
910 + );
911 + check_vocabulary(&dir, &Emit::default(), &[], &[]);
912 + }
913 +
914 + #[test]
915 + fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
916 + let dir = scratch("vocab-allowed");
917 + write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
918 + check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")]);
919 +
920 + // The same licence against a sheet that no longer collides has to fail,
921 + // or the list only ever grows.
922 + let dir = scratch("vocab-allowed-stale");
923 + write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
924 + let err = std::panic::catch_unwind(|| {
925 + check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")])
926 + })
927 + .unwrap_err();
928 + let msg = err
929 + .downcast_ref::<String>()
930 + .expect("panic payload is a String");
931 + assert!(msg.contains("no longer collides"), "got: {msg}");
839 932 }
840 933
841 934 #[test]