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_use,
51};
52
53/// Re-exported so a consumer's `build.rs` needs one dependency rather than
54/// three. Nothing here wraps it; the emitter's options are the emitter's.
55pub use makeover_webview::Emit;
56
57/// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
58///
59/// Clears stale `.toml` files first, so a theme removed or renamed upstream
60/// does not linger in the bundle from an earlier build. That detail is the
61/// reason this is worth sharing rather than retyping: it is easy to omit and
62/// its absence shows up as a theme that will not go away.
63///
64/// # Panics
65///
66/// If the directory cannot be created, read, or written. A build script has
67/// nowhere useful to return an error to, and a half-materialised theme set is
68/// worse than a failed build.
69pub fn themes(dir: impl AsRef<Path>) {
70    let dir = dir.as_ref();
71    std::fs::create_dir_all(dir).expect("create themes dir");
72
73    for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
74        let path = entry.path();
75        if path.extension().is_some_and(|e| e == "toml") {
76            std::fs::remove_file(&path).expect("remove stale theme");
77        }
78    }
79
80    for (id, source) in makeover::embedded_themes() {
81        std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
82    }
83}
84
85/// Write `makeover-webview`'s component stylesheet to `path`.
86///
87/// Baked at build time rather than applied from JS the way the intent layer
88/// is, because composition never changes at runtime: no theme may reach it, so
89/// there is nothing to re-apply and no second pass over `:root` to pay for on
90/// load.
91///
92/// # Panics
93///
94/// If the file cannot be written.
95pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
96    std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
97}
98
99/// Write `makeover-geometry`'s spacing layer, with its canonical density
100/// selection, to `path`.
101///
102/// The policy is the crate's, not this one's: touch hangs off
103/// `(hover: none), (pointer: coarse)` because density is a capability rather
104/// than a device or a width, and `explicit_touch` names a selector an app sets
105/// when the user has chosen. See [`makeover_geometry::density_css`]. All this
106/// adds is the generated-file banner and the write.
107///
108/// Both spacing axes land here, in the order the crate defines them.
109/// [`makeover_geometry::size_class_css`] follows the density block because it
110/// is the narrower claim: density says what is pointing at the screen, size
111/// class says how much screen there is, and on a compact window the two shells
112/// tighten regardless of which density selected them. Shipped in
113/// makeover-geometry 0.7.0 and emitted by nobody until 2026-08-10, which meant
114/// the axis existed in the crate and reached no stylesheet.
115///
116/// # Panics
117///
118/// If the file cannot be written.
119pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
120    let mut css = String::from(
121        "/* Generated by makeover-build from makeover-geometry. Do not edit.\n   \
122         Spacing is named by relationship, not by size. Touch density is a\n   \
123         capability question: a narrow desktop window still has a pointer, a\n   \
124         full-width tablet still has a finger. Window width is the separate\n   \
125         question below it: on a compact window the two shells tighten. */\n",
126    );
127    css.push_str(&makeover_geometry::density_css(explicit_touch));
128    css.push('\n');
129    css.push_str(&makeover_geometry::size_class_css());
130    std::fs::write(path, css).expect("write geometry css");
131}
132
133/// All three generated files at the layout every Tauri consumer already uses:
134/// `themes/` beside the manifest, and `frontend/css/{geometry,layout}.css`
135/// under it.
136///
137/// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
138/// [`themes`] and [`layout_css`] directly.
139///
140/// # Panics
141///
142/// If either file cannot be written.
143pub fn tauri_frontend(
144    manifest_dir: impl AsRef<Path>,
145    opts: &makeover_webview::Emit,
146    explicit_touch: Option<&str>,
147) {
148    let root = manifest_dir.as_ref();
149    let css = root.join("frontend").join("css");
150    themes(root.join("themes"));
151    geometry_css(css.join("geometry.css"), explicit_touch);
152    layout_css(css.join("layout.css"), opts);
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// A scratch directory keyed by process id, so a parallel test run does
160    /// not collide. No timestamp: the pid is enough and is deterministic
161    /// within a run.
162    fn scratch(name: &str) -> std::path::PathBuf {
163        let dir =
164            std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
165        let _ = std::fs::remove_dir_all(&dir);
166        std::fs::create_dir_all(&dir).expect("create scratch");
167        dir
168    }
169
170    #[test]
171    fn themes_are_written_one_file_per_id() {
172        let dir = scratch("themes");
173        themes(&dir);
174        let count = std::fs::read_dir(&dir).unwrap().count();
175        assert_eq!(count, makeover::embedded_themes().count());
176        assert!(count > 0, "makeover ships no themes?");
177    }
178
179    #[test]
180    fn a_theme_removed_upstream_does_not_linger() {
181        // The detail that makes this worth sharing rather than retyping.
182        let dir = scratch("stale");
183        std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
184        themes(&dir);
185        assert!(!dir.join("gone-upstream.toml").exists());
186    }
187
188    #[test]
189    fn a_non_theme_file_is_left_alone() {
190        // Only .toml is cleared, so a README or a .gitignore in the bundle
191        // directory survives a rebuild.
192        let dir = scratch("keep");
193        std::fs::write(dir.join("README.md"), "not a theme").unwrap();
194        themes(&dir);
195        assert!(dir.join("README.md").exists());
196    }
197
198    #[test]
199    fn the_stylesheet_lands_and_names_no_colour() {
200        let dir = scratch("css");
201        let path = dir.join("layout.css");
202        layout_css(&path, &makeover_webview::Emit::default());
203        let css = std::fs::read_to_string(&path).unwrap();
204        assert!(css.contains("--bevel-raised"));
205        assert!(
206            !css.contains('#'),
207            "a colour literal reached a build output"
208        );
209    }
210
211    #[test]
212    fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
213        // The policy itself is tested in makeover-geometry. What is this
214        // crate's job is that the banner is there and the policy reached the
215        // file at all.
216        let dir = scratch("geometry");
217        let path = dir.join("geometry.css");
218        geometry_css(&path, Some(".ui-mode-mobile"));
219        let css = std::fs::read_to_string(&path).unwrap();
220        assert!(css.starts_with("/* Generated by makeover-build"));
221        assert!(css.contains("@media (hover: none), (pointer: coarse)"));
222        assert!(css.contains(".ui-mode-mobile"));
223        // The width axis rides along, and only the shells are in it: a gap
224        // between two controls in a width query is the bug size_class_css
225        // exists to keep out.
226        assert!(css.contains("--gap-pane"), "no compact shell override");
227        let compact = css
228            .split("@media (max-width")
229            .nth(1)
230            .expect("compact block");
231        assert!(
232            !compact.contains("--gap-peer"),
233            "a control gap crept into a width query"
234        );
235    }
236
237    #[test]
238    fn the_tauri_layout_puts_all_three_where_the_apps_look() {
239        let root = scratch("tauri");
240        std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
241        tauri_frontend(&root, &makeover_webview::Emit::default(), None);
242        assert!(
243            root.join("frontend")
244                .join("css")
245                .join("geometry.css")
246                .exists()
247        );
248        assert!(
249            root.join("frontend")
250                .join("css")
251                .join("layout.css")
252                .exists()
253        );
254        assert!(root.join("themes").is_dir());
255    }
256}