makeover-build 0.44.0

Build-script support for the make-family design system: materialise makeover's themes and makeover-webview's stylesheet into a Tauri app's frontend, once, instead of copying the same twenty lines into every consumer's build.rs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! Build-script support for the make-family design system.
//!
//! <!-- wiki: makeover-geometry -->
//!
//! Every consumer materialises the same generated files from a `build.rs`, and
//! until now every consumer wrote that code itself. GoingsOn and Balanced
//! Breakfast grew byte-identical copies of the theme materialiser during the
//! makeover-geometry adoption, and the layout stylesheet would have been the
//! third and fourth copies. This is that code, once.
//!
//! # The geometry emitter, and why it took a decision to land
//!
//! [`geometry_css`] was deliberately absent at first. GoingsOn and Balanced
//! Breakfast did not agree on it: GO scoped the touch preset to a
//! `ui-mode-mobile` class set by a bootstrap script, BB hung it off
//! `@media (hover: none)`, and audiofiles had no switch at all. Extracting it
//! then would have meant picking one of those policies by accident, inside a
//! shared crate, without anyone deciding.
//!
//! Density selection was settled instead -- touch is a capability, so it hangs
//! off `(hover: none), (pointer: coarse)` and never off a user-agent string or
//! a breakpoint -- and the emitter followed. Recording an agreement rather than
//! manufacturing one is the whole point, and it is why the order was that way
//! round.
//!
//! # Why these files are generated rather than checked in
//!
//! Tauri's resource globs are read by its CLI against the crate directory, so
//! they cannot point into a registry checkout or `OUT_DIR`. Materialising into
//! the crate keeps the source crate authoritative without vendoring a second
//! copy that drifts. Every path written here is expected to be gitignored.
//!
//! # The other half: what is checked rather than written
//!
//! A consumer's frontend is not all generated. The stylesheet and the scripts
//! are hand-written and state some of the same facts the generated files ask
//! the crates for, so they can drift where a generated file cannot. [`drift`]
//! holds the checks that keep them honest, and they are assertions rather than
//! substitutions on purpose: a file that has to be generated to be correct
//! stops being readable on its own.

#![forbid(unsafe_code)]

pub mod drift;

use std::path::Path;

pub use drift::{
    check_breakpoints, check_breakpoints_files, check_touch_density, check_vocabulary,
    check_vocabulary_files, check_vocabulary_use,
};

/// Re-exported so a consumer's `build.rs` needs one dependency rather than
/// three. Nothing here wraps it; the emitter's options are the emitter's.
pub use makeover_webview::Emit;

/// The filenames [`typography_css`]'s `@font-face` rules fetch.
///
/// Re-exported for the same reason as [`Emit`], and load-bearing for a further
/// one: the consumer's own build script writes those two files, so the emitter
/// and the writer have to agree on the name. Through this they agree on a
/// constant rather than on a string typed in two repositories.
pub use makeover::{WEBFONT_MONO_FILE, WEBFONT_SANS_FILE};

/// Layer 0 of the font model, re-exported for the same one-dependency reason.
///
/// A build script composing an override needs all four names and has no other
/// reason to depend on `makeover` directly.
pub use makeover::{FontFace, FontOverride, FontSlot, Typography};

/// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
///
/// Clears stale `.toml` files first, so a theme removed or renamed upstream
/// does not linger in the bundle from an earlier build. That detail is the
/// reason this is worth sharing rather than retyping: it is easy to omit and
/// its absence shows up as a theme that will not go away.
///
/// # Panics
///
/// If the directory cannot be created, read, or written. A build script has
/// nowhere useful to return an error to, and a half-materialised theme set is
/// worse than a failed build.
pub fn themes(dir: impl AsRef<Path>) {
    let dir = dir.as_ref();
    std::fs::create_dir_all(dir).expect("create themes dir");

    for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
        let path = entry.path();
        if path.extension().is_some_and(|e| e == "toml") {
            std::fs::remove_file(&path).expect("remove stale theme");
        }
    }

    for (id, source) in makeover::embedded_themes() {
        std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
    }
}

