Skip to main content

max / makeover-build

47.7 KB · 1224 lines History Blame Raw
1 //! Checks that a hand-written frontend still agrees with the crate that
2 //! generates its siblings.
3 //!
4 //! The generated files cannot drift: they ask makeover-geometry for the answer.
5 //! The hand-written ones state it, and a stylesheet or a script that disagrees
6 //! with the crate is not an error at any point -- it is a rule that quietly
7 //! stops matching where it used to. Cheaper to read a panic naming the line.
8 //!
9 //! Deliberately assertions and not substitutions. A JS or CSS file that has to
10 //! be generated to be correct stops being readable on its own, and it is worth
11 //! something that you can still open the frontend in a browser and have it
12 //! work.
13
14 use std::path::{Path, PathBuf};
15
16 use makeover_geometry::{Density, SizeClass};
17 use makeover_webview::Emit;
18
19 /// The declaration this check reads. Shared vocabulary, not a parameter: two
20 /// apps and a server naming the same string want the same name for it.
21 const CONST_NAME: &str = "TOUCH_DENSITY";
22
23 /// The capability sniffs the media query replaced, so neither can come back by
24 /// copy-paste.
25 ///
26 /// Both ask the hardware what it has rather than what is pointing at the
27 /// screen, so both say yes to a touchscreen laptop driving a mouse.
28 const SNIFFS: &[&str] = &["ontouchstart", "maxTouchPoints"];
29
30 /// Fail the build if a JS copy of the touch-density query has drifted from
31 /// [`Density::Touch`].
32 ///
33 /// Every `.js` file under `js_dir`, recursively, must state the crate's own
34 /// media condition in a `const TOUCH_DENSITY = '...'`, at least one file must
35 /// declare it, and no file may name a capability sniff.
36 ///
37 /// The string is the crate's and no app gets a say in it, which is why this
38 /// check takes no policy argument. The generated `geometry.css` already keys
39 /// its touch gap overrides on the same condition, so the gestures and the
40 /// spacing agree by construction rather than by two people remembering.
41 ///
42 /// Emits `cargo:rerun-if-changed` for every file it read.
43 ///
44 /// # Panics
45 ///
46 /// If `js_dir` cannot be read, if no declaration is found, or if any file
47 /// disagrees with the crate. A build script has nowhere useful to return an
48 /// error to, and a frontend that disagrees with its own stylesheet is worse
49 /// than a failed build.
50 pub fn check_touch_density(js_dir: impl AsRef<Path>) {
51 let js_dir = js_dir.as_ref();
52 let want = Density::Touch.media_condition();
53 let mut wrong: Vec<String> = Vec::new();
54 let mut found = 0usize;
55
56 let files = js_files(js_dir);
57 for path in &files {
58 let src = std::fs::read_to_string(path).expect("read js file");
59 let name = path
60 .strip_prefix(js_dir)
61 .unwrap_or(path)
62 .display()
63 .to_string();
64
65 for (offset, literal) in touch_density_literals(&src) {
66 found += 1;
67 if literal != want {
68 wrong.push(format!(
69 " {name}:{} {CONST_NAME} = '{literal}'",
70 line_of(&src, offset)
71 ));
72 }
73 }
74
75 for needle in SNIFFS {
76 if let Some(offset) = src.find(needle) {
77 wrong.push(format!(
78 " {name}:{} {needle} -- device sniff, not a density question",
79 line_of(&src, offset)
80 ));
81 }
82 }
83 }
84
85 assert!(
86 found > 0,
87 "no {CONST_NAME} literal found under {}.\n\n\
88 A frontend that asks whether it is being touched states\n\
89 makeover_geometry::Density::Touch's media condition in a const of that\n\
90 name, and this check exists to keep every copy equal to it. If the\n\
91 const was renamed, rename it back rather than dropping the check; if\n\
92 this frontend genuinely asks no density question, drop the call.",
93 js_dir.display()
94 );
95
96 assert!(
97 wrong.is_empty(),
98 "hand-written touch detection disagrees with makeover_geometry::Density.\n\n\
99 Density::Touch.media_condition() is: {want}\n\n\
100 Wrong:\n{}\n\n\
101 Fix the JS to state the crate's string. Never widen it to catch a\n\
102 device the query misses: density is what is pointing at the screen,\n\
103 and a laptop with a touchscreen and a mouse is a pointer device.",
104 wrong.join("\n")
105 );
106
107 for path in &files {
108 println!("cargo:rerun-if-changed={}", path.display());
109 }
110 }
111
112 /// Every `.js` file under `dir`, recursively, sorted.
113 fn js_files(dir: &Path) -> Vec<PathBuf> {
114 files_with_extension(dir, "js")
115 }
116
117 /// Every file under `dir` with extension `ext`, recursively, sorted.
118 ///
119 /// Recursive because a consumer's frontend is not always one flat directory:
120 /// the Tauri apps keep `js/*.js`, the server keeps subdirectories under
121 /// `static/`, and a check that silently skipped the nested half would report
122 /// clean on the files most likely to have been copied.
123 fn files_with_extension(dir: &Path, ext: &str) -> Vec<PathBuf> {
124 let mut out = Vec::new();
125 let mut stack = vec![dir.to_path_buf()];
126 while let Some(d) = stack.pop() {
127 for entry in std::fs::read_dir(&d)
128 .unwrap_or_else(|e| panic!("read {}: {e}", d.display()))
129 .flatten()
130 {
131 let path = entry.path();
132 if path.is_dir() {
133 stack.push(path);
134 } else if path.extension().is_some_and(|x| x == ext) {
135 out.push(path);
136 }
137 }
138 }
139 out.sort();
140 out
141 }
142
143 /// `(byte offset of the declaration, the literal's contents)` for every
144 /// `const TOUCH_DENSITY = '...'` in a JS source.
145 fn touch_density_literals(src: &str) -> Vec<(usize, &str)> {
146 let mut out = Vec::new();
147 let mut at = 0;
148 while let Some(i) = src[at..].find(CONST_NAME) {
149 let start = at + i;
150 at = start + CONST_NAME.len();
151 // Only the declaration states the string; a use site reads the const.
152 let Some(rest) = src[at..].strip_prefix(" = ") else {
153 continue;
154 };
155 let open = at + " = ".len();
156 let Some(quote @ ('\'' | '"')) = rest.chars().next() else {
157 continue;
158 };
159 let body = open + 1;
160 if let Some(j) = src[body..].find(quote) {
161 out.push((start, &src[body..body + j]));
162 at = body + j + 1;
163 }
164 }
165 out
166 }
167
168 fn line_of(src: &str, offset: usize) -> usize {
169 src[..offset].matches('\n').count() + 1
170 }
171
172 /// Fail the build if a hand-written breakpoint has drifted from [`SizeClass`].
173 ///
174 /// Every pixel width named by a media query under `frontend/css` or
175 /// `frontend/js`, recursively, must be a [`SizeClass`] boundary or one of
176 /// `tuning_widths`.
177 ///
178 /// Without this, moving `SizeClass::Medium::min_px` regenerates the emitted
179 /// stylesheets and silently leaves every hand-written query behind, and what
180 /// you get is not an error but a stylesheet that disagrees with itself at the
181 /// old boundary.
182 ///
183 /// `tuning_widths` is the one thing an app gets a say in, which is why this
184 /// takes a parameter where [`check_touch_density`] does not. A shell boundary
185 /// is a [`SizeClass`] edge and belongs to makeover-geometry; a tuning width is
186 /// a point inside a shell where something reflows without the shell changing --
187 /// a dashboard dropping from three columns to two, a pane's width cap ending.
188 /// Nothing switches shells at one, so it should not move when a size class
189 /// does. Pass `&[]` if the app has none, and treat every addition as owing a
190 /// note saying what it tunes: the list is where a genuine boundary goes to hide
191 /// from this check.
192 ///
193 /// The generated stylesheets are scanned too, and pass by construction: they
194 /// ask makeover-geometry for the number rather than stating it. Scanning them
195 /// costs nothing and means a consumer never has to name which files are
196 /// hand-written.
197 ///
198 /// Emits `cargo:rerun-if-changed` for every file it read.
199 ///
200 /// # Panics
201 ///
202 /// If `frontend/css` or `frontend/js` cannot be read, or if any width is
203 /// neither a size-class boundary nor a declared tuning width. A build script
204 /// has nowhere useful to return an error to.
205 pub fn check_breakpoints(frontend: impl AsRef<Path>, tuning_widths: &[u16]) {
206 let frontend = frontend.as_ref();
207 let mut files = files_with_extension(&frontend.join("css"), "css");
208 files.extend(js_files(&frontend.join("js")));
209 check_paths(&files, tuning_widths, Some(frontend));
210 }
211
212 /// [`check_breakpoints`] against a named list of files rather than a tree.
213 ///
214 /// For a frontend whose generated and hand-written files share a directory, so
215 /// there is nothing to point a directory scan at: the MNW server keeps both
216 /// under `static/` alongside a bundler's output, and bundled third-party CSS
217 /// is exactly the place a width nobody chose would come from.
218 ///
219 /// The cost is that the list is hand-maintained, and a stylesheet nobody adds
220 /// to it is unchecked rather than failing. Prefer [`check_breakpoints`] where
221 /// the layout allows it.
222 ///
223 /// A `.js` path is parsed as script and anything else as stylesheet, which is
224 /// the only difference: a media condition is parenthesised in both.
225 ///
226 /// # Panics
227 ///
228 /// If a listed file cannot be read -- a listed path that no longer exists is a
229 /// check silently covering less than it says -- or if any width is neither a
230 /// size-class boundary nor a declared tuning width.
231 pub fn check_breakpoints_files<P: AsRef<Path>>(paths: &[P], tuning_widths: &[u16]) {
232 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
233 check_paths(&paths, tuning_widths, None);
234 }
235
236 /// The check itself. `root`, when given, is stripped from reported paths.
237 fn check_paths(paths: &[PathBuf], tuning_widths: &[u16], root: Option<&Path>) {
238 let allowed = allowed_widths(tuning_widths);
239 let mut stale: Vec<String> = Vec::new();
240
241 for path in paths {
242 let raw = std::fs::read_to_string(path)
243 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
244 let name = match root {
245 Some(root) => display_name(root, path),
246 None => path.display().to_string(),
247 };
248
249 if path.extension().is_some_and(|x| x == "js") {
250 // No declarations in JS, so any parenthesised width is a query.
251 for (offset, px) in js_widths(&raw) {
252 if !allowed.contains(&px) {
253 stale.push(format!(" {name}:{} ({px}px)", line_of(&raw, offset)));
254 }
255 }
256 continue;
257 }
258
259 // Comments first: a note about a breakpoint that used to be here is
260 // prose, not a rule, and should not fail a build.
261 let src = strip_block_comments(&raw);
262 for (offset, condition) in media_conditions(&src) {
263 for px in media_widths(condition) {
264 if !allowed.contains(&px) {
265 stale.push(format!(
266 " {name}:{} @media{condition} ({px}px)",
267 line_of(&src, offset)
268 ));
269 }
270 }
271 }
272 }
273
274 assert!(
275 stale.is_empty(),
276 "hand-written breakpoints disagree with makeover_geometry::SizeClass.\n\n\
277 Allowed: {allowed:?}\n\
278 ({:?} come from SizeClass; {tuning_widths:?} were passed as tuning widths.)\n\n\
279 Stale:\n{}\n\n\
280 If a size class moved, update these to match. If one of these is a new\n\
281 tuning width inside the wide shell rather than a shell boundary, add it\n\
282 to the caller's tuning list with a note saying what it tunes.\n\n\
283 Best of all, make the rule dimensional so it needs no threshold: a grid\n\
284 wants repeat(auto-fit, minmax(<content floor>, 1fr)) and a size wants\n\
285 clamp(). A threshold is for what appears and disappears.",
286 allowed
287 .iter()
288 .filter(|px| !tuning_widths.contains(px))
289 .collect::<Vec<_>>(),
290 stale.join("\n")
291 );
292
293 for path in paths {
294 println!("cargo:rerun-if-changed={}", path.display());
295 }
296 }
297
298 /// A path as the frontend sees it, for an error a reader can act on.
299 fn display_name(frontend: &Path, path: &Path) -> String {
300 path.strip_prefix(frontend)
301 .unwrap_or(path)
302 .display()
303 .to_string()
304 }
305
306 /// Every width a hand-written media query is allowed to name.
307 ///
308 /// Read out of [`SizeClass::media_condition`] rather than typed, which is the
309 /// whole point: that is the one place the numbers come from, and a bump in
310 /// makeover-geometry has to reach the stylesheet through here.
311 fn allowed_widths(tuning_widths: &[u16]) -> Vec<u16> {
312 let mut widths: Vec<u16> = SizeClass::all()
313 .iter()
314 .flat_map(|c| media_widths(&c.media_condition()))
315 .collect();
316 widths.extend_from_slice(tuning_widths);
317 widths.sort_unstable();
318 widths.dedup();
319 widths
320 }
321
322 /// The pixel values in a media condition, in the order they appear.
323 fn media_widths(condition: &str) -> Vec<u16> {
324 let mut out = Vec::new();
325 let mut rest = condition;
326 while let Some(i) = rest.find("-width:") {
327 rest = &rest[i + "-width:".len()..];
328 let digits: String = rest
329 .trim_start()
330 .chars()
331 .take_while(char::is_ascii_digit)
332 .collect();
333 if let Ok(px) = digits.parse() {
334 out.push(px);
335 }
336 }
337 out
338 }
339
340 /// `(byte offset of the `@media`, the condition text before the `{`)`.
341 fn media_conditions(css: &str) -> Vec<(usize, &str)> {
342 let mut out = Vec::new();
343 let mut at = 0;
344 while let Some(i) = css[at..].find("@media") {
345 let start = at + i;
346 let after = start + "@media".len();
347 match css[after..].find('{') {
348 Some(j) => {
349 out.push((start, &css[after..after + j]));
350 at = after + j;
351 }
352 None => break,
353 }
354 }
355 out
356 }
357
358 /// `(byte offset, pixel value)` for every `(max-width: Npx)` in a JS source.
359 ///
360 /// The parentheses are the whole test, and they have to be: a media condition
361 /// is always parenthesized and a CSS declaration never is, so `'max-width:
362 /// 320px'` in an inline-style string is not a breakpoint and must not read as
363 /// one. goingson's shared-updater.js builds exactly that, and the first version
364 /// of this check failed the build on it.
365 fn js_widths(src: &str) -> Vec<(usize, u16)> {
366 let mut out = Vec::new();
367 for pat in ["(max-width:", "(min-width:"] {
368 let mut at = 0;
369 while let Some(i) = src[at..].find(pat) {
370 let start = at + i;
371 let rest = src[start + pat.len()..].trim_start();
372 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
373 if let Ok(px) = digits.parse()
374 && rest[digits.len()..].starts_with("px)")
375 {
376 out.push((start, px));
377 }
378 at = start + pat.len();
379 }
380 }
381 out
382 }
383
384 /// Replace every `/* ... */` with spaces, so byte offsets still line up.
385 fn strip_block_comments(css: &str) -> String {
386 let bytes = css.as_bytes();
387 let mut out = String::with_capacity(css.len());
388 let mut i = 0;
389 while i < bytes.len() {
390 if bytes[i..].starts_with(b"/*") {
391 let end = css[i..].find("*/").map_or(bytes.len(), |j| i + j + 2);
392 for c in css[i..end].chars() {
393 out.push(if c == '\n' { '\n' } else { ' ' });
394 }
395 i = end;
396 } else {
397 let c = css[i..].chars().next().unwrap();
398 out.push(c);
399 i += c.len_utf8();
400 }
401 }
402 out
403 }
404
405 /// Fail the build if a hand-written stylesheet takes a property the generated
406 /// one already sets -- on the same class, or on an element that carries it.
407 ///
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.
444 ///
445 /// # Why properties and not class names
446 ///
447 /// A shared class name is not by itself a divergence, and the first run of this
448 /// check against goingson is what settled it: nine classes are shared and every
449 /// one is deliberate. `.badge` sets shape in the app and colour in the
450 /// generated sheet, and the app's own comment beside it reads "Fill, edge and
451 /// text colour come from the generated .badge in layout.css. Do not add
452 /// background, border or box-shadow here." That arrangement is correct, so a
453 /// check on names would have asked for it to be deleted. On properties, the
454 /// comment becomes the check.
455 ///
456 /// # The exception list
457 ///
458 /// `allowed` is `(class, property)` pairs this app has reviewed and kept.
459 /// Deciding which are legitimate here would need a selector matcher, and a check
460 /// that guesses wrong about specificity fails correct builds -- so the app
461 /// declares it instead, the same shape as quasi-webview's `RENDERER_OWN`.
462 ///
463 /// The example this section used to give was the sort caret: `content` on
464 /// `.table-heading`'s unsorted arm reserved the gap, `content` on the sorted arm
465 /// was the generated glyph, and the two were a pairing rather than a collision.
466 /// makeover-webview 0.31.0 emits both arms itself, so that pair is now an app
467 /// overriding the caret and the check is right to fail it. A reviewed pairing is
468 /// a claim about who owns a property, and it expires when the design system
469 /// takes the property back.
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 ///
481 /// A pair that stops colliding fails too. A licence nobody is using is where
482 /// the next real collision lands and reads as company.
483 ///
484 /// `frontend` is the directory holding `css/`. `generated` names the sheets
485 /// this crate writes, relative to `frontend/css`, which are skipped: the
486 /// generated file setting a generated property is the point.
487 ///
488 /// Emits `cargo:rerun-if-changed` for every file it read.
489 ///
490 /// # Panics
491 ///
492 /// If `frontend/css` cannot be read, if any hand-written sheet takes a
493 /// generated property without declaring it, or if a declared pair no longer
494 /// collides. A build script has nowhere useful to return an error to, and an
495 /// app quietly overriding its own design system is worse than a failed build.
496 pub fn check_vocabulary(
497 frontend: impl AsRef<Path>,
498 opts: &Emit,
499 generated: &[&str],
500 allowed: &[(&str, &str)],
501 allowed_elements: &[(&str, &str, &str)],
502 ) {
503 let frontend = frontend.as_ref();
504 let css = frontend.join("css");
505 let files: Vec<PathBuf> = files_with_extension(&css, "css")
506 .into_iter()
507 .filter(|p| {
508 let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
509 !generated.contains(&name.as_str())
510 })
511 .collect();
512 check_vocabulary_paths(&files, opts, Some(frontend), allowed, allowed_elements);
513 }
514
515 /// [`check_vocabulary`] against a named list of files rather than a tree.
516 ///
517 /// For a frontend whose generated and hand-written sheets share a directory, so
518 /// a directory scan has nothing to point at. Same trade as
519 /// [`check_breakpoints_files`]: the list is hand-maintained, and a stylesheet
520 /// nobody adds to it is unchecked rather than failing.
521 ///
522 /// # Panics
523 ///
524 /// As [`check_vocabulary`].
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 ) {
531 let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
532 check_vocabulary_paths(&paths, opts, None, allowed, allowed_elements);
533 }
534
535 /// The check itself. `root`, when given, is stripped from reported paths.
536 fn check_vocabulary_paths(
537 paths: &[PathBuf],
538 opts: &Emit,
539 root: Option<&Path>,
540 allowed: &[(&str, &str)],
541 allowed_elements: &[(&str, &str, &str)],
542 ) {
543 let generated =
544 makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
545 let mut clashes: Vec<String> = Vec::new();
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();
549
550 for path in paths {
551 println!("cargo::rerun-if-changed={}", path.display());
552 let raw = std::fs::read_to_string(path)
553 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
554 let name = match root {
555 Some(root) => display_name(root, path),
556 None => path.display().to_string(),
557 };
558 // Read the app's sheet the same way the crate reads its own, or the two
559 // sides are not comparable.
560 let local = makeover_webview::vocabulary::declarations_by_class(&raw);
561 for (class, properties) in &local {
562 let Some(theirs) = generated.get(class) else {
563 continue;
564 };
565 for property in properties.intersection(theirs) {
566 seen.push((class.clone(), property.clone()));
567 if allowed.contains(&(class.as_str(), property.as_str())) {
568 continue;
569 }
570 clashes.push(format!(" {name} .{class} {{ {property} }}"));
571 }
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 }
614 }
615
616 assert!(
617 clashes.is_empty(),
618 "{} hand-written declaration(s) take a property the generated stylesheet \
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.",
625 clashes.len(),
626 clashes.join("\n")
627 );
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
646 let stale: Vec<&(&str, &str)> = allowed
647 .iter()
648 .filter(|(class, property)| {
649 !seen.contains(&((*class).to_string(), (*property).to_string()))
650 })
651 .collect();
652 assert!(
653 stale.is_empty(),
654 "the allowed list declares {stale:?}, which no longer collides with \
655 anything. Delete the entries: an exception nobody is using is where the \
656 next real collision lands and reads as company."
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 );
675 }
676
677 /// Warn when the generated vocabulary has grown dead, and fail when it grows
678 /// deader than the recorded high-water mark.
679 ///
680 /// A generated class no markup emits is a rule shipped to every user for
681 /// nothing, and the proportion was large when it was first measured: 42% of the
682 /// vocabulary unused in goingson, 67% in the MNW server, 84% in Balanced
683 /// Breakfast. Those are not failures on their own or no app would build. What
684 /// this converts is the direction: dead vocabulary becoming a number in a build
685 /// script means a change that worsens it stops being something somebody
686 /// notices.
687 ///
688 /// One-sided, the same shape as the MNW server's `frontend_globals` seal:
689 /// exceeding `high_water` fails, coming in under it warns and asks for the seal
690 /// to be lowered. A build that fails because dead CSS was deleted would teach
691 /// the wrong lesson.
692 ///
693 /// `markup` is every file that can carry a class: templates, `.js`, `.html`,
694 /// and any Rust that writes markup. A class is counted as used if its name
695 /// appears in any of them, which is deliberately generous. A stricter reading
696 /// would need to know how each app builds its class strings, and a check that
697 /// guesses wrong fails a correct build.
698 ///
699 /// # Panics
700 ///
701 /// If a listed file cannot be read, or if more classes are unused than
702 /// `high_water`.
703 pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
704 let generated = makeover_webview::vocabulary::names(opts);
705 let mut haystack = String::new();
706 for path in markup {
707 let path = path.as_ref();
708 println!("cargo::rerun-if-changed={}", path.display());
709 haystack.push_str(
710 &std::fs::read_to_string(path)
711 .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
712 );
713 haystack.push('\n');
714 }
715
716 let unused: Vec<&String> = generated
717 .iter()
718 .filter(|class| !haystack.contains(class.as_str()))
719 .collect();
720
721 assert!(
722 unused.len() <= high_water,
723 "{} of {} generated classes are emitted by no markup, above the recorded {}. \
724 The vocabulary grew or the markup stopped using it:\n{}",
725 unused.len(),
726 generated.len(),
727 high_water,
728 unused
729 .iter()
730 .map(|c| format!(" .{c}"))
731 .collect::<Vec<_>>()
732 .join("\n")
733 );
734
735 if unused.len() < high_water {
736 println!(
737 "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
738 lower the seal so it cannot grow back",
739 unused.len(),
740 high_water
741 );
742 }
743 }
744
745 #[cfg(test)]
746 mod tests {
747 use super::*;
748
749 fn scratch(name: &str) -> PathBuf {
750 let dir =
751 std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
752 let _ = std::fs::remove_dir_all(&dir);
753 std::fs::create_dir_all(&dir).expect("create scratch");
754 dir
755 }
756
757 fn write(dir: &Path, name: &str, src: &str) {
758 if let Some(parent) = dir.join(name).parent() {
759 std::fs::create_dir_all(parent).unwrap();
760 }
761 std::fs::write(dir.join(name), src).unwrap();
762 }
763
764 fn declaring() -> String {
765 format!(
766 "const {CONST_NAME} = '{}';\n",
767 Density::Touch.media_condition()
768 )
769 }
770
771 #[test]
772 fn the_crates_own_string_passes() {
773 let dir = scratch("ok");
774 write(&dir, "touch.js", &declaring());
775 check_touch_density(&dir);
776 }
777
778 #[test]
779 #[should_panic(expected = "disagrees with makeover_geometry::Density")]
780 fn a_drifted_literal_fails() {
781 let dir = scratch("drift");
782 write(&dir, "touch.js", &declaring());
783 write(
784 &dir,
785 "haptics.js",
786 &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
787 );
788 check_touch_density(&dir);
789 }
790
791 #[test]
792 #[should_panic(expected = "device sniff")]
793 fn the_sniff_cannot_come_back() {
794 let dir = scratch("sniff");
795 write(&dir, "touch.js", &declaring());
796 write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
797 check_touch_density(&dir);
798 }
799
800 #[test]
801 #[should_panic(expected = "no TOUCH_DENSITY literal found")]
802 fn a_frontend_that_states_nothing_fails() {
803 let dir = scratch("empty");
804 write(&dir, "app.js", "export const x = 1;\n");
805 check_touch_density(&dir);
806 }
807
808 #[test]
809 fn a_use_site_is_not_a_declaration() {
810 // The const is read far more often than it is declared, and a read
811 // states no string. Counting one as a declaration would make the
812 // `found > 0` assertion pass on a frontend that only imports it.
813 let src =
814 format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
815 assert!(touch_density_literals(&src).is_empty());
816 }
817
818 #[test]
819 fn nested_files_are_read() {
820 // The server keeps its scripts in subdirectories, and the nested half
821 // is the half most likely to be a copy.
822 let dir = scratch("nested");
823 write(&dir, "touch.js", &declaring());
824 write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
825 let files = js_files(&dir);
826 assert_eq!(files.len(), 2);
827 }
828
829 #[test]
830 fn a_non_js_file_is_ignored() {
831 let dir = scratch("nonjs");
832 write(&dir, "touch.js", &declaring());
833 write(&dir, "styles.css", "body { }\n");
834 assert_eq!(js_files(&dir).len(), 1);
835 }
836
837 fn frontend(name: &str) -> PathBuf {
838 let dir = scratch(name);
839 std::fs::create_dir_all(dir.join("css")).unwrap();
840 std::fs::create_dir_all(dir.join("js")).unwrap();
841 dir
842 }
843
844 /// A width every size class agrees is a boundary.
845 fn boundary() -> u16 {
846 SizeClass::Medium.min_px()
847 }
848
849 #[test]
850 fn the_crates_own_boundaries_pass() {
851 let dir = frontend("bp-ok");
852 write(
853 &dir,
854 "css/styles.css",
855 &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
856 );
857 check_breakpoints(&dir, &[]);
858 }
859
860 #[test]
861 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
862 fn a_stale_css_width_fails() {
863 let dir = frontend("bp-css");
864 write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
865 check_breakpoints(&dir, &[]);
866 }
867
868 #[test]
869 #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
870 fn a_stale_js_width_fails() {
871 let dir = frontend("bp-js");
872 write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
873 check_breakpoints(&dir, &[]);
874 }
875
876 #[test]
877 fn a_declared_tuning_width_passes() {
878 let dir = frontend("bp-tuning");
879 write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
880 check_breakpoints(&dir, &[1400]);
881 }
882
883 #[test]
884 fn a_width_in_a_comment_is_prose() {
885 // The note explaining which breakpoint used to be here is not a rule,
886 // and failing a build on documentation would teach people to delete it.
887 let dir = frontend("bp-comment");
888 write(
889 &dir,
890 "css/styles.css",
891 "/* was @media (max-width: 768px) until the size classes landed */\n",
892 );
893 check_breakpoints(&dir, &[]);
894 }
895
896 #[test]
897 fn an_unparenthesized_width_is_not_a_breakpoint() {
898 // A JS string building an inline style states `max-width: 320px` with
899 // no parentheses. It is a declaration, not a query, and the first
900 // version of this check failed the build on one.
901 let dir = frontend("bp-inline");
902 write(
903 &dir,
904 "js/style.js",
905 "el.style.cssText = 'max-width: 320px; display: block';\n",
906 );
907 check_breakpoints(&dir, &[]);
908 }
909
910 #[test]
911 fn nested_css_is_read() {
912 // Same argument as the touch check: the nested half is the half most
913 // likely to be a copy.
914 let dir = frontend("bp-nested");
915 write(
916 &dir,
917 "css/screens/detail.css",
918 "@media (max-width: 768px) { }\n",
919 );
920 let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
921 assert!(found.is_err(), "a nested stylesheet must be scanned");
922 }
923
924 #[test]
925 fn a_named_list_is_checked() {
926 let dir = frontend("bp-list");
927 write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
928 let listed = dir.join("css/style.css");
929 let err =
930 std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
931 let msg = err.downcast_ref::<String>().expect("String payload");
932 assert!(msg.contains("style.css:1"), "got: {msg}");
933 }
934
935 #[test]
936 #[should_panic(expected = "read ")]
937 fn a_listed_file_that_is_gone_fails() {
938 // The list is hand-maintained, so a path that stopped existing is a
939 // check quietly covering less than it claims. Louder than skipping it.
940 let dir = frontend("bp-missing");
941 check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
942 }
943
944 #[test]
945 fn a_listed_js_file_is_parsed_as_script() {
946 // The unparenthesized-declaration rule is what separates the two, and
947 // picking the parser off the extension is the whole difference.
948 let dir = frontend("bp-list-js");
949 write(
950 &dir,
951 "js/style.js",
952 "el.style.cssText = 'max-width: 320px';\n",
953 );
954 check_breakpoints_files(&[dir.join("js/style.js")], &[]);
955 }
956
957 #[test]
958 fn the_error_names_the_file_and_line() {
959 let dir = frontend("bp-message");
960 write(
961 &dir,
962 "css/styles.css",
963 "body { }\n@media (max-width: 768px) { }\n",
964 );
965 let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
966 let msg = err
967 .downcast_ref::<String>()
968 .expect("panic payload is a String");
969 assert!(msg.contains("css/styles.css:2"), "got: {msg}");
970 }
971
972 #[test]
973 fn a_rule_restating_a_generated_class_fails_and_names_it() {
974 let dir = scratch("vocab-clash");
975 // `.card` is makeover's. An app rule for it beats the generated one,
976 // because app CSS is unlayered and the generated sheet is not.
977 write(
978 &dir,
979 "css/styles.css",
980 "body { color: red; }\n.card { box-shadow: none; }\n",
981 );
982 let err =
983 std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
984 .unwrap_err();
985 let msg = err
986 .downcast_ref::<String>()
987 .expect("panic payload is a String");
988 assert!(msg.contains(".card"), "got: {msg}");
989 assert!(msg.contains("box-shadow"), "got: {msg}");
990 assert!(msg.contains("css/styles.css"), "got: {msg}");
991 }
992
993 #[test]
994 fn an_app_class_of_its_own_is_left_alone() {
995 let dir = scratch("vocab-clean");
996 write(
997 &dir,
998 "css/styles.css",
999 ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1000 );
1001 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1002 }
1003
1004 #[test]
1005 fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1006 let dir = scratch("vocab-generated");
1007 let opts = Emit::default();
1008 write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
1009 // Without the skip this is the loudest failure possible: every class in
1010 // the vocabulary, reported as a clash with the vocabulary.
1011 check_vocabulary(&dir, &opts, &["layout.css"], &[], &[]);
1012 }
1013
1014 #[test]
1015 fn a_prefixed_app_is_checked_against_its_own_prefix() {
1016 let dir = scratch("vocab-prefix");
1017 let opts = Emit {
1018 class_prefix: "mo-",
1019 ..Emit::default()
1020 };
1021 // Bare `.card` is the app's own class once the generated sheet writes
1022 // `.mo-card`, so this has to pass.
1023 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1024 check_vocabulary(&dir, &opts, &[], &[], &[]);
1025
1026 let dir = scratch("vocab-prefix-clash");
1027 write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1028 assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[], &[])).is_err());
1029 }
1030
1031 #[test]
1032 fn a_class_shared_without_a_shared_property_is_left_alone() {
1033 let dir = scratch("vocab-additive");
1034 // What goingson actually does: the generated `.badge` sets the text
1035 // colour and the app sets the shape. Same class, no argument.
1036 write(
1037 &dir,
1038 "css/styles.css",
1039 ".badge { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1040 );
1041 check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1042 }
1043
1044 #[test]
1045 fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1046 let dir = scratch("vocab-allowed");
1047 write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1048 check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
1049
1050 // The same licence against a sheet that no longer collides has to fail,
1051 // or the list only ever grows.
1052 let dir = scratch("vocab-allowed-stale");
1053 write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1054 let err = std::panic::catch_unwind(|| {
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 );
1189 })
1190 .unwrap_err();
1191 let msg = err
1192 .downcast_ref::<String>()
1193 .expect("panic payload is a String");
1194 assert!(msg.contains("no longer collides"), "got: {msg}");
1195 }
1196
1197 #[test]
1198 fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
1199 let dir = scratch("vocab-seal");
1200 let opts = Emit::default();
1201 let all = makeover_webview::vocabulary::names(&opts).len();
1202 // Markup naming nothing: every class is unused.
1203 write(&dir, "index.html", "<div></div>\n");
1204 let markup = [dir.join("index.html")];
1205
1206 check_vocabulary_use(&markup, &opts, all);
1207 assert!(
1208 std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, all - 1)).is_err(),
1209 "a vocabulary deader than the seal has to fail"
1210 );
1211 }
1212
1213 #[test]
1214 fn both_quote_styles_read() {
1215 let want = Density::Touch.media_condition();
1216 for q in ['\'', '"'] {
1217 let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
1218 let found = touch_density_literals(&src);
1219 assert_eq!(found.len(), 1);
1220 assert_eq!(found[0].1, want);
1221 }
1222 }
1223 }
1224