Skip to main content

makeover_webview/
build.rs

1//! Build-script support: the generated files every webview consumer
2//! materialises from its `build.rs`, and the checks that keep its hand-written
3//! frontend agreeing with them.
4//!
5//! Behind the `build` feature, so a runtime consumer of the renderer does not
6//! link `makeover`'s theme set. A consumer takes this crate twice: as a
7//! dependency for the markup and as a build-dependency with `features =
8//! ["build"]` for these files. `links` holds both to one version.
9//!
10//! Every consumer materialises the same generated files from a `build.rs`.
11//! This is that code, once.
12//!
13//! # Density
14//!
15//! [`geometry_css`] keys the touch preset on capability: touch hangs off "not
16//! a fine, hovering pointer" and never off a user-agent string or a
17//! breakpoint. An app with a manual override passes the selector it sets to
18//! `explicit_touch`.
19//!
20//! # Why these files are generated rather than checked in
21//!
22//! Tauri's resource globs are read by its CLI against the crate directory, so
23//! they cannot point into a registry checkout or `OUT_DIR`. Materialising into
24//! the crate keeps the source crate authoritative without vendoring a second
25//! copy that drifts. Every path written here is expected to be gitignored.
26//!
27//! # The other half: what is checked rather than written
28//!
29//! A consumer's frontend is not all generated. The stylesheet and the scripts
30//! are hand-written and state some of the same facts the generated files ask
31//! the crates for, so they can drift where a generated file cannot. [`drift`]
32//! holds the checks that keep them honest, and they are assertions rather than
33//! substitutions on purpose: a file that has to be generated to be correct
34//! stops being readable on its own.
35
36pub mod drift;
37
38use std::path::Path;
39
40pub use drift::{Allow, Breakpoints, Vocabulary, check_touch_density, check_vocabulary_use};
41
42/// The filenames [`typography_css`]'s `@font-face` rules fetch.
43///
44/// Re-exported so a consumer's `build.rs` needs no second dependency, and
45/// load-bearing for a further reason: the consumer's own build script writes those two files, so the emitter
46/// and the writer have to agree on the name. Through this they agree on a
47/// constant rather than on a string typed in two repositories.
48pub use makeover::{WEBFONT_MONO_FILE, WEBFONT_SANS_FILE};
49
50/// Layer 0 of the font model, re-exported for the same one-dependency reason.
51///
52/// A build script composing an override needs all four names and has no other
53/// reason to depend on `makeover` directly.
54pub use makeover::{FontFace, FontOverride, FontSlot, Typography};
55
56/// Write only when the bytes differ, so a generated file does not invalidate
57/// the build script that generated it.
58///
59/// Every emitter here writes into a directory the drift checks also read, and
60/// cargo compares a watched file's mtime against the build script's own
61/// `output`. An unconditional write moves that mtime on every run, so every run
62/// becomes the reason for the next one and the script runs on every build.
63///
64/// A read before each write costs microseconds, and the common
65/// case is that nothing changed: these files are a pure function of the crate
66/// version and the `Emit` options.
67fn write_if_changed(path: &Path, contents: &str, what: &str) {
68    // Cargo reruns a build script when a path it named with `rerun-if-changed`
69    // is newer than the script's last output, or has been deleted. Naming the
70    // output is what brings a generated file back after a hand deletion or a
71    // `git clean -X` over a gitignored `themes/`; without it the file stays
72    // gone until an unrelated edit reruns this script. Safe to name because
73    // the write below happens only on a byte difference, so a run with nothing
74    // changed moves no mtime and cannot invalidate itself through this.
75    rerun(path);
76    if std::fs::read_to_string(path).is_ok_and(|existing| existing == contents) {
77        return;
78    }
79    std::fs::write(path, contents).unwrap_or_else(|error| panic!("write {what}: {error}"));
80}
81
82/// Name `path` for cargo to rerun the build script when it changes.
83fn rerun(path: &Path) {
84    println!("cargo::rerun-if-changed={}", path.display());
85}
86
87/// The name of the file [`themes`] writes to record which themes it generated.
88///
89/// Read on the next run to tell a theme this run no longer ships from a
90/// `.toml` the consumer wrote into the same directory, which this crate has no
91/// business deleting. Not a `.toml`, so it never reads as a theme itself.
92const THEMES_MANIFEST: &str = ".makeover-themes";
93
94/// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
95///
96/// Removes only the `.toml` files a previous run of this function generated
97/// and upstream no longer ships, so a theme removed or renamed upstream does
98/// not linger in the bundle from a previous build. A foreign `.toml` the
99/// consumer placed in the same directory is not recorded and is left alone;
100/// clearing by stem deleted a file this crate never wrote. Omitting the
101/// cleanup shows up as a theme that will not go away.
102///
103/// # Panics
104///
105/// If the directory cannot be created, read, or written. A build script has
106/// nowhere useful to return an error to, and a half-materialised theme set is
107/// worse than a failed build.
108pub fn themes(dir: impl AsRef<Path>) {
109    let dir = dir.as_ref();
110    std::fs::create_dir_all(dir).expect("create themes dir");
111
112    let shipped: std::collections::BTreeSet<String> = makeover::embedded_themes()
113        .map(|(id, _)| id.to_string())
114        .collect();
115
116    // Only what upstream no longer ships, and only what a previous run of this
117    // function wrote: the manifest is the boundary between our generated
118    // output and a consumer's own file. Clearing the directory first and
119    // writing every theme back was the same thing by result and not by mtime:
120    // it left `write_if_changed` nothing to compare against, so every build
121    // rewrote every theme and the script invalidated itself through them.
122    let manifest = dir.join(THEMES_MANIFEST);
123    if let Ok(previous) = std::fs::read_to_string(&manifest) {
124        for stem in previous.lines().filter(|line| !line.is_empty()) {
125            if !shipped.contains(stem) {
126                std::fs::remove_file(dir.join(format!("{stem}.toml"))).expect("remove stale theme");
127            }
128        }
129    }
130
131    for (id, source) in makeover::embedded_themes() {
132        write_if_changed(&dir.join(format!("{id}.toml")), source, "theme");
133    }
134
135    // Written last, so it names only what this run actually produced. The
136    // `write_if_changed` keeps it as stable as the themes it lists, which is
137    // the same idempotency the theme files themselves get.
138    let body = shipped.iter().cloned().collect::<Vec<_>>().join("\n");
139    write_if_changed(&manifest, &body, "themes manifest");
140}
141
142/// Write `makeover-webview`'s component stylesheet to `path`.
143///
144/// Baked at build time rather than applied from JS the way the intent layer
145/// is, because composition never changes at runtime: no theme may reach it, so
146/// there is nothing to re-apply and no second pass over `:root` to pay for on
147/// load.
148///
149/// # Panics
150///
151/// If the file cannot be written.
152pub fn layout_css(path: impl AsRef<Path>, opts: &crate::Emit) {
153    write_if_changed(path.as_ref(), &crate::stylesheet(opts), "layout css");
154}
155
156/// Write `makeover-geometry`'s spacing layer, with its canonical density
157/// selection, to `path`.
158///
159/// The policy is the crate's, not this one's: touch hangs off
160/// the negation of a fine, hovering pointer because density is a capability rather
161/// than a device or a width, and `explicit_touch` names a selector an app sets
162/// when the user has chosen. See [`makeover_geometry::density_css`]. All this
163/// adds is the generated-file banner and the write.
164///
165/// Both spacing axes land here, in the order the crate defines them.
166/// [`makeover_geometry::size_class_css`] follows the density block because it
167/// is the narrower claim: density says what is pointing at the screen, size
168/// class says how much screen there is, and on a compact window the two shells
169/// tighten regardless of which density selected them.
170///
171/// [`makeover_geometry::compactness_css`] closes the file, unlayered: row
172/// packing is a reader's choice, not a capability or a width, so its override
173/// must beat the `--row-block` default the density block wrote.
174///
175/// # Panics
176///
177/// If the file cannot be written.
178pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
179    let mut css = String::from(
180        "/* Generated by makeover-webview from makeover-geometry. Do not edit.\n   \
181         Spacing is named by relationship, not by size. Touch density is a\n   \
182         capability question: a narrow desktop window still has a pointer, a\n   \
183         full-width tablet still has a finger. Window width is the separate\n   \
184         question below it: on a compact window the two shells tighten. */\n",
185    );
186    css.push_str(&makeover_geometry::density_css(explicit_touch));
187    css.push('\n');
188    css.push_str(&makeover_geometry::size_class_css());
189    css.push('\n');
190    css.push_str(&makeover_geometry::compactness_css());
191    write_if_changed(path.as_ref(), &css, "geometry css");
192}
193
194/// Write `makeover-timing`'s time axis, and the motion-off block that rides
195/// with it, to `path`.
196///
197/// The third generated axis, and it arrives the same way the spacing one does:
198/// a consumer that calls this gets `--timing-*`, `--motion-fade` and
199/// `--cadence-activity` without stating a number anywhere. All this adds is the
200/// banner and the write; `makeover_timing::timing_css` is the whole file and
201/// already wraps itself in [`makeover_geometry::CSS_LAYER`].
202///
203/// # Its own file, for the reason geometry has its own file
204///
205/// One generated file per crate, named for the axis it carries. Time is not a
206/// narrower claim about space the way size class is about density, so folding
207/// it into `geometry.css` would leave a file whose banner names one crate and
208/// whose contents come from two. The cost is a fourth `<link>` in the consumer,
209/// which is the cost the family already pays three times.
210///
211/// # The `prefers-reduced-motion` block is not optional
212///
213/// `makeover_timing::timing_css` emits the `:root` values and then a media
214/// block overriding two of them. Both land here, in that order, because they
215/// are one statement: a sheet carrying only the values animates at every rung
216/// for a reader who asked it not to, and does it silently.
217///
218/// # Panics
219///
220/// If the file cannot be written.
221pub fn timing_css(path: impl AsRef<Path>) {
222    let mut css = String::from(
223        "/* Generated by makeover-webview from makeover-timing. Do not edit.\n   \
224         A duration is named by what it is waiting for; the number follows.\n   \
225         Three axes: how long a state lasts, how long a change takes, and how\n   \
226         often a repeating mark repeats. The reduced-motion block below zeroes\n   \
227         the last two and leaves the waits alone. A reader asking for less\n   \
228         motion has not asked for a notice to leave early. */\n",
229    );
230    css.push_str(&makeover_timing::timing_css());
231    write_if_changed(path.as_ref(), &css, "timing css");
232}
233
234/// Write the house typography layer to `path`: the two `@font-face` rules and
235/// the two tokens they back.
236///
237/// `font_url` is the directory the consumer serves its fonts from, without a
238/// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri
239/// frontend loading relative to its index.
240///
241/// Generated rather than hand-written for the same reason the spacing layer is:
242/// the facts are the crates' and stating them per app is how three apps came to
243/// hold three different answers to `--font-mono`. It is a separate file from
244/// the layout stylesheet because `@font-face` rules take no part in the
245/// cascade and a consumer may need to load them ahead of a layer order it
246/// declares elsewhere.
247///
248/// # The consumer still has to put the faces there
249///
250/// This writes the CSS that fetches `QuasiMono.woff2` and `QuasiBody.woff2`; it
251/// does not write the fonts. It cannot: they are cut by `quasi-type`, which is
252/// `publish = false`, and this crate is on crates.io. A consumer takes
253/// quasi-type as a git dependency in its own `build.rs` and calls
254/// `quasi_type::cut`, the way `shop-font` does, writing each slot's woff2 under
255/// [`makeover::WEBFONT_MONO_FILE`] and [`makeover::WEBFONT_SANS_FILE`].
256///
257/// # Panics
258///
259/// If the file cannot be written.
260pub fn typography_css(path: impl AsRef<Path>, font_url: &str) {
261    typography_css_from(path, &makeover::Typography::house(font_url));
262}
263
264/// [`typography_css`], for a product that overrides a slot.
265///
266/// Layer 0 of the font model. A product with a brand face declares it here,
267/// once, and the generated sheet carries both the `@font-face` and the token,
268/// which replaces the hand-maintained `@font-face` block plus a
269/// `--font-heading` nothing else in the tree knew about:
270///
271/// ```no_run
272/// use makeover_webview::build::{FontFace, FontOverride, FontSlot, Typography};
273///
274/// makeover_webview::build::typography_css_from(
275///     "static/typography.css",
276///     &Typography::house("/static/fonts").with_override(
277///         FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
278///             .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])),
279///     ),
280/// );
281/// ```
282///
283/// The product still ships the face itself, exactly as it does for the house
284/// two: this writes the CSS that fetches it and cannot produce a font.
285///
286/// # Panics
287///
288/// If the file cannot be written.
289pub fn typography_css_from(path: impl AsRef<Path>, typography: &makeover::Typography) {
290    let mut css = String::from(
291        "/* Generated by makeover-webview from makeover. Do not edit.\n   \
292         Two needs, two names, then a system generic. The faces are cut by\n   \
293         quasi-type from Atkinson Hyperlegible plus the house glyph set, and\n   \
294         both are variable over wght 200-800 in one file, which is why the\n   \
295         @font-face rules name the range. The mono face opens at ExtraLight.\n   \
296         A third token here is this product's own brand face, declared as an\n   \
297         override in its build script. */\n\n",
298    );
299    css.push_str(&typography.css());
300    write_if_changed(path.as_ref(), &css, "typography css");
301}
302
303/// All the generated files at the layout every Tauri consumer already uses:
304/// `themes/` beside the manifest, and
305/// `frontend/css/{geometry,timing,layout,typography}.css` under it.
306///
307/// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
308/// [`themes`], [`layout_css`] and [`typography_css`] directly.
309///
310/// The font URL is `../fonts`. A `url()` in a stylesheet resolves against the
311/// stylesheet rather than the page, so from `frontend/css/typography.css` that
312/// is `frontend/fonts`: the one layout a Tauri app has, since its frontend is
313/// served from its own directory.
314///
315/// # Panics
316///
317/// If any file cannot be written.
318pub fn tauri_frontend(
319    manifest_dir: impl AsRef<Path>,
320    opts: &crate::Emit,
321    explicit_touch: Option<&str>,
322) {
323    tauri_frontend_with(
324        manifest_dir,
325        opts,
326        explicit_touch,
327        &makeover::Typography::house("../fonts"),
328    );
329}
330
331/// [`tauri_frontend`], for a product that overrides a font slot.
332///
333/// Separate rather than a fourth parameter, so an app with no font override
334/// does not have to name the house typography.
335///
336/// The base URL is the caller's: pass `Typography::house("../fonts")` unless
337/// the app serves fonts from somewhere other than the one layout a Tauri
338/// frontend has.
339///
340/// # Panics
341///
342/// If any file cannot be written.
343pub fn tauri_frontend_with(
344    manifest_dir: impl AsRef<Path>,
345    opts: &crate::Emit,
346    explicit_touch: Option<&str>,
347    typography: &makeover::Typography,
348) {
349    let root = manifest_dir.as_ref();
350    let css = root.join("frontend").join("css");
351    themes(root.join("themes"));
352    geometry_css(css.join("geometry.css"), explicit_touch);
353    // Beside geometry rather than after layout: both are value files the
354    // component sheet reads, and a consumer's `<link>` order follows this one.
355    timing_css(css.join("timing.css"));
356    layout_css(css.join("layout.css"), opts);
357    typography_css_from(css.join("typography.css"), typography);
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::test_support::scratch;
364
365    #[test]
366    fn themes_are_written_one_file_per_id() {
367        let dir = scratch("themes");
368        themes(&dir);
369        // `.toml` only: the run also writes the manifest, which is not a theme.
370        let count = std::fs::read_dir(&dir)
371            .unwrap()
372            .flatten()
373            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "toml"))
374            .count();
375        assert_eq!(count, makeover::embedded_themes().count());
376        assert!(count > 0, "makeover ships no themes?");
377    }
378
379    #[test]
380    fn a_theme_removed_upstream_does_not_linger() {
381        // The detail that makes this worth sharing rather than retyping. A
382        // previous run generated `gone-upstream.toml` and recorded it in the
383        // manifest; this run no longer ships it, so the recorded entry is what
384        // marks the file stale.
385        let dir = scratch("stale");
386        std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
387        std::fs::write(dir.join(THEMES_MANIFEST), "gone-upstream\n").unwrap();
388        themes(&dir);
389        assert!(!dir.join("gone-upstream.toml").exists());
390    }
391
392    #[test]
393    fn a_foreign_toml_that_was_not_generated_survives() {
394        // The manifest is the boundary: a `.toml` the consumer placed here was
395        // never generated by `themes`, so a rebuild must not clear it the way
396        // the old by-stem sweep did.
397        let dir = scratch("foreign");
398        std::fs::write(dir.join("user-custom.toml"), "# mine").unwrap();
399        themes(&dir);
400        assert!(dir.join("user-custom.toml").exists());
401    }
402
403    #[test]
404    fn a_second_run_touches_nothing() {
405        // The property the whole write-if-changed pass exists for: a build
406        // script that rewrites its own outputs invalidates itself, and cargo
407        // then reruns it on every build whatever changed. Asserted on mtime
408        // rather than on content, because content was always correct; it was
409        // the mtime that was the bug.
410        let dir = scratch("idempotent");
411        themes(&dir);
412        let before: Vec<_> = std::fs::read_dir(&dir)
413            .unwrap()
414            .flatten()
415            .map(|e| (e.path(), e.metadata().unwrap().modified().unwrap()))
416            .collect();
417        assert!(!before.is_empty(), "themes() wrote nothing to compare");
418
419        themes(&dir);
420        for (path, was) in before {
421            let now = std::fs::metadata(&path).unwrap().modified().unwrap();
422            assert_eq!(
423                was,
424                now,
425                "{} was rewritten by a second run with nothing changed",
426                path.display()
427            );
428        }
429    }
430
431    #[test]
432    fn a_generated_output_is_named_with_rerun_if_changed() {
433        // A deleted output, or a gitignored `themes/` emptied by `git clean
434        // -X`, has to be regenerated on the next build. That only happens if
435        // every write names its path with `rerun-if-changed`, which cargo
436        // treats as an input: a path newer than the script's last output, or
437        // gone, reruns it. Observed through a child process because the test
438        // harness owns this process's stdout and swallows a `println!`.
439        let exe = std::env::current_exe().expect("test binary path");
440        let child = std::process::Command::new(exe)
441            .args(["--nocapture", "emit_rerun_directive_child"])
442            .env("MAKEOVER_EMIT_RERUN", "1")
443            .output()
444            .expect("spawn child");
445        assert!(child.status.success(), "child failed: {child:?}");
446        let stdout = String::from_utf8_lossy(&child.stdout);
447        assert!(
448            stdout.contains("cargo::rerun-if-changed="),
449            "write_if_changed named no output with rerun-if-changed:\n{stdout}"
450        );
451    }
452
453    /// The child for [`a_generated_output_is_named_with_rerun_if_changed`]:
454    /// a silent no-op unless the parent set the trigger, so the ordinary suite
455    /// run does not write files or print directives.
456    #[test]
457    fn emit_rerun_directive_child() {
458        if std::env::var_os("MAKEOVER_EMIT_RERUN").is_none() {
459            return;
460        }
461        layout_css(
462            scratch("rerun-directive").join("layout.css"),
463            &crate::Emit::default(),
464        );
465    }
466
467    #[test]
468    fn a_non_theme_file_is_left_alone() {
469        // Only .toml is cleared, so a README or a .gitignore in the bundle
470        // directory survives a rebuild.
471        let dir = scratch("keep");
472        std::fs::write(dir.join("README.md"), "not a theme").unwrap();
473        themes(&dir);
474        assert!(dir.join("README.md").exists());
475    }
476
477    #[test]
478    fn the_stylesheet_lands_and_names_no_colour() {
479        let dir = scratch("css");
480        let path = dir.join("layout.css");
481        layout_css(&path, &crate::Emit::default());
482        let css = std::fs::read_to_string(&path).unwrap();
483        assert!(css.contains("--bevel-raised"));
484        assert!(
485            !css.contains('#'),
486            "a colour literal reached a build output"
487        );
488    }
489
490    #[test]
491    fn the_typography_file_declares_the_faces_before_the_tokens_that_name_them() {
492        // The vocabulary itself is tested in makeover. What is this crate's
493        // job is that both halves reach one file, in an order that works: a
494        // `@font-face` may follow its use in the cascade, but reading the file
495        // is how anyone finds out a face is fetched at all.
496        let dir = scratch("typography");
497        let path = dir.join("typography.css");
498        typography_css(&path, "/static/fonts");
499        let css = std::fs::read_to_string(&path).unwrap();
500
501        assert!(css.starts_with("/* Generated by makeover-webview"));
502        assert!(
503            css.find("@font-face").unwrap() < css.find(":root").unwrap(),
504            "the tokens come first, so the file reads as a stack with no ground"
505        );
506        assert!(css.contains("url(\"/static/fonts/QuasiMono.woff2\")"));
507        assert!(css.contains("--font-sans: \"Quasi Body\", sans-serif;"));
508
509        // Not a cascade layer. `@font-face` takes no part in the cascade and a
510        // consumer may need these rules ahead of a layer order it declares
511        // elsewhere, so wrapping this file in one would be a silent trap.
512        assert!(!css.contains("@layer"));
513    }
514
515    #[test]
516    fn an_overridden_slot_reaches_the_same_file_as_the_house_two() {
517        // Layer 0's whole point: the brand face stops being a hand-maintained
518        // `@font-face` in the app's own stylesheet and becomes a line in the
519        // generated one, beside the slots it sits next to.
520        let dir = scratch("typography-override");
521        let path = dir.join("typography.css");
522        typography_css_from(
523            &path,
524            &Typography::house("/static/fonts").with_override(
525                FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
526                    .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])),
527            ),
528        );
529        let css = std::fs::read_to_string(&path).unwrap();
530
531        // `@font-face {`, not `@font-face`: the header comment names the
532        // at-rule too, and counting that would make this pass for the wrong
533        // reason the day the comment is reworded.
534        assert_eq!(css.matches("@font-face {").count(), 3);
535        assert!(css.contains("--font-display: \"Young Serif\", serif;"));
536        assert!(css.contains("--font-mono: \"Quasi Mono\", monospace;"));
537        assert!(css.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
538        assert!(!css.contains("@layer"));
539    }
540
541    #[test]
542    fn the_default_tauri_layout_is_the_house_layer_and_nothing_else() {
543        // `tauri_frontend` delegating through `tauri_frontend_with` must not
544        // change a byte for the three consumers already calling it.
545        let dir = scratch("tauri-default");
546        let plain = dir.join("plain.css");
547        let house = dir.join("house.css");
548        typography_css(&plain, "../fonts");
549        typography_css_from(&house, &Typography::house("../fonts"));
550        assert_eq!(
551            std::fs::read_to_string(&plain).unwrap(),
552            std::fs::read_to_string(&house).unwrap()
553        );
554    }
555
556    #[test]
557    fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
558        // The policy itself is tested in makeover-geometry. What is this
559        // crate's job is that the banner is there and the policy reached the
560        // file at all.
561        let dir = scratch("geometry");
562        let path = dir.join("geometry.css");
563        geometry_css(&path, Some(".ui-mode-mobile"));
564        let css = std::fs::read_to_string(&path).unwrap();
565        assert!(css.starts_with("/* Generated by makeover-webview"));
566        assert!(css.contains(&format!(
567            "@media {}",
568            makeover_geometry::Density::Touch.media_condition()
569        )));
570        assert!(css.contains(".ui-mode-mobile"));
571        // The width axis rides along, and only the shells are in it: a gap
572        // between two controls in a width query is the bug size_class_css
573        // exists to keep out.
574        assert!(css.contains("--gap-pane"), "no compact shell override");
575        let compact = css
576            .split(&format!(
577                "@media {}",
578                makeover_geometry::SizeClass::Compact.media_condition()
579            ))
580            .nth(1)
581            .expect("compact block");
582        assert!(
583            !compact.contains("--gap-peer"),
584            "a control gap crept into a width query"
585        );
586        // Row packing rides along, unlayered: the reader's compact override
587        // must beat the `--row-block` default the density block wrote.
588        assert!(
589            css.contains(&format!(
590                ":root[{}=\"{}\"]",
591                makeover_geometry::COMPACTNESS_ATTRIBUTE,
592                makeover_geometry::Compactness::Compact.attribute_value()
593            )),
594            "no compact row-packing override"
595        );
596    }
597
598    #[test]
599    fn the_timing_file_carries_the_values_and_the_block_that_overrides_them() {
600        // The rungs themselves are tested in makeover-timing. What is this
601        // crate's to get wrong is dropping half the file: the values are
602        // useless noise without the media block, and the media block on its
603        // own overrides nothing.
604        let dir = scratch("timing");
605        let path = dir.join("timing.css");
606        timing_css(&path);
607        let css = std::fs::read_to_string(&path).unwrap();
608        assert!(css.starts_with("/* Generated by makeover-webview"));
609        // One token per axis, so a crate that grows a fourth axis and is not
610        // emitted here fails somewhere other than on screen.
611        assert!(css.contains("--timing-dismiss"), "no intent tokens");
612        assert!(css.contains("--motion-fade"), "no motion token");
613        assert!(css.contains("--cadence-activity"), "no cadence token");
614        assert!(
615            css.contains("@media (prefers-reduced-motion: reduce)"),
616            "the values shipped without the block that turns them off"
617        );
618        // The block comes after the values it overrides. Same specificity,
619        // so the order is the whole of the win.
620        assert!(
621            css.find(":root").unwrap() < css.find("prefers-reduced-motion").unwrap(),
622            "the motion-off block cannot override values declared after it"
623        );
624        // Inside the family's layer, like every other generated sheet:
625        // unlayered declarations outrank every named layer, so a generated
626        // file outside it beats the app's own overrides.
627        assert!(css.contains(makeover_geometry::CSS_LAYER));
628    }
629
630    #[test]
631    fn the_tauri_layout_puts_all_four_where_the_apps_look() {
632        let root = scratch("tauri");
633        std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
634        tauri_frontend(&root, &crate::Emit::default(), None);
635        let css = root.join("frontend").join("css");
636        for file in ["geometry.css", "timing.css", "layout.css", "typography.css"] {
637            assert!(css.join(file).exists(), "{file} was not written");
638        }
639        assert!(root.join("themes").is_dir());
640    }
641}