Skip to main content

max / makeover-build

Extract goingson's breakpoint drift check, and take the tuning widths as a parameter The touch check moved here in 0.17.0 and the width one did not, because TUNING_WIDTHS is an app's own list where the touch string is the crate's. A parameter settles it: the boundaries come from SizeClass either way, and what an app gets a say in is which widths tune the inside of a shell. Scans css/ and js/ recursively rather than styles.css and a flat js/. The generated stylesheets pass by construction, so a consumer never has to name which of its files are hand-written, and a nested one cannot be skipped.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 15:50 UTC
Signed with PGP, not checked
Commit: 38787dfd0a901990ebedbddd65ea886d7cde822d
Parent: e2f6e88
3 files changed, +310 insertions, -6 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-build"
3 - version = "0.17.0"
3 + version = "0.18.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 +308 -4
@@ -13,7 +13,7 @@
13 13
14 14 use std::path::{Path, PathBuf};
15 15
16 - use makeover_geometry::Density;
16 + use makeover_geometry::{Density, SizeClass};
17 17
18 18 /// The declaration this check reads. Shared vocabulary, not a parameter: two
19 19 /// apps and a server naming the same string want the same name for it.
@@ -109,12 +109,17 @@
109 109 }
110 110
111 111 /// Every `.js` file under `dir`, recursively, sorted.
112 + fn js_files(dir: &Path) -> Vec<PathBuf> {
113 + files_with_extension(dir, "js")
114 + }
115 +
116 + /// Every file under `dir` with extension `ext`, recursively, sorted.
112 117 ///
113 - /// Recursive because a consumer's scripts are not always one flat directory:
118 + /// Recursive because a consumer's frontend is not always one flat directory:
114 119 /// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
115 120 /// `static/`, and a check that silently skipped the nested half would report
116 121 /// clean on the files most likely to have been copied.
117 - fn js_files(dir: &Path) -> Vec<PathBuf> {
122 + fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
118 123 let mut out = Vec::new();
119 124 let mut stack = vec![dir.to_path_buf()];
120 125 while let Some(d) = stack.pop() {
@@ -125,7 +130,7 @@
125 130 let path = entry.path();
126 131 if path.is_dir() {
127 132 stack.push(path);
128 - } else if path.extension().is_some_and(|x| x == "js") {
133 + } else if path.extension().is_some_and(|x| x == ext) {
129 134 out.push(path);
130 135 }
131 136 }
@@ -163,6 +168,203 @@
163 168 src[..offset].matches('\n').count() + 1
164 169 }
165 170
171 + /// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
172 + ///
173 + /// Every pixel width named by a media query under `frontend/css` or
174 + /// `frontend/js`, recursively, must be a [`SizeClass`] boundary or one of
175 + /// `tuning_widths`.
176 + ///
177 + /// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted
178 + /// stylesheets and silently leaves every hand-written query behind, and what
179 + /// you get is not an error but a stylesheet that disagrees with itself at the
180 + /// old boundary.
181 + ///
182 + /// `tuning_widths` is the one thing an app gets a say in, which is why this
183 + /// takes a parameter where [`check_touch_density`] does not. A shell boundary
184 + /// is a [`SizeClass`] edge and belongs to makeover-geometry; a tuning width is
185 + /// a point inside a shell where something reflows without the shell changing --
186 + /// a dashboard dropping from three columns to two, a pane's width cap ending.
187 + /// Nothing switches shells at one, so it should not move when a size class
188 + /// does. Pass `&[]` if the app has none, and treat every addition as owing a
189 + /// note saying what it tunes: the list is where a genuine boundary goes to hide
190 + /// from this check.
191 + ///
192 + /// The generated stylesheets are scanned too, and pass by construction: they
193 + /// ask makeover-geometry for the number rather than stating it. Scanning them
194 + /// costs nothing and means a consumer never has to name which files are
195 + /// hand-written.
196 + ///
197 + /// Emits `cargo:rerun-if-changed` for every file it read.
198 + ///
199 + /// # Panics
200 + ///
201 + /// If `frontend/css` or `frontend/js` cannot be read, or if any width is
202 + /// neither a size-class boundary nor a declared tuning width. A build script
203 + /// has nowhere useful to return an error to.
204 + pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
205 + let frontend = frontend.as_ref();
206 + let allowed = allowed_widths(tuning_widths);
207 + let mut stale: Vec<String> = Vec::new();
208 +
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");
212 + // Comments first: a note about a breakpoint that used to be here is
213 + // prose, not a rule, and should not fail a build.
214 + let src = strip_block_comments(&raw);
215 + let name = display_name(frontend, path);
216 + for (offset, condition) in media_conditions(&src) {
217 + for px in media_widths(condition) {
218 + if !allowed.contains(&px) {
219 + stale.push(format!(
220 + " {name}:{} @media{condition} ({px}px)",
221 + line_of(&src, offset)
222 + ));
223 + }
224 + }
225 + }
226 + }
227 +
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 + assert!(
241 + stale.is_empty(),
242 + "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
243 + Allowed: {allowed:?}\n\
244 + ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
245 + Stale:\n{}\n\n\
246 + If a size class moved, update these to match. If one of these is a new\n\
247 + 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.",
249 + allowed
250 + .iter()
251 + .filter(|px| !tuning_widths.contains(px))
252 + .collect::<Vec<_>>(),
253 + stale.join("\n")
254 + );
255 +
256 + for path in css_files.iter().chain(&js_files) {
257 + println!("cargo:rerun-if-changed={}", path.display());
258 + }
259 + }
260 +
261 + /// A path as the frontend sees it, for an error a reader can act on.
262 + fn display_name(frontend: &Path, path: &Path) -> String {
263 + path.strip_prefix(frontend)
264 + .unwrap_or(path)
265 + .display()
266 + .to_string()
267 + }
268 +
269 + /// Every width a hand-written media query is allowed to name.
270 + ///
271 + /// Read out of [`SizeClass::media_condition`] rather than typed, which is the
272 + /// whole point: that is the one place the numbers come from, and a bump in
273 + /// makeover-geometry has to reach the stylesheet through here.
274 + fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
275 + let mut widths: Vec<u16> = SizeClass::all()
276 + .iter()
277 + .flat_map(|c| media_widths(&c.media_condition()))
278 + .collect();
279 + widths.extend_from_slice(tuning_widths);
280 + widths.sort_unstable();
281 + widths.dedup();
282 + widths
283 + }
284 +
285 + /// The pixel values in a media condition, in the order they appear.
286 + fn media_widths(condition: &str) -> Vec<u16> {
287 + let mut out = Vec::new();
288 + let mut rest = condition;
289 + while let Some(i) = rest.find("-width:") {
290 + rest = &rest[i + "-width:".len()..];
291 + let digits: String = rest
292 + .trim_start()
293 + .chars()
294 + .take_while(char::is_ascii_digit)
295 + .collect();
296 + if let Ok(px) = digits.parse() {
297 + out.push(px);
298 + }
299 + }
300 + out
301 + }
302 +
303 + /// `(byte offset of the `@media`, the condition text before the `{`)`.
304 + fn media_conditions(css: &str) -> Vec<(usize, &str)> {
305 + let mut out = Vec::new();
306 + let mut at = 0;
307 + while let Some(i) = css[at..].find("@media") {
308 + let start = at + i;
309 + let after = start + "@media".len();
310 + match css[after..].find('{') {
311 + Some(j) => {
312 + out.push((start, &css[after..after + j]));
313 + at = after + j;
314 + }
315 + None => break,
316 + }
317 + }
318 + out
319 + }
320 +
321 + /// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
322 + ///
323 + /// The parentheses are the whole test, and they have to be: a media condition
324 + /// is always parenthesized and a CSS declaration never is, so `'max-width:
325 + /// 320px'` in an inline-style string is not a breakpoint and must not read as
326 + /// one. goingson's shared-updater.js builds exactly that, and the first version
327 + /// of this check failed the build on it.
328 + fn js_widths(src: &str) -> Vec<(usize, u16)> {
329 + let mut out = Vec::new();
330 + for pat in ["(max-width:", "(min-width:"] {
331 + let mut at = 0;
332 + while let Some(i) = src[at..].find(pat) {
333 + let start = at + i;
334 + let rest = src[start + pat.len()..].trim_start();
335 + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
336 + if let Ok(px) = digits.parse()
337 + && rest[digits.len()..].starts_with("px)")
338 + {
339 + out.push((start, px));
340 + }
341 + at = start + pat.len();
342 + }
343 + }
344 + out
345 + }
346 +
347 + /// Replace every `/* ... */` with spaces, so byte offsets still line up.
348 + fn strip_block_comments(css: &str) -> String {
349 + let bytes = css.as_bytes();
350 + let mut out = String::with_capacity(css.len());
351 + let mut i = 0;
352 + while i < bytes.len() {
353 + if bytes[i..].starts_with(b"/*") {
354 + let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
355 + for c in css[i..end].chars() {
356 + out.push(if c == '\n' { '\n' } else { ' ' });
357 + }
358 + i = end;
359 + } else {
360 + let c = css[i..].chars().next().unwrap();
361 + out.push(c);
362 + i += c.len_utf8();
363 + }
364 + }
365 + out
366 + }
367 +
166 368 #[cfg(test)]
167 369 mod tests {
168 370 use super::*;
@@ -255,6 +457,108 @@
255 457 assert_eq!(js_files(&dir).len(), 1);
256 458 }
257 459
460 + fn frontend(name: &str) -> PathBuf {
461 + let dir = scratch(name);
462 + std::fs::create_dir_all(dir.join("css")).unwrap();
463 + std::fs::create_dir_all(dir.join("js")).unwrap();
464 + dir
465 + }
466 +
467 + /// A width every size class agrees is a boundary.
468 + fn boundary() -> u16 {
469 + SizeClass::Medium.min_px()
470 + }
471 +
472 + #[test]
473 + fn the_crates_own_boundaries_pass() {
474 + let dir = frontend("bp-ok");
475 + write(
476 + &dir,
477 + "css/styles.css",
478 + &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
479 + );
480 + check_breakpoints(&dir, &[]);
481 + }
482 +
483 + #[test]
484 + #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
485 + fn a_stale_css_width_fails() {
486 + let dir = frontend("bp-css");
487 + write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
488 + check_breakpoints(&dir, &[]);
489 + }
490 +
491 + #[test]
492 + #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
493 + fn a_stale_js_width_fails() {
494 + let dir = frontend("bp-js");
495 + write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
496 + check_breakpoints(&dir, &[]);
497 + }
498 +
499 + #[test]
500 + fn a_declared_tuning_width_passes() {
501 + let dir = frontend("bp-tuning");
502 + write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
503 + check_breakpoints(&dir, &[1400]);
504 + }
505 +
506 + #[test]
507 + fn a_width_in_a_comment_is_prose() {
508 + // The note explaining which breakpoint used to be here is not a rule,
509 + // and failing a build on documentation would teach people to delete it.
510 + let dir = frontend("bp-comment");
511 + write(
512 + &dir,
513 + "css/styles.css",
514 + "/* was @media (max-width: 768px) until the size classes landed */\n",
515 + );
516 + check_breakpoints(&dir, &[]);
517 + }
518 +
519 + #[test]
520 + fn an_unparenthesized_width_is_not_a_breakpoint() {
521 + // A JS string building an inline style states `max-width: 320px` with
522 + // no parentheses. It is a declaration, not a query, and the first
523 + // version of this check failed the build on one.
524 + let dir = frontend("bp-inline");
525 + write(
526 + &dir,
527 + "js/style.js",
528 + "el.style.cssText = 'max-width: 320px; display: block';\n",
529 + );
530 + check_breakpoints(&dir, &[]);
531 + }
532 +
533 + #[test]
534 + fn nested_css_is_read() {
535 + // Same argument as the touch check: the nested half is the half most
536 + // likely to be a copy.
537 + let dir = frontend("bp-nested");
538 + write(
539 + &dir,
540 + "css/screens/detail.css",
541 + "@media (max-width: 768px) { }\n",
542 + );
543 + let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
544 + assert!(found.is_err(), "a nested stylesheet must be scanned");
545 + }
546 +
547 + #[test]
548 + fn the_error_names_the_file_and_line() {
549 + let dir = frontend("bp-message");
550 + write(
551 + &dir,
552 + "css/styles.css",
553 + "body { }\n@media (max-width: 768px) { }\n",
554 + );
555 + let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
556 + let msg = err
557 + .downcast_ref::<String>()
558 + .expect("panic payload is a String");
559 + assert!(msg.contains("css/styles.css:2"), "got: {msg}");
560 + }
561 +
258 562 #[test]
259 563 fn both_quote_styles_read() {
260 564 let want = Density::Touch.media_condition();
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_touch_density;
48 + pub use drift::{check_breakpoints, 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.