Skip to main content

makeover_build/
drift.rs

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
14use std::path::{Path, PathBuf};
15
16use makeover_geometry::{Density, SizeClass};
17use 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.
21const 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.
28const 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.
50pub 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.
113fn 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.
123fn 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.
145fn 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
168fn 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.
205pub 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.
231pub 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.
237fn 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.
299fn 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.
311fn 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.
323fn 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 `{`)`.
341fn 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.
365fn 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.
385fn 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, and nothing looks.
413///
414/// # Two passes, because a rule can carry no class
415///
416/// The class pass is the original: an app `.button` against the generated
417/// `.button`. It reads rules by the classes in their selectors, so a rule with
418/// no class in it is invisible to it -- and `button { color: var(--content) }`
419/// is exactly that. It sets the same property the generated `.button` sets, on
420/// every described act in the app, and it took `.button[data-tone="danger"]`'s
421/// tone with it: a destructive act rendered indistinguishable from an ordinary
422/// one for months, with this check reporting nothing.
423///
424/// The element pass closes it. `makeover_webview::vocabulary::ELEMENT_CLASSES`
425/// says which generated classes an element can carry -- CSS cannot say it, and
426/// the renderer can -- and a bare element rule taking a property the design
427/// system sets on one of those classes is the same defect as the class case.
428///
429/// Two things are not reported, both deliberately:
430///
431///   - A scoped rule (`.page button`). It reaches the elements inside one
432///     region rather than every one of them, so whether it lands on a described
433///     act depends on where that act renders. The certain case is the one this
434///     reads.
435///   - A property the app names on the class itself, from a rule that outranks
436///     the element rule. Both are the app's and both sit in the same layer, so
437///     that one contest is settled by specificity, and what reaches the design
438///     system is the class rule -- which the class pass has already, reported
439///     or reviewed. The rank matters: `.field` does not beat
440///     `input[type="text"]`, and a handoff written as the weaker of the two is
441///     a remedy that looks written and is not.
442///
443/// # Why properties and not class names
444///
445/// A shared class name is not by itself a divergence, and the first run of this
446/// check against goingson is what settled it: nine classes are shared and every
447/// one is deliberate. `.badge` sets shape in the app and colour in the
448/// generated sheet, and the app's own comment beside it reads "Fill, edge and
449/// text colour come from the generated .badge in layout.css. Do not add
450/// background, border or box-shadow here." That arrangement is correct, so a
451/// check on names would have asked for it to be deleted. On properties, the
452/// comment becomes the check.
453///
454/// # The exception list
455///
456/// `allowed` is `(class, property)` pairs this app has reviewed and kept.
457/// Deciding which are legitimate here would need a selector matcher, and a check
458/// that guesses wrong about specificity fails correct builds -- so the app
459/// declares it instead, the same shape as quasi-webview's `RENDERER_OWN`.
460///
461/// A reviewed pairing is a claim about who owns a property, and it expires when
462/// the design system takes the property back.
463///
464/// `allowed_elements` is the same thing one pass down: `(element, class,
465/// property)` triples where a bare element rule reaching a generated class has
466/// been read and kept.
467///
468/// The remedy is usually neither list. A later layer can hand the property back
469/// with `revert-layer`, which says "whatever the design system set here, keep
470/// it" on the arms makeover actually paints, and that is a statement in the
471/// stylesheet rather than a note in a build script. Handoffs are not reported by
472/// either pass.
473///
474/// A pair that stops colliding fails too. A licence nobody is using is where
475/// the next real collision lands and reads as company.
476///
477/// `frontend` is the directory holding `css/`. `generated` names the sheets
478/// this crate writes, relative to `frontend/css`, which are skipped: the
479/// generated file setting a generated property is the point.
480///
481/// Emits `cargo:rerun-if-changed` for every file it read.
482///
483/// # Panics
484///
485/// If `frontend/css` cannot be read, if any hand-written sheet takes a
486/// generated property without declaring it, or if a declared pair no longer
487/// collides. A build script has nowhere useful to return an error to, and an
488/// app quietly overriding its own design system is worse than a failed build.
489pub fn check_vocabulary(
490    frontend: impl AsRef<Path>,
491    opts: &Emit,
492    generated: &[&str],
493    allowed: &[(&str, &str)],
494    allowed_elements: &[(&str, &str, &str)],
495) {
496    check_vocabulary_with(frontend, opts, generated, &[], allowed, allowed_elements);
497}
498
499/// [`check_vocabulary`], also against sheets the app takes from upstream.
500///
501/// `upstream` is the text of stylesheets that sit between makeover and the app
502/// and own the classes they style as makeover owns its own: quasi-webview's
503/// `SCREEN_CSS` is the case. makeover-build cannot name it, since quasi builds
504/// on this crate and not the other way round, so the app that links both hands
505/// it in. Without it goingson's sheet redrew most of quasi's `.button`, `.chip`,
506/// `.tab` and `.field` boxes and nothing said so.
507///
508/// A hand-written sheet is checked against makeover's generated stylesheet and
509/// every upstream sheet together. A file whose text *is* an upstream sheet, the
510/// copy an app writes into its frontend to serve, is still checked against
511/// makeover and is never read as clashing with itself. The allowed lists cover
512/// both kinds of owner.
513///
514/// # Panics
515///
516/// As [`check_vocabulary`].
517pub fn check_vocabulary_with(
518    frontend: impl AsRef<Path>,
519    opts: &Emit,
520    generated: &[&str],
521    upstream: &[&str],
522    allowed: &[(&str, &str)],
523    allowed_elements: &[(&str, &str, &str)],
524) {
525    let frontend = frontend.as_ref();
526    let css = frontend.join("css");
527    let files: Vec<PathBuf> = files_with_extension(&css, "css")
528        .into_iter()
529        .filter(|p| {
530            let name = p.strip_prefix(&css).unwrap_or(p).display().to_string();
531            !generated.contains(&name.as_str())
532        })
533        .collect();
534    check_vocabulary_paths(
535        &files,
536        opts,
537        Some(frontend),
538        upstream,
539        allowed,
540        allowed_elements,
541    );
542}
543
544/// [`check_vocabulary`] against a named list of files rather than a tree.
545///
546/// For a frontend whose generated and hand-written sheets share a directory, so
547/// a directory scan has nothing to point at. Same trade as
548/// [`check_breakpoints_files`]: the list is hand-maintained, and a stylesheet
549/// nobody adds to it is unchecked rather than failing.
550///
551/// # Panics
552///
553/// As [`check_vocabulary`].
554pub fn check_vocabulary_files<P: AsRef<Path>>(
555    paths: &[P],
556    opts: &Emit,
557    allowed: &[(&str, &str)],
558    allowed_elements: &[(&str, &str, &str)],
559) {
560    check_vocabulary_files_with(paths, opts, &[], allowed, allowed_elements);
561}
562
563/// [`check_vocabulary_files`], also against upstream sheets. See
564/// [`check_vocabulary_with`] for what `upstream` is.
565///
566/// # Panics
567///
568/// As [`check_vocabulary`].
569pub fn check_vocabulary_files_with<P: AsRef<Path>>(
570    paths: &[P],
571    opts: &Emit,
572    upstream: &[&str],
573    allowed: &[(&str, &str)],
574    allowed_elements: &[(&str, &str, &str)],
575) {
576    let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
577    check_vocabulary_paths(&paths, opts, None, upstream, allowed, allowed_elements);
578}
579
580/// The check itself. `root`, when given, is stripped from reported paths.
581fn check_vocabulary_paths(
582    paths: &[PathBuf],
583    opts: &Emit,
584    root: Option<&Path>,
585    upstream: &[&str],
586    allowed: &[(&str, &str)],
587    allowed_elements: &[(&str, &str, &str)],
588) {
589    let makeover =
590        makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
591    // What a hand-written sheet answers to: makeover's classes and every
592    // upstream sheet's, merged per class.
593    let mut owned = makeover.clone();
594    for sheet in upstream {
595        for (class, properties) in makeover_webview::vocabulary::declarations_by_class(sheet) {
596            owned.entry(class).or_default().extend(properties);
597        }
598    }
599    let mut clashes: Vec<String> = Vec::new();
600    let mut seen: Vec<(String, String)> = Vec::new();
601    let mut element_clashes: Vec<String> = Vec::new();
602    let mut element_seen: Vec<(String, String, String)> = Vec::new();
603
604    for path in paths {
605        println!("cargo::rerun-if-changed={}", path.display());
606        let raw = std::fs::read_to_string(path)
607            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
608        let name = match root {
609            Some(root) => display_name(root, path),
610            None => path.display().to_string(),
611        };
612        // An upstream sheet served from the frontend answers to makeover alone:
613        // measured against itself every declaration in it would clash.
614        let generated = if upstream.contains(&raw.as_str()) {
615            &makeover
616        } else {
617            &owned
618        };
619        // Read the app's sheet the same way the crate reads its own, or the two
620        // sides are not comparable.
621        let local = makeover_webview::vocabulary::declarations_by_class(&raw);
622        for (class, properties) in &local {
623            let Some(theirs) = generated.get(class) else {
624                continue;
625            };
626            for property in properties.intersection(theirs) {
627                seen.push((class.clone(), property.clone()));
628                if allowed.contains(&(class.as_str(), property.as_str())) {
629                    continue;
630                }
631                clashes.push(format!("  {name}  .{class} {{ {property} }}"));
632            }
633        }
634
635        // The second pass: a rule carrying no class at all, which the first one
636        // cannot see. What the app says about the class itself settles the
637        // pair, but only from a rule that outranks the element rule -- both are
638        // the app's and both are in the same layer, so this one contest is
639        // decided by specificity. `.field` does not beat `input[type="text"]`.
640        let mentioned = makeover_webview::vocabulary::mentions_by_class(&raw);
641        let by_element = makeover_webview::vocabulary::declarations_by_element(&raw);
642        for (element, properties) in &by_element {
643            for class in makeover_webview::vocabulary::classes_for_element(element, opts) {
644                let Some(theirs) = generated.get(&class) else {
645                    continue;
646                };
647                for (property, rank) in properties {
648                    if !theirs.contains(property) {
649                        continue;
650                    }
651                    // A tie goes to the class rule: at equal specificity the
652                    // later rule wins, and a remedy is written after the rule
653                    // it remedies.
654                    let spoken_for = mentioned
655                        .get(&class)
656                        .and_then(|properties| properties.get(property))
657                        .is_some_and(|theirs| theirs >= rank);
658                    if spoken_for {
659                        continue;
660                    }
661                    element_seen.push((element.clone(), class.clone(), property.clone()));
662                    if allowed_elements.contains(&(
663                        element.as_str(),
664                        class.as_str(),
665                        property.as_str(),
666                    )) {
667                        continue;
668                    }
669                    element_clashes.push(format!(
670                        "  {name}  {element} {{ {property} }}  beats  .{class} {{ {property} }}"
671                    ));
672                }
673            }
674        }
675    }
676
677    assert!(
678        clashes.is_empty(),
679        "{} hand-written declaration(s) take a property the generated stylesheet, \
680         or an upstream sheet named to this check, already sets on the same \
681         class. App CSS wins over @layer makeover, whether by a later layer or \
682         by being unlayered, so each of these wins over the design system \
683         silently:\n{}\n\nDelete the declaration, or, if \
684         it is a deliberate pairing on a different selector arm, add \
685         (class, property) to this check's allowed list and say why beside it. \
686         Count the consumers before deciding a divergence is worth keeping.",
687        clashes.len(),
688        clashes.join("\n")
689    );
690
691    assert!(
692        element_clashes.is_empty(),
693        "{} hand-written element rule(s) take a property the generated \
694         stylesheet sets on a class that element carries. App CSS wins over \
695         @layer makeover, whether by a later layer or by being unlayered, so a \
696         described component rendered on one of these elements loses the \
697         design system's version of that property silently -- which is how a \
698         destructive act came to look like an ordinary one:\n{}\n\nHand the \
699         property back on the arms makeover paints \
700         (`.{{class}}:disabled {{ color: revert-layer }}`), scope the element \
701         rule so it stops reaching described markup, or add \
702         (element, class, property) to this check's allowed-elements list and \
703         say why beside it.",
704        element_clashes.len(),
705        element_clashes.join("\n")
706    );
707
708    let stale: Vec<&(&str, &str)> = allowed
709        .iter()
710        .filter(|(class, property)| {
711            !seen.contains(&((*class).to_string(), (*property).to_string()))
712        })
713        .collect();
714    assert!(
715        stale.is_empty(),
716        "the allowed list declares {stale:?}, which no longer collides with \
717         anything. Delete the entries: an exception nobody is using is where the \
718         next real collision lands and reads as company."
719    );
720
721    let stale: Vec<&(&str, &str, &str)> = allowed_elements
722        .iter()
723        .filter(|(element, class, property)| {
724            !element_seen.contains(&(
725                (*element).to_string(),
726                (*class).to_string(),
727                (*property).to_string(),
728            ))
729        })
730        .collect();
731    assert!(
732        stale.is_empty(),
733        "the allowed-elements list declares {stale:?}, which no longer collides \
734         with anything. Delete the entries: an exception nobody is using is \
735         where the next real collision lands and reads as company."
736    );
737}
738
739/// Warn when the generated vocabulary has grown dead, and fail when it grows
740/// deader than the recorded high-water mark.
741///
742/// A generated class no markup emits is a rule shipped to every user for
743/// nothing, and the proportion was large when it was first measured: 42% of the
744/// vocabulary unused in goingson, 67% in the MNW server, 84% in Balanced
745/// Breakfast. Those are not failures on their own or no app would build. What
746/// this converts is the direction: dead vocabulary becoming a number in a build
747/// script means a change that worsens it stops being something somebody
748/// notices.
749///
750/// One-sided, the same shape as the MNW server's `frontend_globals` seal:
751/// exceeding `high_water` fails, coming in under it warns and asks for the seal
752/// to be lowered. A build that fails because dead CSS was deleted would teach
753/// the wrong lesson.
754///
755/// `markup` is every file that can carry a class: templates, `.js`, `.html`,
756/// and any Rust that writes markup. A class is counted as used if its name
757/// appears in any of them, which is deliberately generous. A stricter reading
758/// would need to know how each app builds its class strings, and a check that
759/// guesses wrong fails a correct build.
760///
761/// # Panics
762///
763/// If a listed file cannot be read, or if more classes are unused than
764/// `high_water`.
765/// Whether `haystack` names `class` whole, rather than as the front of a longer
766/// name: `min-1` must not be read as used because `min-16` is.
767fn haystack_names(haystack: &str, class: &str) -> bool {
768    haystack.match_indices(class).any(|(at, found)| {
769        haystack[at + found.len()..]
770            .chars()
771            .next()
772            .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
773    })
774}
775
776pub fn check_vocabulary_use<P: AsRef<Path>>(markup: &[P], opts: &Emit, high_water: usize) {
777    let generated = makeover_webview::vocabulary::names(opts);
778    let mut haystack = String::new();
779    for path in markup {
780        let path = path.as_ref();
781        println!("cargo::rerun-if-changed={}", path.display());
782        haystack.push_str(
783            &std::fs::read_to_string(path)
784                .unwrap_or_else(|e| panic!("read {}: {e}", path.display())),
785        );
786        haystack.push('\n');
787    }
788
789    // The floor ladder is one piece of vocabulary, not thirty-two. A column
790    // carries whichever `min-N` rung its floor lands on, so no site names every
791    // rung and counting each would seal the dead count at the ladder's length.
792    // It is used if any rung is, and counted once if none is.
793    let rung = |class: &str| {
794        class
795            .strip_prefix(opts.class_prefix)
796            .and_then(|name| name.strip_prefix("min-"))
797            .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
798    };
799    let ladder_used = generated
800        .iter()
801        .any(|class| rung(class) && haystack_names(&haystack, class));
802    let mut unused: Vec<&String> = generated
803        .iter()
804        .filter(|class| !rung(class) && !haystack.contains(class.as_str()))
805        .collect();
806    let ladder_name = format!("{}min-N", opts.class_prefix);
807    if !ladder_used && generated.iter().any(|class| rung(class)) {
808        unused.push(&ladder_name);
809    }
810
811    assert!(
812        unused.len() <= high_water,
813        "{} of {} generated classes are emitted by no markup, above the recorded {}. \
814         The vocabulary grew or the markup stopped using it:\n{}",
815        unused.len(),
816        generated.len(),
817        high_water,
818        unused
819            .iter()
820            .map(|c| format!("  .{c}"))
821            .collect::<Vec<_>>()
822            .join("\n")
823    );
824
825    if unused.len() < high_water {
826        println!(
827            "cargo::warning=dead makeover vocabulary is down to {} from a sealed {}; \
828             lower the seal so it cannot grow back",
829            unused.len(),
830            high_water
831        );
832    }
833}
834
835#[cfg(test)]
836mod tests {
837    use super::*;
838
839    fn scratch(name: &str) -> PathBuf {
840        let dir =
841            std::env::temp_dir().join(format!("makeover-drift-{}-{name}", std::process::id()));
842        let _ = std::fs::remove_dir_all(&dir);
843        std::fs::create_dir_all(&dir).expect("create scratch");
844        dir
845    }
846
847    fn write(dir: &Path, name: &str, src: &str) {
848        if let Some(parent) = dir.join(name).parent() {
849            std::fs::create_dir_all(parent).unwrap();
850        }
851        std::fs::write(dir.join(name), src).unwrap();
852    }
853
854    fn declaring() -> String {
855        format!(
856            "const {CONST_NAME} = '{}';\n",
857            Density::Touch.media_condition()
858        )
859    }
860
861    #[test]
862    fn the_crates_own_string_passes() {
863        let dir = scratch("ok");
864        write(&dir, "touch.js", &declaring());
865        check_touch_density(&dir);
866    }
867
868    #[test]
869    #[should_panic(expected = "disagrees with makeover_geometry::Density")]
870    fn a_drifted_literal_fails() {
871        let dir = scratch("drift");
872        write(&dir, "touch.js", &declaring());
873        write(
874            &dir,
875            "haptics.js",
876            &format!("const {CONST_NAME} = '(pointer: coarse)';\n"),
877        );
878        check_touch_density(&dir);
879    }
880
881    #[test]
882    #[should_panic(expected = "device sniff")]
883    fn the_sniff_cannot_come_back() {
884        let dir = scratch("sniff");
885        write(&dir, "touch.js", &declaring());
886        write(&dir, "legacy.js", "if ('ontouchstart' in window) {}\n");
887        check_touch_density(&dir);
888    }
889
890    #[test]
891    #[should_panic(expected = "no TOUCH_DENSITY literal found")]
892    fn a_frontend_that_states_nothing_fails() {
893        let dir = scratch("empty");
894        write(&dir, "app.js", "export const x = 1;\n");
895        check_touch_density(&dir);
896    }
897
898    #[test]
899    fn a_use_site_is_not_a_declaration() {
900        // The const is read far more often than it is declared, and a read
901        // states no string. Counting one as a declaration would make the
902        // `found > 0` assertion pass on a frontend that only imports it.
903        let src =
904            format!("import {{ {CONST_NAME} }} from './touch.js';\nmatchMedia({CONST_NAME});\n");
905        assert!(touch_density_literals(&src).is_empty());
906    }
907
908    #[test]
909    fn nested_files_are_read() {
910        // The server keeps its scripts in subdirectories, and the nested half
911        // is the half most likely to be a copy.
912        let dir = scratch("nested");
913        write(&dir, "touch.js", &declaring());
914        write(&dir, "screens/legacy.js", "navigator.maxTouchPoints > 0;\n");
915        let files = js_files(&dir);
916        assert_eq!(files.len(), 2);
917    }
918
919    #[test]
920    fn a_non_js_file_is_ignored() {
921        let dir = scratch("nonjs");
922        write(&dir, "touch.js", &declaring());
923        write(&dir, "styles.css", "body { }\n");
924        assert_eq!(js_files(&dir).len(), 1);
925    }
926
927    fn frontend(name: &str) -> PathBuf {
928        let dir = scratch(name);
929        std::fs::create_dir_all(dir.join("css")).unwrap();
930        std::fs::create_dir_all(dir.join("js")).unwrap();
931        dir
932    }
933
934    /// A width every size class agrees is a boundary.
935    fn boundary() -> u16 {
936        SizeClass::Medium.min_px()
937    }
938
939    #[test]
940    fn the_crates_own_boundaries_pass() {
941        let dir = frontend("bp-ok");
942        write(
943            &dir,
944            "css/styles.css",
945            &format!("@media (min-width: {}px) {{ body {{ }} }}\n", boundary()),
946        );
947        check_breakpoints(&dir, &[]);
948    }
949
950    #[test]
951    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
952    fn a_stale_css_width_fails() {
953        let dir = frontend("bp-css");
954        write(&dir, "css/styles.css", "@media (max-width: 768px) { }\n");
955        check_breakpoints(&dir, &[]);
956    }
957
958    #[test]
959    #[should_panic(expected = "disagree with makeover_geometry::SizeClass")]
960    fn a_stale_js_width_fails() {
961        let dir = frontend("bp-js");
962        write(&dir, "js/shell.js", "matchMedia('(max-width: 768px)');\n");
963        check_breakpoints(&dir, &[]);
964    }
965
966    #[test]
967    fn a_declared_tuning_width_passes() {
968        let dir = frontend("bp-tuning");
969        write(&dir, "css/styles.css", "@media (min-width: 1400px) { }\n");
970        check_breakpoints(&dir, &[1400]);
971    }
972
973    #[test]
974    fn a_width_in_a_comment_is_prose() {
975        // The note explaining which breakpoint used to be here is not a rule,
976        // and failing a build on documentation would teach people to delete it.
977        let dir = frontend("bp-comment");
978        write(
979            &dir,
980            "css/styles.css",
981            "/* was @media (max-width: 768px) until the size classes landed */\n",
982        );
983        check_breakpoints(&dir, &[]);
984    }
985
986    #[test]
987    fn an_unparenthesized_width_is_not_a_breakpoint() {
988        // A JS string building an inline style states `max-width: 320px` with
989        // no parentheses. It is a declaration, not a query, and the first
990        // version of this check failed the build on one.
991        let dir = frontend("bp-inline");
992        write(
993            &dir,
994            "js/style.js",
995            "el.style.cssText = 'max-width: 320px; display: block';\n",
996        );
997        check_breakpoints(&dir, &[]);
998    }
999
1000    #[test]
1001    fn nested_css_is_read() {
1002        // Same argument as the touch check: the nested half is the half most
1003        // likely to be a copy.
1004        let dir = frontend("bp-nested");
1005        write(
1006            &dir,
1007            "css/screens/detail.css",
1008            "@media (max-width: 768px) { }\n",
1009        );
1010        let found = std::panic::catch_unwind(|| check_breakpoints(&dir, &[]));
1011        assert!(found.is_err(), "a nested stylesheet must be scanned");
1012    }
1013
1014    #[test]
1015    fn a_named_list_is_checked() {
1016        let dir = frontend("bp-list");
1017        write(&dir, "css/style.css", "@media (max-width: 768px) { }\n");
1018        let listed = dir.join("css/style.css");
1019        let err =
1020            std::panic::catch_unwind(|| check_breakpoints_files(&[&listed], &[])).unwrap_err();
1021        let msg = err.downcast_ref::<String>().expect("String payload");
1022        assert!(msg.contains("style.css:1"), "got: {msg}");
1023    }
1024
1025    #[test]
1026    #[should_panic(expected = "read ")]
1027    fn a_listed_file_that_is_gone_fails() {
1028        // The list is hand-maintained, so a path that stopped existing is a
1029        // check quietly covering less than it claims. Louder than skipping it.
1030        let dir = frontend("bp-missing");
1031        check_breakpoints_files(&[dir.join("css/never-written.css")], &[]);
1032    }
1033
1034    #[test]
1035    fn a_listed_js_file_is_parsed_as_script() {
1036        // The unparenthesized-declaration rule is what separates the two, and
1037        // picking the parser off the extension is the whole difference.
1038        let dir = frontend("bp-list-js");
1039        write(
1040            &dir,
1041            "js/style.js",
1042            "el.style.cssText = 'max-width: 320px';\n",
1043        );
1044        check_breakpoints_files(&[dir.join("js/style.js")], &[]);
1045    }
1046
1047    #[test]
1048    fn the_error_names_the_file_and_line() {
1049        let dir = frontend("bp-message");
1050        write(
1051            &dir,
1052            "css/styles.css",
1053            "body { }\n@media (max-width: 768px) { }\n",
1054        );
1055        let err = std::panic::catch_unwind(|| check_breakpoints(&dir, &[])).unwrap_err();
1056        let msg = err
1057            .downcast_ref::<String>()
1058            .expect("panic payload is a String");
1059        assert!(msg.contains("css/styles.css:2"), "got: {msg}");
1060    }
1061
1062    #[test]
1063    fn a_rule_restating_an_upstream_class_fails_and_its_served_copy_does_not() {
1064        // quasi-webview's sheet owns `.run`'s gap the way makeover owns
1065        // `.card`'s shadow, and an app redrawing it wins silently the same way.
1066        let upstream = ".tile-set { gap: 4px; }\n";
1067        let dir = scratch("vocab-upstream");
1068        write(&dir, "css/quasi.css", upstream);
1069        write(&dir, "css/styles.css", ".tile-set { gap: 0; }\n");
1070        let err = std::panic::catch_unwind(|| {
1071            check_vocabulary_with(&dir, &Emit::default(), &[], &[upstream], &[], &[]);
1072        })
1073        .unwrap_err();
1074        let msg = err
1075            .downcast_ref::<String>()
1076            .expect("panic payload is a String");
1077        assert!(
1078            msg.contains("css/styles.css  .tile-set { gap }"),
1079            "got: {msg}"
1080        );
1081        assert!(
1082            !msg.contains("css/quasi.css"),
1083            "the served copy clashed with itself: {msg}"
1084        );
1085
1086        // Without the upstream sheet named, the same tree passes, which is the
1087        // blind spot this closes.
1088        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1089    }
1090
1091    #[test]
1092    fn a_rule_restating_a_generated_class_fails_and_names_it() {
1093        let dir = scratch("vocab-clash");
1094        // `.card` is makeover's. An app rule for it beats the generated one,
1095        // because app CSS is unlayered and the generated sheet is not.
1096        write(
1097            &dir,
1098            "css/styles.css",
1099            "body { color: red; }\n.card { box-shadow: none; }\n",
1100        );
1101        let err =
1102            std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1103                .unwrap_err();
1104        let msg = err
1105            .downcast_ref::<String>()
1106            .expect("panic payload is a String");
1107        assert!(msg.contains(".card"), "got: {msg}");
1108        assert!(msg.contains("box-shadow"), "got: {msg}");
1109        assert!(msg.contains("css/styles.css"), "got: {msg}");
1110    }
1111
1112    #[test]
1113    fn an_app_class_of_its_own_is_left_alone() {
1114        let dir = scratch("vocab-clean");
1115        write(
1116            &dir,
1117            "css/styles.css",
1118            ".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
1119        );
1120        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1121    }
1122
1123    #[test]
1124    fn the_generated_sheet_is_skipped_rather_than_reported_against_itself() {
1125        let dir = scratch("vocab-generated");
1126        let opts = Emit::default();
1127        write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
1128        // Without the skip this is the loudest failure possible: every class in
1129        // the vocabulary, reported as a clash with the vocabulary.
1130        check_vocabulary(&dir, &opts, &["layout.css"], &[], &[]);
1131    }
1132
1133    #[test]
1134    fn a_prefixed_app_is_checked_against_its_own_prefix() {
1135        let dir = scratch("vocab-prefix");
1136        let opts = Emit {
1137            class_prefix: "mo-",
1138            ..Emit::default()
1139        };
1140        // Bare `.card` is the app's own class once the generated sheet writes
1141        // `.mo-card`, so this has to pass.
1142        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1143        check_vocabulary(&dir, &opts, &[], &[], &[]);
1144
1145        let dir = scratch("vocab-prefix-clash");
1146        write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
1147        assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[], &[])).is_err());
1148    }
1149
1150    #[test]
1151    fn a_class_shared_without_a_shared_property_is_left_alone() {
1152        let dir = scratch("vocab-additive");
1153        // The generated `.cell-value` sets only the text colour, so an app
1154        // setting its shape shares the class and takes nothing from it.
1155        write(
1156            &dir,
1157            "css/styles.css",
1158            ".cell-value { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
1159        );
1160        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1161    }
1162
1163    #[test]
1164    fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
1165        let dir = scratch("vocab-allowed");
1166        write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
1167        check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
1168
1169        // The same licence against a sheet that no longer collides has to fail,
1170        // or the list only ever grows.
1171        let dir = scratch("vocab-allowed-stale");
1172        write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
1173        let err = std::panic::catch_unwind(|| {
1174            check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")], &[]);
1175        })
1176        .unwrap_err();
1177        let msg = err
1178            .downcast_ref::<String>()
1179            .expect("panic payload is a String");
1180        assert!(msg.contains("no longer collides"), "got: {msg}");
1181    }
1182
1183    #[test]
1184    fn an_element_rule_clobbering_a_generated_class_fails_and_names_all_three() {
1185        let dir = scratch("vocab-element");
1186        // The defect that shipped for months: no class in the selector, so the
1187        // class pass sees nothing, and every described act in the app takes the
1188        // app's bevel instead of the design system's.
1189        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1190        let err =
1191            std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1192                .unwrap_err();
1193        let msg = err
1194            .downcast_ref::<String>()
1195            .expect("panic payload is a String");
1196        assert!(msg.contains("select {"), "got: {msg}");
1197        assert!(msg.contains(".field"), "got: {msg}");
1198        assert!(msg.contains("box-shadow"), "got: {msg}");
1199        assert!(msg.contains("css/styles.css"), "got: {msg}");
1200    }
1201
1202    #[test]
1203    fn a_handoff_on_the_class_is_the_remedy_and_reads_as_one() {
1204        let dir = scratch("vocab-element-handoff");
1205        // What a consumer writes instead of an exception: the element rule
1206        // stays, and a later layer gives the property back on the class. The
1207        // check has to read that as settled or the remedy fails the build it
1208        // was written to fix.
1209        write(
1210            &dir,
1211            "css/styles.css",
1212            "select { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1213        );
1214        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1215    }
1216
1217    #[test]
1218    fn a_property_the_app_states_on_the_class_is_not_the_element_rules_doing() {
1219        let dir = scratch("vocab-element-spoken-for");
1220        // Within the app's own sheet the class rule outranks the bare element
1221        // rule, so what reaches the design system is `.button`, not `button`.
1222        // The class pass has that pair -- here as a reviewed one -- and
1223        // reporting it twice would ask for two remedies for one collision.
1224        write(
1225            &dir,
1226            "css/styles.css",
1227            "select { box-shadow: none; }\n.field { box-shadow: none; }\n",
1228        );
1229        check_vocabulary(&dir, &Emit::default(), &[], &[("field", "box-shadow")], &[]);
1230    }
1231
1232    #[test]
1233    fn a_handoff_that_loses_to_the_rule_it_remedies_is_not_a_remedy() {
1234        let dir = scratch("vocab-element-weak-handoff");
1235        // The shape that reads as fixed and is not: both rules are the app's
1236        // and both are in the same layer, so the state on the element rule
1237        // decides, and the described field keeps the app's sunken fill.
1238        write(
1239            &dir,
1240            "css/styles.css",
1241            "select:focus { box-shadow: none; }\n.field { box-shadow: revert-layer; }\n",
1242        );
1243        let err =
1244            std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[], &[]))
1245                .unwrap_err();
1246        let msg = err
1247            .downcast_ref::<String>()
1248            .expect("panic payload is a String");
1249        assert!(msg.contains(".field"), "got: {msg}");
1250
1251        // Written to win, it is.
1252        let dir = scratch("vocab-element-strong-handoff");
1253        write(
1254            &dir,
1255            "css/styles.css",
1256            "select:focus { box-shadow: none; }\nselect.field { box-shadow: revert-layer; }\n",
1257        );
1258        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1259    }
1260
1261    #[test]
1262    fn a_scoped_rule_is_not_read_as_an_element_rule() {
1263        let dir = scratch("vocab-element-scoped");
1264        // It reaches the buttons inside one region rather than every button, so
1265        // whether it lands on a described act depends on where that act
1266        // renders. Failing the build on a guess is the worse error.
1267        write(
1268            &dir,
1269            "css/styles.css",
1270            ".wizard select { box-shadow: none; }\n",
1271        );
1272        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1273    }
1274
1275    #[test]
1276    fn an_element_the_design_system_never_renders_onto_is_left_alone() {
1277        let dir = scratch("vocab-element-unpaired");
1278        // `.card` is a container: no generated class sits on a `<footer>`, so
1279        // there is nothing for this rule to take.
1280        write(&dir, "css/styles.css", "footer { box-shadow: none; }\n");
1281        check_vocabulary(&dir, &Emit::default(), &[], &[], &[]);
1282    }
1283
1284    #[test]
1285    fn a_reviewed_element_pairing_passes_and_stops_passing_when_it_stops_colliding() {
1286        let dir = scratch("vocab-element-allowed");
1287        write(&dir, "css/styles.css", "select { box-shadow: none; }\n");
1288        check_vocabulary(
1289            &dir,
1290            &Emit::default(),
1291            &[],
1292            &[],
1293            &[("select", "field", "box-shadow")],
1294        );
1295
1296        // And the same licence against a sheet that no longer collides fails,
1297        // for the reason the class list's does.
1298        let dir = scratch("vocab-element-allowed-stale");
1299        write(&dir, "css/styles.css", "select { padding: 2px; }\n");
1300        let err = std::panic::catch_unwind(|| {
1301            check_vocabulary(
1302                &dir,
1303                &Emit::default(),
1304                &[],
1305                &[],
1306                &[("select", "field", "box-shadow")],
1307            );
1308        })
1309        .unwrap_err();
1310        let msg = err
1311            .downcast_ref::<String>()
1312            .expect("panic payload is a String");
1313        assert!(msg.contains("no longer collides"), "got: {msg}");
1314    }
1315
1316    #[test]
1317    fn dead_vocabulary_above_the_seal_fails_and_below_it_passes() {
1318        let dir = scratch("vocab-seal");
1319        let opts = Emit::default();
1320        let names = makeover_webview::vocabulary::names(&opts);
1321        let rungs = names.iter().filter(|c| c.starts_with("min-")).count();
1322        assert!(rungs > 1, "the floor ladder is in the vocabulary");
1323        // Markup naming nothing: every class is unused, and the ladder counts
1324        // once rather than once a rung.
1325        let dead = names.len() - rungs + 1;
1326        write(&dir, "index.html", "<div></div>\n");
1327        let markup = [dir.join("index.html")];
1328
1329        check_vocabulary_use(&markup, &opts, dead);
1330        assert!(
1331            std::panic::catch_unwind(|| check_vocabulary_use(&markup, &opts, dead - 1)).is_err(),
1332            "a vocabulary deader than the seal has to fail"
1333        );
1334    }
1335
1336    #[test]
1337    fn one_rung_of_the_floor_ladder_uses_the_ladder() {
1338        // A column carries whichever rung its floor lands on, so a site naming
1339        // one rung is using the ladder, and one naming none has one dead name.
1340        let opts = Emit::default();
1341        let names = makeover_webview::vocabulary::names(&opts);
1342        let rungs = names.iter().filter(|c| c.starts_with("min-")).count();
1343        let dead = names.len() - rungs + 1;
1344
1345        let dir = scratch("vocab-ladder");
1346        write(&dir, "index.html", "<div class=\"cell min-16\"></div>\n");
1347        let one_rung = [dir.join("index.html")];
1348        // `cell` is named too, so two fewer than a page naming nothing.
1349        check_vocabulary_use(&one_rung, &opts, dead - 2);
1350
1351        // A longer rung does not name a shorter one it starts with.
1352        write(&dir, "index.html", "<div class=\"min-24\"></div>\n");
1353        assert!(haystack_names("<div class=\"min-24\">", "min-24"));
1354        assert!(!haystack_names("<div class=\"min-24\">", "min-2"));
1355    }
1356
1357    #[test]
1358    fn both_quote_styles_read() {
1359        let want = Density::Touch.media_condition();
1360        for q in ['\'', '"'] {
1361            let src = format!("const {CONST_NAME} = {q}{want}{q};\n");
1362            let found = touch_density_literals(&src);
1363            assert_eq!(found.len(), 1);
1364            assert_eq!(found[0].1, want);
1365        }
1366    }
1367}