Skip to main content

max / makeover-build

Seed makeover-build: the shared materialiser makeover-geometry's open question 3, closed. GO and BB grew byte-identical copies of the theme materialiser during that adoption, and the phase-A stylesheet would have been the third and fourth copies. This is that code once, and both apps now call it. Deliberately excludes the geometry emitter. GO scopes the touch preset to a ui-mode-mobile class, BB hangs it off @media (hover: none), audiofiles has no switch at all. Extracting it would pick one of those policies by accident inside a shared crate without anyone deciding. Density selection is unowned and has its own task; the geometry half follows it. A helper crate should record agreement, not manufacture it. The stale-theme sweep is the reason this is worth sharing rather than retyping: it is easy to omit and its absence shows up as a theme that will not go away. Tested, along with the fact that a non-.toml file in the bundle directory survives a rebuild. 5 tests, clippy clean at -D warnings. Unpublished.
Author: Max Johnson <me@maxj.phd> · 2026-07-28 21:53 UTC
Signed with PGP, not checked
Commit: f354a5e74c1af32145975b37f207ca7788220ef7
6 files changed, +291 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +43
@@ -1,0 +1,43 @@
1 + [package]
2 + name = "makeover-build"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "Build-script support for the make-family design system: materialise makeover's themes and makeover-webview's stylesheet into a Tauri app's frontend, once, instead of copying the same twenty lines into every consumer's build.rs."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-build"
8 +
9 + [dependencies]
10 + makeover = { path = "../makeover" }
11 + makeover-webview = { path = "../makeover-webview" }
12 +
13 + [lints.rust]
14 + unused = "warn"
15 + unreachable_pub = "warn"
16 +
17 + [lints.clippy]
18 + pedantic = { level = "warn", priority = -1 }
19 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
20 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
21 + # else in `pedantic` stays a warning. Keep this block identical across repos.
22 + module_name_repetitions = "allow"
23 + # Doc lints. No docs-completeness push is underway.
24 + missing_errors_doc = "allow"
25 + missing_panics_doc = "allow"
26 + doc_markdown = "allow"
27 + # Numeric casts. Endemic and mostly intentional in size and byte math.
28 + cast_possible_truncation = "allow"
29 + cast_sign_loss = "allow"
30 + cast_precision_loss = "allow"
31 + cast_possible_wrap = "allow"
32 + cast_lossless = "allow"
33 + # Subjective structure and style nags. High churn, low signal.
34 + must_use_candidate = "allow"
35 + too_many_lines = "allow"
36 + struct_excessive_bools = "allow"
37 + similar_names = "allow"
38 + items_after_statements = "allow"
39 + single_match_else = "allow"
40 + # Frequent false-positives in TUI and router-heavy code.
41 + match_same_arms = "allow"
42 + unnecessary_wraps = "allow"
43 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +54
@@ -1,0 +1,54 @@
1 + # makeover-build
2 +
3 + Build-script support for the make-family design system.
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 Breakfast
7 + grew byte-identical copies of the theme materialiser during the makeover-geometry
8 + adoption, and the layout stylesheet would have been the third and fourth copies.
9 + This is that code, once.
10 +
11 + ```rust
12 + // build.rs
13 + fn main() {
14 + makeover_build::tauri_frontend(env!("CARGO_MANIFEST_DIR"), &makeover_build::Emit::default());
15 + tauri_build::build();
16 + }
17 + ```
18 +
19 + That writes `themes/` beside the manifest and `frontend/css/layout.css` under
20 + it, which is where both Tauri apps already look. Consumers wanting different
21 + paths call `themes` and `layout_css` directly.
22 +
23 + ## What is deliberately not here
24 +
25 + The geometry emitter. GoingsOn scopes the touch preset to a `ui-mode-mobile`
26 + class set by a bootstrap script, Balanced Breakfast hangs it off
27 + `@media (hover: none)`, and audiofiles has no switch at all. Extracting it would
28 + mean picking one of those policies by accident, inside a shared crate, without
29 + anyone deciding. Density selection is unowned and has its own task; the geometry
30 + half lands after it.
31 +
32 + Taking the identical half now and leaving the contested half alone is the point.
33 + A helper crate should record agreement, not manufacture it.
34 +
35 + ## Why these files are generated
36 +
37 + Tauri's resource globs are read by its CLI against the crate directory, so they
38 + cannot point into a registry checkout or `OUT_DIR`. Materialising into the crate
39 + keeps the source crate authoritative without vendoring a second copy that
40 + drifts. Every path written here is expected to be gitignored.
41 +
42 + `themes` clears stale `.toml` files before writing, so a theme removed or
43 + renamed upstream does not linger in the bundle from an earlier build. That is
44 + the detail that makes this worth sharing rather than retyping: it is easy to
45 + omit, and its absence shows up as a theme that will not go away.
46 +
47 + ## Status
48 +
49 + Unpublished, path deps on `makeover` and `makeover-webview`. Consumers are
50 + GoingsOn and Balanced Breakfast.
51 +
52 + ## Licence
53 +
54 + MIT.
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
A src/lib.rs +164
@@ -1,0 +1,164 @@
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 + //! # What is deliberately not here
12 + //!
13 + //! The geometry emitter. GoingsOn and Balanced Breakfast do *not* agree on it:
14 + //! GO scopes the touch preset to a `ui-mode-mobile` class set by a bootstrap
15 + //! script, BB hangs it off `@media (hover: none)`, and audiofiles has no
16 + //! switch at all. Extracting it would mean picking one of those policies by
17 + //! accident, inside a shared crate, without anyone deciding. Density selection
18 + //! is unowned and has its own task; the geometry half lands after it.
19 + //!
20 + //! Taking the identical half now and leaving the contested half alone is the
21 + //! whole point: a helper crate should record agreement, not manufacture it.
22 + //!
23 + //! # Why these files are generated rather than checked in
24 + //!
25 + //! Tauri's resource globs are read by its CLI against the crate directory, so
26 + //! they cannot point into a registry checkout or `OUT_DIR`. Materialising into
27 + //! the crate keeps the source crate authoritative without vendoring a second
28 + //! copy that drifts. Every path written here is expected to be gitignored.
29 +
30 + #![forbid(unsafe_code)]
31 +
32 + use std::path::Path;
33 +
34 + /// Re-exported so a consumer's `build.rs` needs one dependency rather than
35 + /// three. Nothing here wraps it; the emitter's options are the emitter's.
36 + pub use makeover_webview::Emit;
37 +
38 + /// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
39 + ///
40 + /// Clears stale `.toml` files first, so a theme removed or renamed upstream
41 + /// does not linger in the bundle from an earlier build. That detail is the
42 + /// reason this is worth sharing rather than retyping: it is easy to omit and
43 + /// its absence shows up as a theme that will not go away.
44 + ///
45 + /// # Panics
46 + ///
47 + /// If the directory cannot be created, read, or written. A build script has
48 + /// nowhere useful to return an error to, and a half-materialised theme set is
49 + /// worse than a failed build.
50 + pub fn themes(dir: impl AsRef<Path>) {
51 + let dir = dir.as_ref();
52 + std::fs::create_dir_all(dir).expect("create themes dir");
53 +
54 + for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
55 + let path = entry.path();
56 + if path.extension().is_some_and(|e| e == "toml") {
57 + std::fs::remove_file(&path).expect("remove stale theme");
58 + }
59 + }
60 +
61 + for (id, source) in makeover::embedded_themes() {
62 + std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
63 + }
64 + }
65 +
66 + /// Write `makeover-webview`'s component stylesheet to `path`.
67 + ///
68 + /// Baked at build time rather than applied from JS the way the intent layer
69 + /// is, because composition never changes at runtime: no theme may reach it, so
70 + /// there is nothing to re-apply and no second pass over `:root` to pay for on
71 + /// load.
72 + ///
73 + /// # Panics
74 + ///
75 + /// If the file cannot be written.
76 + pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
77 + std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
78 + }
79 +
80 + /// Both of the above at the layout every Tauri consumer already uses:
81 + /// `themes/` beside the manifest, and `frontend/css/layout.css` under it.
82 + ///
83 + /// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
84 + /// [`themes`] and [`layout_css`] directly.
85 + ///
86 + /// # Panics
87 + ///
88 + /// If either file cannot be written.
89 + pub fn tauri_frontend(manifest_dir: impl AsRef<Path>, opts: &makeover_webview::Emit) {
90 + let root = manifest_dir.as_ref();
91 + themes(root.join("themes"));
92 + layout_css(root.join("frontend").join("css").join("layout.css"), opts);
93 + }
94 +
95 + #[cfg(test)]
96 + mod tests {
97 + use super::*;
98 +
99 + /// A scratch directory keyed by process id, so a parallel test run does
100 + /// not collide. No timestamp: the pid is enough and is deterministic
101 + /// within a run.
102 + fn scratch(name: &str) -> std::path::PathBuf {
103 + let dir =
104 + std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
105 + let _ = std::fs::remove_dir_all(&dir);
106 + std::fs::create_dir_all(&dir).expect("create scratch");
107 + dir
108 + }
109 +
110 + #[test]
111 + fn themes_are_written_one_file_per_id() {
112 + let dir = scratch("themes");
113 + themes(&dir);
114 + let count = std::fs::read_dir(&dir).unwrap().count();
115 + assert_eq!(count, makeover::embedded_themes().count());
116 + assert!(count > 0, "makeover ships no themes?");
117 + }
118 +
119 + #[test]
120 + fn a_theme_removed_upstream_does_not_linger() {
121 + // The detail that makes this worth sharing rather than retyping.
122 + let dir = scratch("stale");
123 + std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
124 + themes(&dir);
125 + assert!(!dir.join("gone-upstream.toml").exists());
126 + }
127 +
128 + #[test]
129 + fn a_non_theme_file_is_left_alone() {
130 + // Only .toml is cleared, so a README or a .gitignore in the bundle
131 + // directory survives a rebuild.
132 + let dir = scratch("keep");
133 + std::fs::write(dir.join("README.md"), "not a theme").unwrap();
134 + themes(&dir);
135 + assert!(dir.join("README.md").exists());
136 + }
137 +
138 + #[test]
139 + fn the_stylesheet_lands_and_names_no_colour() {
140 + let dir = scratch("css");
141 + let path = dir.join("layout.css");
142 + layout_css(&path, &makeover_webview::Emit::default());
143 + let css = std::fs::read_to_string(&path).unwrap();
144 + assert!(css.contains("--bevel-raised"));
145 + assert!(
146 + !css.contains('#'),
147 + "a colour literal reached a build output"
148 + );
149 + }
150 +
151 + #[test]
152 + fn the_tauri_layout_puts_both_where_the_apps_look() {
153 + let root = scratch("tauri");
154 + std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
155 + tauri_frontend(&root, &makeover_webview::Emit::default());
156 + assert!(
157 + root.join("frontend")
158 + .join("css")
159 + .join("layout.css")
160 + .exists()
161 + );
162 + assert!(root.join("themes").is_dir());
163 + }
164 + }