Skip to main content

max / makeover-build

21.7 KB · 503 lines History Blame Raw
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
44 pub mod drift;
45
46 use std::path::Path;
47
48 pub 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.
55 pub 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.
63 pub use makeover::{WEBFONT_MONO_FILE, WEBFONT_SANS_FILE};
64
65 /// Layer 0 of the font model, re-exported for the same one-dependency reason.
66 ///
67 /// A build script composing an override needs all four names and has no other
68 /// reason to depend on `makeover` directly.
69 pub use makeover::{FontFace, FontOverride, FontSlot, Typography};
70
71 /// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
72 ///
73 /// Clears stale `.toml` files first, so a theme removed or renamed upstream
74 /// does not linger in the bundle from a previous build. Omitting that step
75 /// shows up as a theme that will not go away.
76 ///
77 /// # Panics
78 ///
79 /// If the directory cannot be created, read, or written. A build script has
80 /// nowhere useful to return an error to, and a half-materialised theme set is
81 /// worse than a failed build.
82 pub fn themes(dir: impl AsRef<Path>) {
83 let dir = dir.as_ref();
84 std::fs::create_dir_all(dir).expect("create themes dir");
85
86 for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
87 let path = entry.path();
88 if path.extension().is_some_and(|e| e == "toml") {
89 std::fs::remove_file(&path).expect("remove stale theme");
90 }
91 }
92
93 for (id, source) in makeover::embedded_themes() {
94 std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
95 }
96 }
97
98 /// Write `makeover-webview`'s component stylesheet to `path`.
99 ///
100 /// Baked at build time rather than applied from JS the way the intent layer
101 /// is, because composition never changes at runtime: no theme may reach it, so
102 /// there is nothing to re-apply and no second pass over `:root` to pay for on
103 /// load.
104 ///
105 /// # Panics
106 ///
107 /// If the file cannot be written.
108 pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
109 std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
110 }
111
112 /// Write `makeover-geometry`'s spacing layer, with its canonical density
113 /// selection, to `path`.
114 ///
115 /// The policy is the crate's, not this one's: touch hangs off
116 /// `(hover: none), (pointer: coarse)` because density is a capability rather
117 /// than a device or a width, and `explicit_touch` names a selector an app sets
118 /// when the user has chosen. See [`makeover_geometry::density_css`]. All this
119 /// adds is the generated-file banner and the write.
120 ///
121 /// Both spacing axes land here, in the order the crate defines them.
122 /// [`makeover_geometry::size_class_css`] follows the density block because it
123 /// is the narrower claim: density says what is pointing at the screen, size
124 /// class says how much screen there is, and on a compact window the two shells
125 /// tighten regardless of which density selected them.
126 ///
127 /// # Panics
128 ///
129 /// If the file cannot be written.
130 pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
131 let mut css = String::from(
132 "/* Generated by makeover-build from makeover-geometry. Do not edit.\n \
133 Spacing is named by relationship, not by size. Touch density is a\n \
134 capability question: a narrow desktop window still has a pointer, a\n \
135 full-width tablet still has a finger. Window width is the separate\n \
136 question below it: on a compact window the two shells tighten. */\n",
137 );
138 css.push_str(&makeover_geometry::density_css(explicit_touch));
139 css.push('\n');
140 css.push_str(&makeover_geometry::size_class_css());
141 std::fs::write(path, css).expect("write geometry css");
142 }
143
144 /// Write `makeover-timing`'s time axis, and the motion-off block that rides
145 /// with it, to `path`.
146 ///
147 /// The third generated axis, and it arrives the same way the spacing one does:
148 /// a consumer that calls this gets `--timing-*`, `--motion-fade` and
149 /// `--cadence-activity` without stating a number anywhere. All this adds is the
150 /// banner and the write; `makeover_timing::timing_css` is the whole file and
151 /// already wraps itself in [`makeover_geometry::CSS_LAYER`].
152 ///
153 /// # Its own file, for the reason geometry has its own file
154 ///
155 /// One generated file per crate, named for the axis it carries. Time is not a
156 /// narrower claim about space the way size class is about density, so folding
157 /// it into `geometry.css` would leave a file whose banner names one crate and
158 /// whose contents come from two. The cost is a fourth `<link>` in the consumer,
159 /// which is the cost the family already pays three times.
160 ///
161 /// # The `prefers-reduced-motion` block is not optional
162 ///
163 /// `makeover_timing::timing_css` emits the `:root` values and then a media
164 /// block overriding two of them. Both land here, in that order, because they
165 /// are one statement: a sheet carrying only the values animates at every rung
166 /// for a reader who asked it not to, and does it silently.
167 ///
168 /// # Panics
169 ///
170 /// If the file cannot be written.
171 pub fn timing_css(path: impl AsRef<Path>) {
172 let mut css = String::from(
173 "/* Generated by makeover-build from makeover-timing. Do not edit.\n \
174 A duration is named by what it is waiting for; the number follows.\n \
175 Three axes: how long a state lasts, how long a change takes, and how\n \
176 often a repeating mark repeats. The reduced-motion block below zeroes\n \
177 the last two and leaves the waits alone. A reader asking for less\n \
178 motion has not asked for a notice to leave early. */\n",
179 );
180 css.push_str(&makeover_timing::timing_css());
181 std::fs::write(path, css).expect("write timing css");
182 }
183
184 /// Write the house typography layer to `path`: the two `@font-face` rules and
185 /// the two tokens they back.
186 ///
187 /// `font_url` is the directory the consumer serves its fonts from, without a
188 /// trailing slash — `/static/fonts` on the MNW server, `fonts` for a Tauri
189 /// frontend loading relative to its index.
190 ///
191 /// Generated rather than hand-written for the same reason the spacing layer is:
192 /// the facts are the crates' and stating them per app is how three apps came to
193 /// hold three different answers to `--font-mono`. It is a separate file from
194 /// the layout stylesheet because `@font-face` rules take no part in the
195 /// cascade and a consumer may need to load them ahead of a layer order it
196 /// declares elsewhere.
197 ///
198 /// # The consumer still has to put the faces there
199 ///
200 /// This writes the CSS that fetches `QuasiMono.woff2` and `QuasiBody.woff2`; it
201 /// does not write the fonts. It cannot: they are cut by `quasi-type`, which is
202 /// `publish = false`, and this crate is on crates.io. A consumer takes
203 /// quasi-type as a git dependency in its own `build.rs` and calls
204 /// `quasi_type::cut`, the way `shop-font` does, writing each slot's woff2 under
205 /// [`makeover::WEBFONT_MONO_FILE`] and [`makeover::WEBFONT_SANS_FILE`].
206 ///
207 /// # Panics
208 ///
209 /// If the file cannot be written.
210 pub fn typography_css(path: impl AsRef<Path>, font_url: &str) {
211 typography_css_from(path, &makeover::Typography::house(font_url));
212 }
213
214 /// [`typography_css`], for a product that overrides a slot.
215 ///
216 /// Layer 0 of the font model. A product with a brand face declares it here,
217 /// once, and the generated sheet carries both the `@font-face` and the token —
218 /// which is what replaces the hand-maintained `@font-face` block plus a
219 /// `--font-heading` nothing else in the tree knew about:
220 ///
221 /// ```no_run
222 /// use makeover_build::{FontFace, FontOverride, FontSlot, Typography};
223 ///
224 /// makeover_build::typography_css_from(
225 /// "static/typography.css",
226 /// &Typography::house("/static/fonts").with_override(
227 /// FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
228 /// .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])),
229 /// ),
230 /// );
231 /// ```
232 ///
233 /// The product still ships the face itself, exactly as it does for the house
234 /// two: this writes the CSS that fetches it and cannot produce a font.
235 ///
236 /// # Panics
237 ///
238 /// If the file cannot be written.
239 pub fn typography_css_from(path: impl AsRef<Path>, typography: &makeover::Typography) {
240 let mut css = String::from(
241 "/* Generated by makeover-build from makeover. Do not edit.\n \
242 Two needs, two names, then a system generic. The faces are cut by\n \
243 quasi-type from Atkinson Hyperlegible plus the house glyph set, and\n \
244 both are variable over wght 200-800 in one file, which is why the\n \
245 @font-face rules name the range. The mono face opens at ExtraLight.\n \
246 A third token here is this product's own brand face, declared as an\n \
247 override in its build script. */\n\n",
248 );
249 css.push_str(&typography.css());
250 std::fs::write(path, css).expect("write typography css");
251 }
252
253 /// All the generated files at the layout every Tauri consumer already uses:
254 /// `themes/` beside the manifest, and
255 /// `frontend/css/{geometry,timing,layout,typography}.css` under it.
256 ///
257 /// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
258 /// [`themes`], [`layout_css`] and [`typography_css`] directly.
259 ///
260 /// The font URL is `fonts`, relative to the frontend's index — the one layout
261 /// a Tauri app has, since its frontend is served from its own directory.
262 ///
263 /// # Panics
264 ///
265 /// If any file cannot be written.
266 pub fn tauri_frontend(
267 manifest_dir: impl AsRef<Path>,
268 opts: &makeover_webview::Emit,
269 explicit_touch: Option<&str>,
270 ) {
271 tauri_frontend_with(
272 manifest_dir,
273 opts,
274 explicit_touch,
275 &makeover::Typography::house("../fonts"),
276 );
277 }
278
279 /// [`tauri_frontend`], for a product that overrides a font slot.
280 ///
281 /// Separate rather than a fourth parameter on `tauri_frontend` so the three
282 /// consumers already calling it do not have to move: goingson is held at an
283 /// older `makeover` by a theming decision unrelated to fonts, and a signature
284 /// change here would make a font feature it cannot take into a build break it
285 /// cannot avoid.
286 ///
287 /// The base URL is the caller's: pass `Typography::house("../fonts")` unless
288 /// the app serves fonts from somewhere other than the one layout a Tauri
289 /// frontend has.
290 ///
291 /// # Panics
292 ///
293 /// If any file cannot be written.
294 pub fn tauri_frontend_with(
295 manifest_dir: impl AsRef<Path>,
296 opts: &makeover_webview::Emit,
297 explicit_touch: Option<&str>,
298 typography: &makeover::Typography,
299 ) {
300 let root = manifest_dir.as_ref();
301 let css = root.join("frontend").join("css");
302 themes(root.join("themes"));
303 geometry_css(css.join("geometry.css"), explicit_touch);
304 // Beside geometry rather than after layout: both are value files the
305 // component sheet reads, and a consumer's `<link>` order follows this one.
306 timing_css(css.join("timing.css"));
307 layout_css(css.join("layout.css"), opts);
308 typography_css_from(css.join("typography.css"), typography);
309 }
310
311 #[cfg(test)]
312 mod tests {
313 use super::*;
314
315 /// A scratch directory keyed by process id, so a parallel test run does
316 /// not collide. No timestamp: the pid is enough and is deterministic
317 /// within a run.
318 fn scratch(name: &str) -> std::path::PathBuf {
319 let dir =
320 std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
321 let _ = std::fs::remove_dir_all(&dir);
322 std::fs::create_dir_all(&dir).expect("create scratch");
323 dir
324 }
325
326 #[test]
327 fn themes_are_written_one_file_per_id() {
328 let dir = scratch("themes");
329 themes(&dir);
330 let count = std::fs::read_dir(&dir).unwrap().count();
331 assert_eq!(count, makeover::embedded_themes().count());
332 assert!(count > 0, "makeover ships no themes?");
333 }
334
335 #[test]
336 fn a_theme_removed_upstream_does_not_linger() {
337 // The detail that makes this worth sharing rather than retyping.
338 let dir = scratch("stale");
339 std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
340 themes(&dir);
341 assert!(!dir.join("gone-upstream.toml").exists());
342 }
343
344 #[test]
345 fn a_non_theme_file_is_left_alone() {
346 // Only .toml is cleared, so a README or a .gitignore in the bundle
347 // directory survives a rebuild.
348 let dir = scratch("keep");
349 std::fs::write(dir.join("README.md"), "not a theme").unwrap();
350 themes(&dir);
351 assert!(dir.join("README.md").exists());
352 }
353
354 #[test]
355 fn the_stylesheet_lands_and_names_no_colour() {
356 let dir = scratch("css");
357 let path = dir.join("layout.css");
358 layout_css(&path, &makeover_webview::Emit::default());
359 let css = std::fs::read_to_string(&path).unwrap();
360 assert!(css.contains("--bevel-raised"));
361 assert!(
362 !css.contains('#'),
363 "a colour literal reached a build output"
364 );
365 }
366
367 #[test]
368 fn the_typography_file_declares_the_faces_before_the_tokens_that_name_them() {
369 // The vocabulary itself is tested in makeover. What is this crate's
370 // job is that both halves reach one file, in an order that works: a
371 // `@font-face` may follow its use in the cascade, but reading the file
372 // is how anyone finds out a face is fetched at all.
373 let dir = scratch("typography");
374 let path = dir.join("typography.css");
375 typography_css(&path, "/static/fonts");
376 let css = std::fs::read_to_string(&path).unwrap();
377
378 assert!(css.starts_with("/* Generated by makeover-build"));
379 assert!(
380 css.find("@font-face").unwrap() < css.find(":root").unwrap(),
381 "the tokens come first, so the file reads as a stack with no ground"
382 );
383 assert!(css.contains("url(\"/static/fonts/QuasiMono.woff2\")"));
384 assert!(css.contains("--font-sans: \"Quasi Body\", sans-serif;"));
385
386 // Not a cascade layer. `@font-face` takes no part in the cascade and a
387 // consumer may need these rules ahead of a layer order it declares
388 // elsewhere, so wrapping this file in one would be a silent trap.
389 assert!(!css.contains("@layer"));
390 }
391
392 #[test]
393 fn an_overridden_slot_reaches_the_same_file_as_the_house_two() {
394 // Layer 0's whole point: the brand face stops being a hand-maintained
395 // `@font-face` in the app's own stylesheet and becomes a line in the
396 // generated one, beside the slots it sits next to.
397 let dir = scratch("typography-override");
398 let path = dir.join("typography.css");
399 typography_css_from(
400 &path,
401 &Typography::house("/static/fonts").with_override(
402 FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
403 .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])),
404 ),
405 );
406 let css = std::fs::read_to_string(&path).unwrap();
407
408 // `@font-face {`, not `@font-face`: the header comment names the
409 // at-rule too, and counting that would make this pass for the wrong
410 // reason the day the comment is reworded.
411 assert_eq!(css.matches("@font-face {").count(), 3);
412 assert!(css.contains("--font-display: \"Young Serif\", serif;"));
413 assert!(css.contains("--font-mono: \"Quasi Mono\", monospace;"));
414 assert!(css.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
415 assert!(!css.contains("@layer"));
416 }
417
418 #[test]
419 fn the_default_tauri_layout_is_the_house_layer_and_nothing_else() {
420 // `tauri_frontend` delegating through `tauri_frontend_with` must not
421 // change a byte for the three consumers already calling it.
422 let dir = scratch("tauri-default");
423 let plain = dir.join("plain.css");
424 let house = dir.join("house.css");
425 typography_css(&plain, "../fonts");
426 typography_css_from(&house, &Typography::house("../fonts"));
427 assert_eq!(
428 std::fs::read_to_string(&plain).unwrap(),
429 std::fs::read_to_string(&house).unwrap()
430 );
431 }
432
433 #[test]
434 fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
435 // The policy itself is tested in makeover-geometry. What is this
436 // crate's job is that the banner is there and the policy reached the
437 // file at all.
438 let dir = scratch("geometry");
439 let path = dir.join("geometry.css");
440 geometry_css(&path, Some(".ui-mode-mobile"));
441 let css = std::fs::read_to_string(&path).unwrap();
442 assert!(css.starts_with("/* Generated by makeover-build"));
443 assert!(css.contains("@media (hover: none), (pointer: coarse)"));
444 assert!(css.contains(".ui-mode-mobile"));
445 // The width axis rides along, and only the shells are in it: a gap
446 // between two controls in a width query is the bug size_class_css
447 // exists to keep out.
448 assert!(css.contains("--gap-pane"), "no compact shell override");
449 let compact = css
450 .split("@media (max-width")
451 .nth(1)
452 .expect("compact block");
453 assert!(
454 !compact.contains("--gap-peer"),
455 "a control gap crept into a width query"
456 );
457 }
458
459 #[test]
460 fn the_timing_file_carries_the_values_and_the_block_that_overrides_them() {
461 // The rungs themselves are tested in makeover-timing. What is this
462 // crate's to get wrong is dropping half the file: the values are
463 // useless noise without the media block, and the media block on its
464 // own overrides nothing.
465 let dir = scratch("timing");
466 let path = dir.join("timing.css");
467 timing_css(&path);
468 let css = std::fs::read_to_string(&path).unwrap();
469 assert!(css.starts_with("/* Generated by makeover-build"));
470 // One token per axis, so a crate that grows a fourth axis and is not
471 // emitted here fails somewhere other than on screen.
472 assert!(css.contains("--timing-dismiss"), "no intent tokens");
473 assert!(css.contains("--motion-fade"), "no motion token");
474 assert!(css.contains("--cadence-activity"), "no cadence token");
475 assert!(
476 css.contains("@media (prefers-reduced-motion: reduce)"),
477 "the values shipped without the block that turns them off"
478 );
479 // The block comes after the values it overrides. Same specificity,
480 // so the order is the whole of the win.
481 assert!(
482 css.find(":root").unwrap() < css.find("prefers-reduced-motion").unwrap(),
483 "the motion-off block cannot override values declared after it"
484 );
485 // Inside the family's layer, like every other generated sheet:
486 // unlayered declarations outrank every named layer, so a generated
487 // file outside it beats the app's own overrides.
488 assert!(css.contains(makeover_geometry::CSS_LAYER));
489 }
490
491 #[test]
492 fn the_tauri_layout_puts_all_four_where_the_apps_look() {
493 let root = scratch("tauri");
494 std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
495 tauri_frontend(&root, &makeover_webview::Emit::default(), None);
496 let css = root.join("frontend").join("css");
497 for file in ["geometry.css", "timing.css", "layout.css", "typography.css"] {
498 assert!(css.join(file).exists(), "{file} was not written");
499 }
500 assert!(root.join("themes").is_dir());
501 }
502 }
503