/// Write `makeover-webview`'s component stylesheet to `path`.
///
/// Baked at build time rather than applied from JS the way the intent layer
/// is, because composition never changes at runtime: no theme may reach it, so
/// there is nothing to re-apply and no second pass over `:root` to pay for on
/// load.
///
/// # Panics
///
/// If the file cannot be written.
pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
    std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
}

/// Write `makeover-geometry`'s spacing layer, with its canonical density
/// selection, to `path`.
///
/// The policy is the crate's, not this one's: touch hangs off
/// `(hover: none), (pointer: coarse)` because density is a capability rather
/// than a device or a width, and `explicit_touch` names a selector an app sets
/// when the user has chosen. See [`makeover_geometry::density_css`]. All this
/// adds is the generated-file banner and the write.
///
/// Both spacing axes land here, in the order the crate defines them.
/// [`makeover_geometry::size_class_css`] follows the density block because it
/// is the narrower claim: density says what is pointing at the screen, size
/// class says how much screen there is, and on a compact window the two shells
/// tighten regardless of which density selected them. Shipped in
/// makeover-geometry 0.7.0 and emitted by nobody until 2026-08-10, which meant
/// the axis existed in the crate and reached no stylesheet.
///
/// # Panics
///
/// If the file cannot be written.
pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
    let mut css = String::from(
        "/* Generated by makeover-build from makeover-geometry. Do not edit.\n   \
         Spacing is named by relationship, not by size. Touch density is a\n   \
         capability question: a narrow desktop window still has a pointer, a\n   \
         full-width tablet still has a finger. Window width is the separate\n   \
         question below it: on a compact window the two shells tighten. */\n",
    );
    css.push_str(&makeover_geometry::density_css(explicit_touch));
    css.push('\n');
    css.push_str(&makeover_geometry::size_class_css());
    std::fs::write(path, css).expect("write geometry css");
}

/// Write the house typography layer to `path`: the two `@font-face` rules and
/// the two tokens they back.
///
/// `font_url` is the directory the consumer serves its fonts from, without a
/// trailing slash — `/static/fonts` on the MNW server, `fonts` for a Tauri
/// frontend loading relative to its index.
///
/// Generated rather than hand-written for the same reason the spacing layer is:
/// the facts are the crates' and stating them per app is how three apps came to
/// hold three different answers to `--font-mono`. It is a separate file from
/// the layout stylesheet because `@font-face` rules take no part in the
/// cascade and a consumer may need to load them ahead of a layer order it
/// declares elsewhere.
///
/// # The consumer still has to put the faces there
///
/// This writes the CSS that fetches `QuasiMono.woff2` and `QuasiBody.woff2`; it
/// does not write the fonts. It cannot: they are cut by `quasi-type`, which is
/// `publish = false`, and this crate is on crates.io. A consumer takes
/// quasi-type as a git dependency in its own `build.rs` and calls
/// `quasi_type::cut`, the way `shop-font` does, writing each slot's woff2 under
/// [`makeover::WEBFONT_MONO_FILE`] and [`makeover::WEBFONT_SANS_FILE`].
///
/// # Panics
///
/// If the file cannot be written.
pub fn typography_css(path: impl AsRef<Path>, font_url: &str) {
    typography_css_from(path, &makeover::Typography::house(font_url));
}

/// [`typography_css`], for a product that overrides a slot.
///
/// Layer 0 of the font model. A product with a brand face declares it here,
/// once, and the generated sheet carries both the `@font-face` and the token —
/// which is what replaces the hand-maintained `@font-face` block plus a
/// `--font-heading` nothing else in the tree knew about:
///
/// ```no_run
/// use makeover_build::{FontFace, FontOverride, FontSlot, Typography};
///
/// makeover_build::typography_css_from(
///     "static/typography.css",
///     &Typography::house("/static/fonts").with_override(
///         FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
///             .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])),
///     ),
/// );
/// ```
///
/// The product still ships the face itself, exactly as it does for the house
/// two: this writes the CSS that fetches it and cannot produce a font.
///
/// # Panics
///
/// If the file cannot be written.
pub fn typography_css_from(path: impl AsRef<Path>, typography: &makeover::Typography) {
    let mut css = String::from(
        "/* Generated by makeover-build from makeover. Do not edit.\n   \
         Two needs, two names, then a system generic. The faces are cut by\n   \
         quasi-type from Atkinson Hyperlegible plus the house glyph set, and\n   \
         both are variable over wght 200-800 in one file, which is why the\n   \
         @font-face rules name the range. The mono face opens at ExtraLight.\n   \
         A third token here is this product's own brand face, declared as an\n   \
         override in its build script. */\n\n",
    );
    css.push_str(&typography.css());
    std::fs::write(path, css).expect("write typography css");
}

