Skip to main content

max / makeover-build

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