Skip to main content

max / makeover-build

0.50.0: check the element rules too, and say what actually beats what check_vocabulary read an app's rules by the classes in their selectors, so a rule carrying no class was invisible to it. That is where the MNW server's lost button tone came from: `button { color: var(--content) }` in a later layer, beating the generated `.button[data-tone]` on every described act, with the check reporting nothing for months. Six primitives were in that state and none of them could be seen. The second pass pairs the bare element rules against the classes those elements carry, per makeover-webview's ELEMENT_CLASSES. It skips a scoped rule, which reaches one region rather than every element of its kind, and a property the app names on the class from a rule that outranks the element rule -- both are the app's and both are in the same layer, so that contest is specificity, and `.field` does not beat `input[type="text"]`. allowed_elements is the escape hatch, and a stale entry fails the way a stale (class, property) does. The remedy is usually neither list: a `revert-layer` handoff on the arms makeover paints, which is a statement in the stylesheet rather than a note in a build script. makeover-webview 0.58.0 stopped reading those as overrides, so the class list drops its deferral entries -- nineteen of them in the MNW server. The panic message also claimed the app's CSS is unlayered. It has not been since 2026-08-10; the app wins by layer order instead, and both routes lose the contest identically. It says so now.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 16:52 UTC
Signed with PGP, not checked
Commit: 197ee2109a61b38987dca225cdfe8a616bac708c
Parent: 3736484
2 files changed, +285 insertions, -27 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-build"
3 - version = "0.49.1"
3 + version = "0.50.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"
@@ -37,7 +37,7 @@
37 37 # a cell into a run of leaves, so "this text is a link" became a member the run
38 38 # holds rather than a field on one container, and the renderer needed a name for
39 39 # it that was not invented locally.
40 - makeover-webview = "0.57.0"
40 + makeover-webview = "0.58.0"
41 41 makeover-geometry = "0.7"
42 42
43 43 [lints.rust]
M src/drift.rs +283 -25
@@ -403,14 +403,44 @@
403 403 }
404 404
405 405 /// Fail the build if a hand-written stylesheet takes a property the generated
406 - /// one already sets on the same class.
406 + /// one already sets -- on the same class, or on an element that carries it.
407 407 ///
408 - /// The generated sheet sits in `@layer makeover`. Unlayered app CSS beats a
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.
408 + /// The generated sheet sits in `@layer makeover`. App CSS beats it whatever the
409 + /// specificity, either by being unlayered or by sitting in a layer the app's
410 + /// order statement puts after `makeover`, so an app declaration for a property
411 + /// makeover already sets does not merge with it: it wins, silently, and the
412 + /// design system's version of that component stops applying. Both sort-caret
413 + /// defects found on 2026-08-11 were this, and both were live for months because
414 + /// nothing looked.
415 + ///
416 + /// # Two passes, because a rule can carry no class
417 + ///
418 + /// The class pass is the original: an app `.button` against the generated
419 + /// `.button`. It reads rules by the classes in their selectors, so a rule with
420 + /// no class in it is invisible to it -- and `button { color: var(--content) }`
421 + /// is exactly that. It sets the same property the generated `.button` sets, on
422 + /// every described act in the app, and it took `.button[data-tone="danger"]`'s
423 + /// tone with it: a destructive act rendered indistinguishable from an ordinary
424 + /// one for months, with this check reporting nothing.
425 + ///
426 + /// The element pass closes it. `makeover_webview::vocabulary::ELEMENT_CLASSES`
427 + /// says which generated classes an element can carry -- CSS cannot say it, and
428 + /// the renderer can -- and a bare element rule taking a property the design
429 + /// system sets on one of those classes is the same defect as the class case.
430 + ///
431 + /// Two things are not reported, both deliberately:
432 + ///
433 + /// - A scoped rule (`.page button`). It reaches the elements inside one
434 + /// region rather than every one of them, so whether it lands on a described
435 + /// act depends on where that act renders. The certain case is the one this
436 + /// reads.
437 + /// - A property the app names on the class itself, from a rule that outranks
438 + /// the element rule. Both are the app's and both sit in the same layer, so
439 + /// that one contest is settled by specificity, and what reaches the design
440 + /// system is the class rule -- which the class pass has already, reported
441 + /// or reviewed. The rank matters: `.field` does not beat
442 + /// `input[type="text"]`, and a handoff written as the weaker of the two is
443 + /// a remedy that looks written and is not.
414 444 ///
415 445 /// # Why properties and not class names
416 446 ///
@@ -438,6 +468,16 @@
438 468 /// a claim about who owns a property, and it expires when the design system
439 469 /// takes the property back.
440 470 ///
471 + /// `allowed_elements` is the same thing one pass down: `(element, class,
472 + /// property)` triples where a bare element rule reaching a generated class has
473 + /// been read and kept.
474 + ///
475 + /// The remedy is usually neither list. A later layer can hand the property back
476 + /// with `revert-layer`, which says "whatever the design system set here, keep
477 + /// it" on the arms makeover actually paints, and that is a statement in the
478 + /// stylesheet rather than a note in a build script. Handoffs are not reported by
479 + /// either pass.
480 + ///
441 481 /// A pair that stops colliding fails too. A licence nobody is using is where
442 482 /// the next real collision lands and reads as company.
443 483 ///
@@ -458,6 +498,7 @@
458 498 opts: &Emit,
459 499 generated: &[&str],
460 500 allowed: &[(&str, &str)],
501 + allowed_elements: &[(&str, &str, &str)],
461 502 ) {
462 503 let frontend = frontend.as_ref();
463 504 let css = frontend.join("css");
@@ -468,7 +509,7 @@
468 509 !generated.contains(&name.as_str())
469 510 })
470 511 .collect();
471 - check_vocabulary_paths(&files, opts, Some(frontend), allowed);
512 + check_vocabulary_paths(&files, opts, Some(frontend), allowed, allowed_elements);
472 513 }
473 514
474 515 /// [`check_vocabulary`] against a named list of files rather than a tree.
@@ -481,9 +522,14 @@
481 522 /// # Panics
482 523 ///
483 524 /// As [`check_vocabulary`].
484 - pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit, allowed: &[(&str, &str)]) {
525 + pub fn check_vocabulary_files<P: AsRef<Path>>(
526 + paths: &[P],
527 + opts: &Emit,
528 + allowed: &[(&str, &str)],
529 + allowed_elements: &[(&str, &str, &str)],
530 + ) {
485 531 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
486 - check_vocabulary_paths(&paths, opts, None, allowed);
532 + check_vocabulary_paths(&paths, opts, None, allowed, allowed_elements);
487 533 }
488 534
489 535 /// The check itself. `root`, when given, is stripped from reported paths.
@@ -492,11 +538,14 @@
492 538 opts: &Emit,
493 539 root: Option<&Path>,
494 540 allowed: &[(&str, &str)],
541 + allowed_elements: &[(&str, &str, &str)],
495 542 ) {
496 543 let generated =
497 544 makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
498 545 let mut clashes: Vec<String> = Vec::new();
499 546 let mut seen: Vec<(String, String)> = Vec::new();
547 + let mut element_clashes: Vec<String> = Vec::new();
548 + let mut element_seen: Vec<(String, String, String)> = Vec::new();
500 549
501 550 for path in paths {
502 551 println!("cargo::rerun-if-changed={}", path.display());
@@ -521,21 +570,79 @@
521 570 clashes.push(format!(" {name} .{class} {{ {property} }}"));
522 571 }
523 572 }
573 +
574 + // The second pass: a rule carrying no class at all, which the first one
575 + // cannot see. What the app says about the class itself settles the
576 + // pair, but only from a rule that outranks the element rule -- both are
577 + // the app's and both are in the same layer, so this one contest is
578 + // decided by specificity. `.field` does not beat `input[type="text"]`.
579 + let mentioned = makeover_webview::vocabulary::mentions_by_class(&raw);
580 + let by_element = makeover_webview::vocabulary::declarations_by_element(&raw);
581 + for (element, properties) in &by_element {
582 + for class in makeover_webview::vocabulary::classes_for_element(element, opts) {
583 + let Some(theirs) = generated.get(&class) else {
584 + continue;
585 + };
586 + for (property, rank) in properties {
587 + if !theirs.contains(property) {
588 + continue;
589 + }
590 + // A tie goes to the class rule: at equal specificity the
591 + // later rule wins, and a remedy is written after the rule
592 + // it remedies.
593 + let spoken_for = mentioned
594 + .get(&class)
595 + .and_then(|properties| properties.get(property))
596 + .is_some_and(|theirs| theirs >= rank);
597 + if spoken_for {
598 + continue;
599 + }
600 + element_seen.push((element.clone(), class.clone(), property.clone()));
601 + if allowed_elements.contains(&(
602 + element.as_str(),
603 + class.as_str(),
604 + property.as_str(),
605 + )) {
606 + continue;
607 + }
608 + element_clashes.push(format!(
609 + " {name} {element} {{ {property} }} beats .{class} {{ {property} }}"
610 + ));
611 + }
612 + }
613 + }
524 614 }
525 615
526 616 assert!(
527 617 clashes.is_empty(),
528 618 "{} hand-written declaration(s) take a property the generated stylesheet \
529 - already sets on the same class. App CSS is unlayered and beats \
530 - @layer makeover, so each of these wins over the design system \
531 - silently:\n{}\n\nDelete the declaration, or, if it is a deliberate pairing \
532 - on a different selector arm, add (class, property) to this check's \
533 - allowed list and say why beside it. Count the consumers before deciding \
534 - a divergence is worth keeping.",
619 + already sets on the same class. App CSS wins over @layer makeover, \
620 + whether by a later layer or by being unlayered, so each of these wins \
621 + over the design system silently:\n{}\n\nDelete the declaration, or, if \
622 + it is a deliberate pairing on a different selector arm, add \
623 + (class, property) to this check's allowed list and say why beside it. \
624 + Count the consumers before deciding a divergence is worth keeping.",
535 625 clashes.len(),
536 626 clashes.join("\n")
537 627 );
538 628
629 + assert!(
630 + element_clashes.is_empty(),
631 + "{} hand-written element rule(s) take a property the generated \
632 + stylesheet sets on a class that element carries. App CSS wins over \
633 + @layer makeover, whether by a later layer or by being unlayered, so a \
634 + described component rendered on one of these elements loses the \
635 + design system's version of that property silently -- which is how a \
636 + destructive act came to look like an ordinary one:\n{}\n\nHand the \
637 + property back on the arms makeover paints \
638 + (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
639 + rule so it stops reaching described markup, or add \
640 + (element, class, property) to this check's allowed-elements list and \
641 + say why beside it.",
642 + element_clashes.len(),
643 + element_clashes.join("\n")
644 + );
645 +
539 646 let stale: Vec<&(&str, &str)> = allowed
540 647 .iter()
541 648 .filter(|(class, property)| {
@@ -548,6 +655,23 @@
548 655 anything. Delete the entries: an exception nobody is using is where the \
549 656 next real collision lands and reads as company."
550 657 );
658 +
659 + let stale: Vec<&(&str, &str, &str)> = allowed_elements
660 + .iter()
661 + .filter(|(element, class, property)| {
662 + !element_seen.contains(&(
663 + (*element).to_string(),
664 + (*class).to_string(),
665 + (*property).to_string(),
666 + ))
667 + })
668 + .collect();
669 + assert!(
670 + stale.is_empty(),
671 + "the allowed-elements list declares {stale:?}, which no longer collides \
672 + with anything. Delete the entries: an exception nobody is using is \
673 + where the next real collision lands and reads as company."
674 + );
551 675 }
552 676
553 677 /// Warn when the generated vocabulary has grown dead, and fail when it grows
@@ -855,8 +979,9 @@
855 979 "css/styles.css",
856 980 "body { color: red; }\n.card { box-shadow: none; }\n",
857 981 );
858 - let err = std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[]))
859 - .unwrap_err();
982 + let err =
983 + std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
984 + .unwrap_err();
860 985 let msg = err
861 986 .downcast_ref::<String>()
862 987 .expect("panic payload is a String");
@@ -873,7 +998,7 @@
873 998 "css/styles.css",
874 999 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
875 1000 );
876 - check_vocabulary(&dir, &Emit::default(), &[], &[]);
1001 + check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
877 1002 }
878 1003
879 1004 #[test]
@@ -883,7 +1008,7 @@
883 1008 write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
884 1009 // Without the skip this is the loudest failure possible: every class in
885 1010 // the vocabulary, reported as a clash with the vocabulary.
886 - check_vocabulary(&dir, &opts, &["layout.css"], &[]);
1011 + check_vocabulary(&dir, &opts, &["layout.css"], &[], &[]);
887 1012 }
888 1013
889 1014 #[test]
@@ -896,11 +1021,11 @@
896 1021 // Bare `.card` is the app's own class once the generated sheet writes
897 1022 // `.mo-card`, so this has to pass.
898 1023 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
899 - check_vocabulary(&dir, &opts, &[], &[]);
1024 + check_vocabulary(&dir, &opts, &[], &[], &[]);
900 1025
901 1026 let dir = scratch("vocab-prefix-clash");
902 1027 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
903 - assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[])).is_err());
1028 + assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[], &[])).is_err());
904 1029 }
905 1030
906 1031 #[test]
@@ -913,21 +1038,154 @@
913 1038 "css/styles.css",
914 1039 ".badge { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
915 1040 );
916 - check_vocabulary(&dir, &Emit::default(), &[], &[]);
1041 + check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
917 1042 }
918 1043
919 1044 #[test]
920 1045 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
921 1046 let dir = scratch("vocab-allowed");
922 1047 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
923 - check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")]);
1048 + check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
924 1049
925 1050 // The same licence against a sheet that no longer collides has to fail,
926 1051 // or the list only ever grows.
927 1052 let dir = scratch("vocab-allowed-stale");
928 1053 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
929 1054 let err = std::panic::catch_unwind(|| {
930 - check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")]);
1055 + check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
1056 + })
1057 + .unwrap_err();
1058 + let msg = err
1059 + .downcast_ref::<String>()
1060 + .expect("panic payload is a String");
1061 + assert!(msg.contains("no longer collides"), "got: {msg}");
1062 + }
1063 +
1064 + #[test]
1065 + fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
1066 + let dir = scratch("vocab-element");
1067 + // The defect that shipped for months: no class in the selector, so the
1068 + // class pass sees nothing, and every described act in the app takes the
1069 + // app's bevel instead of the design system's.
1070 + write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1071 + let err =
1072 + std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1073 + .unwrap_err();
1074 + let msg = err
1075 + .downcast_ref::<String>()
1076 + .expect("panic payload is a String");
1077 + assert!(msg.contains("select {"), "got: {msg}");
1078 + assert!(msg.contains(".field"), "got: {msg}");
1079 + assert!(msg.contains("box-shadow"), "got: {msg}");
1080 + assert!(msg.contains("css/styles.css"), "got: {msg}");
1081 + }
1082 +
1083 + #[test]
1084 + fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
1085 + let dir = scratch("vocab-element-handoff");
1086 + // What a consumer writes instead of an exception: the element rule
1087 + // stays, and a later layer gives the property back on the class. The
1088 + // check has to read that as settled or the remedy fails the build it
1089 + // was written to fix.
1090 + write(
1091 + &dir,
1092 + "css/styles.css",
1093 + "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1094 + );
1095 + check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1096 + }
1097 +
1098 + #[test]
1099 + fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
1100 + let dir = scratch("vocab-element-spoken-for");
1101 + // Within the app's own sheet the class rule outranks the bare element
1102 + // rule, so what reaches the design system is `.button`, not `button`.
1103 + // The class pass has that pair -- here as a reviewed one -- and
1104 + // reporting it twice would ask for two remedies for one collision.
1105 + write(
1106 + &dir,
1107 + "css/styles.css",
1108 + "select { box-shadow: none; }\n.field { box-shadow: none; }\n",
1109 + );
1110 + check_vocabulary(&dir, &Emit::default(), &[], &[("field", "box-shadow")], &[]);
1111 + }
1112 +
1113 + #[test]
1114 + fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
1115 + let dir = scratch("vocab-element-weak-handoff");
1116 + // The shape that reads as fixed and is not: both rules are the app's
1117 + // and both are in the same layer, so the state on the element rule
1118 + // decides, and the described field keeps the app's sunken fill.
1119 + write(
1120 + &dir,
1121 + "css/styles.css",
1122 + "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1123 + );
1124 + let err =
1125 + std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1126 + .unwrap_err();
1127 + let msg = err
1128 + .downcast_ref::<String>()
1129 + .expect("panic payload is a String");
1130 + assert!(msg.contains(".field"), "got: {msg}");
1131 +
1132 + // Written to win, it is.
1133 + let dir = scratch("vocab-element-strong-handoff");
1134 + write(
1135 + &dir,
1136 + "css/styles.css",
1137 + "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
1138 + );
1139 + check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1140 + }
1141 +
1142 + #[test]
1143 + fn a_scoped_rule_is_not_read_as_an_element_rule() {
1144 + let dir = scratch("vocab-element-scoped");
1145 + // It reaches the buttons inside one region rather than every button, so
1146 + // whether it lands on a described act depends on where that act
1147 + // renders. Failing the build on a guess is the worse error.
1148 + write(
1149 + &dir,
1150 + "css/styles.css",
1151 + ".wizard select { box-shadow: none; }\n",
1152 + );
1153 + check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1154 + }
1155 +
1156 + #[test]
1157 + fn an_element_the_design_system_never_renders_onto_is_left_alone() {
1158 + let dir = scratch("vocab-element-unpaired");
1159 + // `.card` is a container: no generated class sits on a `<footer>`, so
1160 + // there is nothing for this rule to take.
1161 + write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
1162 + check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1163 + }
1164 +
1165 + #[test]
1166 + fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
1167 + let dir = scratch("vocab-element-allowed");
1168 + write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1169 + check_vocabulary(
1170 + &dir,
1171 + &Emit::default(),
1172 + &[],
1173 + &[],
1174 + &[("select", "field", "box-shadow")],
1175 + );
1176 +
1177 + // And the same licence against a sheet that no longer collides fails,
1178 + // for the reason the class list's does.
1179 + let dir = scratch("vocab-element-allowed-stale");
1180 + write(&dir, "css/styles.css", "select { padding: 2px; }\n");
1181 + let err = std::panic::catch_unwind(|| {
1182 + check_vocabulary(
1183 + &dir,
1184 + &Emit::default(),
1185 + &[],
1186 + &[],
1187 + &[("select", "field", "box-shadow")],
1188 + );
931 1189 })
932 1190 .unwrap_err();
933 1191 let msg = err