/// All the generated files at the layout every Tauri consumer already uses:
/// `themes/` beside the manifest, and
/// `frontend/css/{geometry,layout,typography}.css` under it.
///
/// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
/// [`themes`], [`layout_css`] and [`typography_css`] directly.
///
/// The font URL is `fonts`, relative to the frontend's index — the one layout
/// a Tauri app has, since its frontend is served from its own directory.
///
/// # Panics
///
/// If any file cannot be written.
pub fn tauri_frontend(
    manifest_dir: impl AsRef<Path>,
    opts: &makeover_webview::Emit,
    explicit_touch: Option<&str>,
) {
    tauri_frontend_with(
        manifest_dir,
        opts,
        explicit_touch,
        &makeover::Typography::house("../fonts"),
    );
}

/// [`tauri_frontend`], for a product that overrides a font slot.
///
/// Separate rather than a fourth parameter on `tauri_frontend` so the three
/// consumers already calling it do not have to move: goingson is held at an
/// older `makeover` by a theming decision unrelated to fonts, and a signature
/// change here would make a font feature it cannot take into a build break it
/// cannot avoid.
///
/// The base URL is the caller's: pass `Typography::house("../fonts")` unless
/// the app serves fonts from somewhere other than the one layout a Tauri
/// frontend has.
///
/// # Panics
///
/// If any file cannot be written.
pub fn tauri_frontend_with(
    manifest_dir: impl AsRef<Path>,
    opts: &makeover_webview::Emit,
    explicit_touch: Option<&str>,
    typography: &makeover::Typography,
) {
    let root = manifest_dir.as_ref();
    let css = root.join("frontend").join("css");
    themes(root.join("themes"));
    geometry_css(css.join("geometry.css"), explicit_touch);
    layout_css(css.join("layout.css"), opts);
    typography_css_from(css.join("typography.css"), typography);
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A scratch directory keyed by process id, so a parallel test run does
    /// not collide. No timestamp: the pid is enough and is deterministic
    /// within a run.
    fn scratch(name: &str) -> std::path::PathBuf {
        let dir =
            std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).expect("create scratch");
        dir
    }

    #[test]
    fn themes_are_written_one_file_per_id() {
        let dir = scratch("themes");
        themes(&dir);
        let count = std::fs::read_dir(&dir).unwrap().count();
        assert_eq!(count, makeover::embedded_themes().count());
        assert!(count > 0, "makeover ships no themes?");
    }

    #[test]
    fn a_theme_removed_upstream_does_not_linger() {
        // The detail that makes this worth sharing rather than retyping.
        let dir = scratch("stale");
        std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
        themes(&dir);
        assert!(!dir.join("gone-upstream.toml").exists());
    }

    #[test]
    fn a_non_theme_file_is_left_alone() {
        // Only .toml is cleared, so a README or a .gitignore in the bundle
        // directory survives a rebuild.
        let dir = scratch("keep");
        std::fs::write(dir.join("README.md"), "not a theme").unwrap();
        themes(&dir);
        assert!(dir.join("README.md").exists());
    }

    #[test]
    fn the_stylesheet_lands_and_names_no_colour() {
        let dir = scratch("css");
        let path = dir.join("layout.css");
        layout_css(&path, &makeover_webview::Emit::default());
        let css = std::fs::read_to_string(&path).unwrap();
        assert!(css.contains("--bevel-raised"));
        assert!(
            !css.contains('#'),
            "a colour literal reached a build output"
        );
    }

    #[test]
    fn the_typography_file_declares_the_faces_before_the_tokens_that_name_them() {
        // The vocabulary itself is tested in makeover. What is this crate's
        // job is that both halves reach one file, in an order that works: a
        // `@font-face` may follow its use in the cascade, but reading the file
        // is how anyone finds out a face is fetched at all.
        let dir = scratch("typography");
        let path = dir.join("typography.css");
        typography_css(&path, "/static/fonts");
        let css = std::fs::read_to_string(&path).unwrap();

        assert!(css.starts_with("/* Generated by makeover-build"));
        assert!(
            css.find("@font-face").unwrap() < css.find(":root").unwrap(),
            "the tokens come first, so the file reads as a stack with no ground"
        );
        assert!(css.contains("url(\"/static/fonts/QuasiMono.woff2\")"));
        assert!(css.contains("--font-sans: \"Quasi Body\", sans-serif;"));

        // Not a cascade layer. `@font-face` takes no part in the cascade and a
        // consumer may need these rules ahead of a layer order it declares
        // elsewhere, so wrapping this file in one would be a silent trap.
        assert!(!css.contains("@layer"));
    }

    #[test]
    fn an_overridden_slot_reaches_the_same_file_as_the_house_two() {
        // Layer 0's whole point: the brand face stops being a hand-maintained
        // `@font-face` in the app's own stylesheet and becomes a line in the
        // generated one, beside the slots it sits next to.
        let dir = scratch("typography-override");
        let path = dir.join("typography.css");
        typography_css_from(
            &path,
            &Typography::house("/static/fonts").with_override(
                FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
                    .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])),
            ),
        );
        let css = std::fs::read_to_string(&path).unwrap();

        // `@font-face {`, not `@font-face`: the header comment names the
        // at-rule too, and counting that would make this pass for the wrong
        // reason the day the comment is reworded.
        assert_eq!(css.matches("@font-face {").count(), 3);
        assert!(css.contains("--font-display: \"Young Serif\", serif;"));
        assert!(css.contains("--font-mono: \"Quasi Mono\", monospace;"));
        assert!(css.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
        assert!(!css.contains("@layer"));
    }

    #[test]
    fn the_default_tauri_layout_is_the_house_layer_and_nothing_else() {
        // `tauri_frontend` delegating through `tauri_frontend_with` must not
        // change a byte for the three consumers already calling it.
        let dir = scratch("tauri-default");
        let plain = dir.join("plain.css");
        let house = dir.join("house.css");
        typography_css(&plain, "../fonts");
        typography_css_from(&house, &Typography::house("../fonts"));
        assert_eq!(
            std::fs::read_to_string(&plain).unwrap(),
            std::fs::read_to_string(&house).unwrap()
        );
    }

    #[test]
    fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
        // The policy itself is tested in makeover-geometry. What is this
        // crate's job is that the banner is there and the policy reached the
        // file at all.
        let dir = scratch("geometry");
        let path = dir.join("geometry.css");
        geometry_css(&path, Some(".ui-mode-mobile"));
        let css = std::fs::read_to_string(&path).unwrap();
        assert!(css.starts_with("/* Generated by makeover-build"));
        assert!(css.contains("@media (hover: none), (pointer: coarse)"));
        assert!(css.contains(".ui-mode-mobile"));
        // The width axis rides along, and only the shells are in it: a gap
        // between two controls in a width query is the bug size_class_css
        // exists to keep out.
        assert!(css.contains("--gap-pane"), "no compact shell override");
        let compact = css
            .split("@media (max-width")
            .nth(1)
            .expect("compact block");
        assert!(
            !compact.contains("--gap-peer"),
            "a control gap crept into a width query"
        );
    }

    #[test]
    fn the_tauri_layout_puts_all_three_where_the_apps_look() {
        let root = scratch("tauri");
        std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
        tauri_frontend(&root, &makeover_webview::Emit::default(), None);
        assert!(
            root.join("frontend")
                .join("css")
                .join("geometry.css")
                .exists()
        );
        assert!(
            root.join("frontend")
                .join("css")
                .join("layout.css")
                .exists()
        );
        assert!(root.join("themes").is_dir());
    }
}