//! Build-script support for the make-family design system. //! //! //! //! 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 `.toml`. /// /// Clears stale `.toml` files first, so a theme removed or renamed upstream /// does not linger in the bundle from a previous build. Omitting that step /// 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) { 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, 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. /// /// # Panics /// /// If the file cannot be written. pub fn geometry_css(path: impl AsRef, 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 `makeover-timing`'s time axis, and the motion-off block that rides /// with it, to `path`. /// /// The third generated axis, and it arrives the same way the spacing one does: /// a consumer that calls this gets `--timing-*`, `--motion-fade` and /// `--cadence-activity` without stating a number anywhere. All this adds is the /// banner and the write; `makeover_timing::timing_css` is the whole file and /// already wraps itself in [`makeover_geometry::CSS_LAYER`]. /// /// # Its own file, for the reason geometry has its own file /// /// One generated file per crate, named for the axis it carries. Time is not a /// narrower claim about space the way size class is about density, so folding /// it into `geometry.css` would leave a file whose banner names one crate and /// whose contents come from two. The cost is a fourth `` in the consumer, /// which is the cost the family already pays three times. /// /// # The `prefers-reduced-motion` block is not optional /// /// `makeover_timing::timing_css` emits the `:root` values and then a media /// block overriding two of them. Both land here, in that order, because they /// are one statement: a sheet carrying only the values animates at every rung /// for a reader who asked it not to, and does it silently. /// /// # Panics /// /// If the file cannot be written. pub fn timing_css(path: impl AsRef) { let mut css = String::from( "/* Generated by makeover-build from makeover-timing. Do not edit.\n \ A duration is named by what it is waiting for; the number follows.\n \ Three axes: how long a state lasts, how long a change takes, and how\n \ often a repeating mark repeats. The reduced-motion block below zeroes\n \ the last two and leaves the waits alone. A reader asking for less\n \ motion has not asked for a notice to leave early. */\n", ); css.push_str(&makeover_timing::timing_css()); std::fs::write(path, css).expect("write timing 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, 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, 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,timing,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, 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, 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); // Beside geometry rather than after layout: both are value files the // component sheet reads, and a consumer's `` order follows this one. timing_css(css.join("timing.css")); 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_timing_file_carries_the_values_and_the_block_that_overrides_them() { // The rungs themselves are tested in makeover-timing. What is this // crate's to get wrong is dropping half the file: the values are // useless noise without the media block, and the media block on its // own overrides nothing. let dir = scratch("timing"); let path = dir.join("timing.css"); timing_css(&path); let css = std::fs::read_to_string(&path).unwrap(); assert!(css.starts_with("/* Generated by makeover-build")); // One token per axis, so a crate that grows a fourth axis and is not // emitted here fails somewhere other than on screen. assert!(css.contains("--timing-dismiss"), "no intent tokens"); assert!(css.contains("--motion-fade"), "no motion token"); assert!(css.contains("--cadence-activity"), "no cadence token"); assert!( css.contains("@media (prefers-reduced-motion: reduce)"), "the values shipped without the block that turns them off" ); // The block comes after the values it overrides. Same specificity, // so the order is the whole of the win. assert!( css.find(":root").unwrap() < css.find("prefers-reduced-motion").unwrap(), "the motion-off block cannot override values declared after it" ); // Inside the family's layer, like every other generated sheet: // unlayered declarations outrank every named layer, so a generated // file outside it beats the app's own overrides. assert!(css.contains(makeover_geometry::CSS_LAYER)); } #[test] fn the_tauri_layout_puts_all_four_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); let css = root.join("frontend").join("css"); for file in ["geometry.css", "timing.css", "layout.css", "typography.css"] { assert!(css.join(file).exists(), "{file} was not written"); } assert!(root.join("themes").is_dir()); } }