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/// The filenames [`typography_css`]'s `@font-face` rules fetch.
58///
59/// Re-exported for the same reason as [`Emit`], and load-bearing for a further
60/// one: the consumer's own build script writes those two files, so the emitter
61/// and the writer have to agree on the name. Through this they agree on a
62/// constant rather than on a string typed in two repositories.
63pub use makeover::{WEBFONT_MONO_FILE, WEBFONT_SANS_FILE};
64
65/// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
66///
67/// Clears stale `.toml` files first, so a theme removed or renamed upstream
68/// does not linger in the bundle from an earlier build. That detail is the
69/// reason this is worth sharing rather than retyping: it is easy to omit and
70/// its absence shows up as a theme that will not go away.
71///
72/// # Panics
73///
74/// If the directory cannot be created, read, or written. A build script has
75/// nowhere useful to return an error to, and a half-materialised theme set is
76/// worse than a failed build.
77pub fn themes(dir: impl AsRef<Path>) {
78 let dir = dir.as_ref();
79 std::fs::create_dir_all(dir).expect("create themes dir");
80
81 for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
82 let path = entry.path();
83 if path.extension().is_some_and(|e| e == "toml") {
84 std::fs::remove_file(&path).expect("remove stale theme");
85 }
86 }
87
88 for (id, source) in makeover::embedded_themes() {
89 std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
90 }
91}
92
93/// Write `makeover-webview`'s component stylesheet to `path`.
94///
95/// Baked at build time rather than applied from JS the way the intent layer
96/// is, because composition never changes at runtime: no theme may reach it, so
97/// there is nothing to re-apply and no second pass over `:root` to pay for on
98/// load.
99///
100/// # Panics
101///
102/// If the file cannot be written.
103pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
104 std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
105}
106
107/// Write `makeover-geometry`'s spacing layer, with its canonical density
108/// selection, to `path`.
109///
110/// The policy is the crate's, not this one's: touch hangs off
111/// `(hover: none), (pointer: coarse)` because density is a capability rather
112/// than a device or a width, and `explicit_touch` names a selector an app sets
113/// when the user has chosen. See [`makeover_geometry::density_css`]. All this
114/// adds is the generated-file banner and the write.
115///
116/// Both spacing axes land here, in the order the crate defines them.
117/// [`makeover_geometry::size_class_css`] follows the density block because it
118/// is the narrower claim: density says what is pointing at the screen, size
119/// class says how much screen there is, and on a compact window the two shells
120/// tighten regardless of which density selected them. Shipped in
121/// makeover-geometry 0.7.0 and emitted by nobody until 2026-08-10, which meant
122/// the axis existed in the crate and reached no stylesheet.
123///
124/// # Panics
125///
126/// If the file cannot be written.
127pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
128 let mut css = String::from(
129 "/* Generated by makeover-build from makeover-geometry. Do not edit.\n \
130 Spacing is named by relationship, not by size. Touch density is a\n \
131 capability question: a narrow desktop window still has a pointer, a\n \
132 full-width tablet still has a finger. Window width is the separate\n \
133 question below it: on a compact window the two shells tighten. */\n",
134 );
135 css.push_str(&makeover_geometry::density_css(explicit_touch));
136 css.push('\n');
137 css.push_str(&makeover_geometry::size_class_css());
138 std::fs::write(path, css).expect("write geometry css");
139}
140
141/// Write the house typography layer to `path`: the two `@font-face` rules and
142/// the two tokens they back.
143///
144/// `font_url` is the directory the consumer serves its fonts from, without a
145/// trailing slash — `/static/fonts` on the MNW server, `fonts` for a Tauri
146/// frontend loading relative to its index.
147///
148/// Generated rather than hand-written for the same reason the spacing layer is:
149/// the facts are the crates' and stating them per app is how three apps came to
150/// hold three different answers to `--font-mono`. It is a separate file from
151/// the layout stylesheet because `@font-face` rules take no part in the
152/// cascade and a consumer may need to load them ahead of a layer order it
153/// declares elsewhere.
154///
155/// # The consumer still has to put the faces there
156///
157/// This writes the CSS that fetches `QuasiMono.woff2` and `QuasiBody.woff2`; it
158/// does not write the fonts. It cannot: they are cut by `quasi-type`, which is
159/// `publish = false`, and this crate is on crates.io. A consumer takes
160/// quasi-type as a git dependency in its own `build.rs` and calls
161/// `quasi_type::cut`, the way `shop-font` does, writing each slot's woff2 under
162/// [`makeover::WEBFONT_MONO_FILE`] and [`makeover::WEBFONT_SANS_FILE`].
163///
164/// # Panics
165///
166/// If the file cannot be written.
167pub fn typography_css(path: impl AsRef<Path>, font_url: &str) {
168 let mut css = String::from(
169 "/* Generated by makeover-build from makeover. Do not edit.\n \
170 Two needs, two names, then a system generic. The faces are cut by\n \
171 quasi-type from Atkinson Hyperlegible plus the house glyph set, and\n \
172 both are variable over wght 200-800 in one file, which is why the\n \
173 @font-face rules name the range. The mono face opens at ExtraLight. */\n\n",
174 );
175 css.push_str(&makeover::font_face_css(font_url));
176 css.push_str(&makeover::typography_css_vars());
177 std::fs::write(path, css).expect("write typography css");
178}
179
180/// All the generated files at the layout every Tauri consumer already uses:
181/// `themes/` beside the manifest, and
182/// `frontend/css/{geometry,layout,typography}.css` under it.
183///
184/// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
185/// [`themes`], [`layout_css`] and [`typography_css`] directly.
186///
187/// The font URL is `fonts`, relative to the frontend's index — the one layout
188/// a Tauri app has, since its frontend is served from its own directory.
189///
190/// # Panics
191///
192/// If any file cannot be written.
193pub fn tauri_frontend(
194 manifest_dir: impl AsRef<Path>,
195 opts: &makeover_webview::Emit,
196 explicit_touch: Option<&str>,
197) {
198 let root = manifest_dir.as_ref();
199 let css = root.join("frontend").join("css");
200 themes(root.join("themes"));
201 geometry_css(css.join("geometry.css"), explicit_touch);
202 layout_css(css.join("layout.css"), opts);
203 typography_css(css.join("typography.css"), "../fonts");
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 /// A scratch directory keyed by process id, so a parallel test run does
211 /// not collide. No timestamp: the pid is enough and is deterministic
212 /// within a run.
213 fn scratch(name: &str) -> std::path::PathBuf {
214 let dir =
215 std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
216 let _ = std::fs::remove_dir_all(&dir);
217 std::fs::create_dir_all(&dir).expect("create scratch");
218 dir
219 }
220
221 #[test]
222 fn themes_are_written_one_file_per_id() {
223 let dir = scratch("themes");
224 themes(&dir);
225 let count = std::fs::read_dir(&dir).unwrap().count();
226 assert_eq!(count, makeover::embedded_themes().count());
227 assert!(count > 0, "makeover ships no themes?");
228 }
229
230 #[test]
231 fn a_theme_removed_upstream_does_not_linger() {
232 // The detail that makes this worth sharing rather than retyping.
233 let dir = scratch("stale");
234 std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
235 themes(&dir);
236 assert!(!dir.join("gone-upstream.toml").exists());
237 }
238
239 #[test]
240 fn a_non_theme_file_is_left_alone() {
241 // Only .toml is cleared, so a README or a .gitignore in the bundle
242 // directory survives a rebuild.
243 let dir = scratch("keep");
244 std::fs::write(dir.join("README.md"), "not a theme").unwrap();
245 themes(&dir);
246 assert!(dir.join("README.md").exists());
247 }
248
249 #[test]
250 fn the_stylesheet_lands_and_names_no_colour() {
251 let dir = scratch("css");
252 let path = dir.join("layout.css");
253 layout_css(&path, &makeover_webview::Emit::default());
254 let css = std::fs::read_to_string(&path).unwrap();
255 assert!(css.contains("--bevel-raised"));
256 assert!(
257 !css.contains('#'),
258 "a colour literal reached a build output"
259 );
260 }
261
262 #[test]
263 fn the_typography_file_declares_the_faces_before_the_tokens_that_name_them() {
264 // The vocabulary itself is tested in makeover. What is this crate's
265 // job is that both halves reach one file, in an order that works: a
266 // `@font-face` may follow its use in the cascade, but reading the file
267 // is how anyone finds out a face is fetched at all.
268 let dir = scratch("typography");
269 let path = dir.join("typography.css");
270 typography_css(&path, "/static/fonts");
271 let css = std::fs::read_to_string(&path).unwrap();
272
273 assert!(css.starts_with("/* Generated by makeover-build"));
274 assert!(
275 css.find("@font-face").unwrap() < css.find(":root").unwrap(),
276 "the tokens come first, so the file reads as a stack with no ground"
277 );
278 assert!(css.contains("url(\"/static/fonts/QuasiMono.woff2\")"));
279 assert!(css.contains("--font-sans: \"Quasi Body\", sans-serif;"));
280
281 // Not a cascade layer. `@font-face` takes no part in the cascade and a
282 // consumer may need these rules ahead of a layer order it declares
283 // elsewhere, so wrapping this file in one would be a silent trap.
284 assert!(!css.contains("@layer"));
285 }
286
287 #[test]
288 fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
289 // The policy itself is tested in makeover-geometry. What is this
290 // crate's job is that the banner is there and the policy reached the
291 // file at all.
292 let dir = scratch("geometry");
293 let path = dir.join("geometry.css");
294 geometry_css(&path, Some(".ui-mode-mobile"));
295 let css = std::fs::read_to_string(&path).unwrap();
296 assert!(css.starts_with("/* Generated by makeover-build"));
297 assert!(css.contains("@media (hover: none), (pointer: coarse)"));
298 assert!(css.contains(".ui-mode-mobile"));
299 // The width axis rides along, and only the shells are in it: a gap
300 // between two controls in a width query is the bug size_class_css
301 // exists to keep out.
302 assert!(css.contains("--gap-pane"), "no compact shell override");
303 let compact = css
304 .split("@media (max-width")
305 .nth(1)
306 .expect("compact block");
307 assert!(
308 !compact.contains("--gap-peer"),
309 "a control gap crept into a width query"
310 );
311 }
312
313 #[test]
314 fn the_tauri_layout_puts_all_three_where_the_apps_look() {
315 let root = scratch("tauri");
316 std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
317 tauri_frontend(&root, &makeover_webview::Emit::default(), None);
318 assert!(
319 root.join("frontend")
320 .join("css")
321 .join("geometry.css")
322 .exists()
323 );
324 assert!(
325 root.join("frontend")
326 .join("css")
327 .join("layout.css")
328 .exists()
329 );
330 assert!(root.join("themes").is_dir());
331 }
332}