Skip to main content

makeover_build/
lib.rs

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