Skip to main content

max / makeover-build

Add a named-list form of the breakpoint check, for a frontend with no tree to scan The MNW server keeps its generated stylesheets, its hand-written ones and a bundler's output all under static/, so a directory scan has nothing to point at and would read third-party CSS as drift. It had written its own copy of the check for that reason. check_breakpoints_files takes the list instead, and the server's list is the knob its layout needs. Carries the server's better closing advice into the shared message: a rule that is dimensional needs no threshold at all.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 16:00 UTC
Signed with PGP, not checked
Commit: a4b7fe7038e5d76bd428b8eee4cc7b90fe085afd
Parent: 38787df
3 files changed, +89 insertions, -20 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-build"
3 - version = "0.18.0"
3 + version = "0.19.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"
M src/drift.rs +87 -18
@@ -203,16 +203,61 @@
203 203 /// has nowhere useful to return an error to.
204 204 pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
205 205 let frontend = frontend.as_ref();
206 + let mut files = files_with_extension(&frontend.join("css"), "css");
207 + files.extend(js_files(&frontend.join("js")));
208 + check_paths(&files, tuning_widths, Some(frontend));
209 + }
210 +
211 + /// [`check_breakpoints`] against a named list of files rather than a tree.
212 + ///
213 + /// For a frontend whose generated and hand-written files share a directory, so
214 + /// there is nothing to point a directory scan at: the MNW server keeps both
215 + /// under `static/` alongside a bundler's output, and bundled third-party CSS
216 + /// is exactly the place a width nobody chose would come from.
217 + ///
218 + /// The cost is that the list is hand-maintained, and a stylesheet nobody adds
219 + /// to it is unchecked rather than failing. Prefer [`check_breakpoints`] where
220 + /// the layout allows it.
221 + ///
222 + /// A `.js` path is parsed as script and anything else as stylesheet, which is
223 + /// the only difference: a media condition is parenthesised in both.
224 + ///
225 + /// # Panics
226 + ///
227 + /// If a listed file cannot be read -- a listed path that no longer exists is a
228 + /// check silently covering less than it says -- or if any width is neither a
229 + /// size-class boundary nor a declared tuning width.
230 + pub fn check_breakpoints_files<P: AsRef<Path>>(paths: &[P], tuning_widths: &[u16]) {
231 + let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
232 + check_paths(&paths, tuning_widths, None);
233 + }
234 +
235 + /// The check itself. `root`, when given, is stripped from reported paths.
236 + fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
206 237 let allowed = allowed_widths(tuning_widths);
207 238 let mut stale: Vec<String> = Vec::new();
208 239
209 - let css_files = files_with_extension(&frontend.join("css"), "css");
210 - for path in &css_files {
211 - let raw = std::fs::read_to_string(path).expect("read css file");
240 + for path in paths {
241 + let raw = std::fs::read_to_string(path)
242 + .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
243 + let name = match root {
244 + Some(root) => display_name(root, path),
245 + None => path.display().to_string(),
246 + };
247 +
248 + if path.extension().is_some_and(|x| x == "js") {
249 + // No declarations in JS, so any parenthesised width is a query.
250 + for (offset, px) in js_widths(&raw) {
251 + if !allowed.contains(&px) {
252 + stale.push(format!(" {name}:{} ({px}px)", line_of(&raw, offset)));
253 + }
254 + }
255 + continue;
256 + }
257 +
212 258 // Comments first: a note about a breakpoint that used to be here is
213 259 // prose, not a rule, and should not fail a build.
214 260 let src = strip_block_comments(&raw);
215 - let name = display_name(frontend, path);
216 261 for (offset, condition) in media_conditions(&src) {
217 262 for px in media_widths(condition) {
218 263 if !allowed.contains(&px) {
@@ -225,18 +270,6 @@
225 270 }
226 271 }
227 272
228 - let js_files = js_files(&frontend.join("js"));
229 - for path in &js_files {
230 - let src = std::fs::read_to_string(path).expect("read js file");
231 - let name = display_name(frontend, path);
232 - // No declarations in JS, so any width condition is a media query.
233 - for (offset, px) in js_widths(&src) {
234 - if !allowed.contains(&px) {
235 - stale.push(format!(" {name}:{} ({px}px)", line_of(&src, offset)));
236 - }
237 - }
238 - }
239 -
240 273 assert!(
241 274 stale.is_empty(),
242 275 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
@@ -245,7 +278,10 @@
245 278 Stale:\n{}\n\n\
246 279 If a size class moved, update these to match. If one of these is a new\n\
247 280 tuning width inside the wide shell rather than a shell boundary, add it\n\
248 - to the caller's tuning list with a note saying what it tunes.",
281 + to the caller's tuning list with a note saying what it tunes.\n\n\
282 + Best of all, make the rule dimensional so it needs no threshold: a grid\n\
283 + wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
284 + clamp(). A threshold is for what appears and disappears.",
249 285 allowed
250 286 .iter()
251 287 .filter(|px| !tuning_widths.contains(px))
@@ -253,7 +289,7 @@
253 289 stale.join("\n")
254 290 );
255 291
256 - for path in css_files.iter().chain(&js_files) {
292 + for path in paths {
257 293 println!("cargo:rerun-if-changed={}", path.display());
258 294 }
259 295 }
@@ -544,6 +580,39 @@
544 580 assert!(found.is_err(), "a nested stylesheet must be scanned");
545 581 }
546 582
583 + #[test]
584 + fn a_named_list_is_checked() {
585 + let dir = frontend("bp-list");
586 + write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
587 + let listed = dir.join("css/style.css");
588 + let err =
589 + std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
590 + let msg = err.downcast_ref::<String>().expect("String payload");
591 + assert!(msg.contains("style.css:1"), "got: {msg}");
592 + }
593 +
594 + #[test]
595 + #[should_panic(expected = "read ")]
596 + fn a_listed_file_that_is_gone_fails() {
597 + // The list is hand-maintained, so a path that stopped existing is a
598 + // check quietly covering less than it claims. Louder than skipping it.
599 + let dir = frontend("bp-missing");
600 + check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
601 + }
602 +
603 + #[test]
604 + fn a_listed_js_file_is_parsed_as_script() {
605 + // The unparenthesized-declaration rule is what separates the two, and
606 + // picking the parser off the extension is the whole difference.
607 + let dir = frontend("bp-list-js");
608 + write(
609 + &dir,
610 + "js/style.js",
611 + "el.style.cssText = 'max-width: 320px';\n",
612 + );
613 + check_breakpoints_files(&[dir.join("js/style.js")], &[]);
614 + }
615 +
547 616 #[test]
548 617 fn the_error_names_the_file_and_line() {
549 618 let dir = frontend("bp-message");
M src/lib.rs +1 -1
@@ -45,7 +45,7 @@
45 45
46 46 use std::path::Path;
47 47
48 - pub use drift::{check_breakpoints, check_touch_density};
48 + pub use drift::{check_breakpoints, check_breakpoints_files, check_touch_density};
49 49
50 50 /// Re-exported so a consumer's `build.rs` needs one dependency rather than
51 51 /// three. Nothing here wraps it; the emitter's options are the emitter's.