Skip to main content

max / makenotwork

46.6 KB · 957 lines History Blame Raw
1 use std::collections::hash_map::DefaultHasher;
2 use std::fmt::Write as _;
3 use std::hash::{Hash, Hasher};
4 use std::process::Command;
5 use std::{fs, path::Path};
6
7 fn main() {
8 // Set GIT_HASH env var for compile-time inclusion via option_env!()
9 println!("cargo::rustc-env=GIT_HASH={}", git_hash());
10
11 // Compile the TypeScript frontend to static/dist/ (best-effort, see fn).
12 build_frontend();
13
14 // The three generated stylesheets. Spacing comes from makeover-geometry,
15 // time from makeover-timing and composition from makeover-webview, the
16 // same way colour already comes from makeover through theming.rs. Written
17 // into static/ rather than a bundle directory because the server serves
18 // its stylesheets; all three are gitignored, since the crates are the
19 // source and a checked-in copy would drift from the pin.
20 //
21 // No explicit touch selector: density hangs off (hover: none),
22 // (pointer: coarse) alone, because the server has no mode class to key it
23 // on. GO passes `.ui-mode-mobile` because it has one.
24 // The four scripts quasi-webview ships, written where the shell already says
25 // they are served from. Each is behaviour a description cannot state and a
26 // renderer therefore owns: how many rows are ticked and that a control over
27 // none of them should not be pressable (`quasi-selection.js`), a readout
28 // reckoned against the current time that keeps moving (`quasi-clock.js`),
29 // an htmx answer that has to become a saved file rather than page text
30 // (`quasi-download.js`), and a chosen value that lands in the box the
31 // reader is typing in (`quasi-fill.js`).
32 // `Shell::under("/static")` has named these addresses since each script
33 // existed and only two of the files were being written, so every page has
34 // been linking a script that 404s -- which is what the loop is for: a name
35 // added to the renderer and forgotten here is a feature that silently does
36 // nothing.
37 //
38 // Written from the crate's own constants for the reason they are constants:
39 // the script reads hooks the emitter writes, so a copy checked in here goes
40 // stale against the next bump in silence. Gitignored, like the stylesheets.
41 for (name, source) in GENERATED_STATIC_JS {
42 // `write_if_changed`, not `fs::write`, and this is load-bearing rather
43 // than a micro-optimisation. `markup_files` globs `static/*.js`, and
44 // `check_vocabulary_use` emits a `rerun-if-changed` for every file it
45 // reads, so these four are files this script both writes AND watches.
46 // An unconditional write moves the mtime on every run, cargo then sees
47 // a watched file newer than the build script's own output, and the
48 // crate recompiles on every cargo invocation forever. That is what put
49 // a second full 5m25s compile inside every Sando `cargo_test` gate
50 // (task 4200580f); cargo named this exact file as the stale one.
51 write_if_changed(Path::new(&format!("static/{name}")), source);
52 }
53
54 makeover_build::geometry_css("static/geometry.css", None);
55 // The time axis, beside the spacing one and for the same reason: the
56 // durations are makeover-timing's answer, and a number typed into a
57 // hand-written sheet here is the drift that crate exists to end.
58 makeover_build::timing_css("static/timing.css");
59 makeover_build::layout_css("static/layout.css", &makeover_build::Emit::default());
60
61 // The embeds get their own copy of the spacing layer, because they are
62 // served into third-party iframes and cannot link a stylesheet. Pointer
63 // density only, and no `@media` block: an embed body is `height: 100vh`
64 // inside an iframe the host page sized, so growing the gaps on a coarse
65 // pointer clips rather than reflows, and the host author never sees it
66 // happen. Revisit if embeds ever gain a resize protocol.
67 fs::write(
68 "static/embed-geometry.css",
69 makeover_geometry::geometry_css_vars(makeover_geometry::Density::Pointer),
70 )
71 .expect("write static/embed-geometry.css");
72
73 // The typography layer, and the faces it fetches.
74 //
75 // Two halves that have to agree: makeover_build writes the `@font-face`
76 // rules and the tokens, and `cut_house_faces` puts the woff2 files where
77 // those rules point. Both name the file through
78 // `makeover::WEBFONT_*_FILE`, so the agreement is a constant rather than a
79 // string typed twice.
80 //
81 // Young Serif is the third token, and it is here rather than in style.css
82 // because that is what layer 0 is for. It used to be a hand-written
83 // `@font-face` plus a `--font-heading` nothing outside this repository had
84 // heard of; declared as an override it is the same shape as the two house
85 // slots, and `--font-display` is the name every product uses for its brand
86 // tier. The face itself is committed rather than cut: it is upstream's,
87 // unmodified, and quasi-type has no part in it.
88 makeover_build::typography_css_from(
89 "static/typography.css",
90 &makeover_build::Typography::house("/static/fonts").with_override(
91 makeover_build::FontOverride::new(
92 makeover_build::FontSlot::Display,
93 "\"Young Serif\", serif",
94 )
95 .with_face(makeover_build::FontFace::new(
96 "Young Serif",
97 ["ysrf.woff2", "ysrf.ttf"],
98 )),
99 ),
100 );
101
102 // The embeds get the house layer WITHOUT the override, for the same reason
103 // they get their own spacing layer: different context, different answer.
104 // An embed carries no brand face by decision — `--font-display` is left
105 // undefined so the `var(--font-display, Georgia, serif)` fallback in each
106 // template renders — and the alternative is 21 KB of a serif face on
107 // somebody else's page to style one card title. The two files are
108 // generated from one `Typography` vocabulary, so they cannot disagree
109 // about the house slots the way two hand-written sheets did.
110 makeover_build::typography_css_from(
111 "static/embed-typography.css",
112 &makeover_build::Typography::house("/static/fonts"),
113 );
114
115 cut_house_faces();
116
117 println!("cargo::rerun-if-changed=build.rs");
118
119 // The landing shots' own dimensions, so the description can reserve their
120 // space. Generated rather than written down; see `shot_dimensions`.
121 let shots = shot_dimensions();
122 let mut table = String::from(
123 "/// Intrinsic size of each landing screenshot, by URL path.\n\
124 /// Generated by build.rs from the files themselves.\n\
125 pub static SHOT_DIMENSIONS: &[(&str, u32, u32)] = &[\n",
126 );
127 for (path, w, h) in &shots {
128 let _ = writeln!(table, " ({path:?}, {w}, {h}),");
129 }
130 table.push_str("];\n");
131 fs::write(
132 Path::new(&std::env::var("OUT_DIR").expect("OUT_DIR")).join("shot_dimensions.rs"),
133 table,
134 )
135 .expect("write shot_dimensions.rs");
136
137 // The same check goingson and balanced_breakfast run, out of
138 // makeover-build rather than a third copy of it. The named-list form:
139 // static/ holds the generated stylesheets and a bundler's output beside
140 // the hand-written ones, so there is no directory to scan. No tuning
141 // widths -- every threshold here is a shell boundary.
142 makeover_build::check_breakpoints_files(&HAND_WRITTEN_CSS, &[]);
143 makeover_build::check_vocabulary_files(
144 &HAND_WRITTEN_CSS,
145 &makeover_build::Emit::default(),
146 REVIEWED_OVERLAPS,
147 REVIEWED_ELEMENT_OVERLAPS,
148 );
149 makeover_build::check_vocabulary_use(
150 &markup_files(),
151 &makeover_build::Emit::default(),
152 DEAD_VOCABULARY_HIGH_WATER,
153 );
154
155 // --- Static asset fingerprinting ---
156 // Hash the content of key static files to produce a version suffix.
157 // When any watched file changes, URLs in templates get a new ?v= param,
158 // busting browser caches automatically.
159 let static_files = [
160 "static/style.css",
161 // The two per-page sheets. Linked from page templates rather than
162 // from the head, which is why they were outside the fingerprint and
163 // served stale to any browser holding a cached copy.
164 "static/wizard.css",
165 "static/media-player.css",
166 // Same case again, found 2026-08-15: linked from a <noscript> in the
167 // carousel partial rather than from the head, so it was outside the
168 // fingerprint and a change to it served stale to any browser holding a
169 // cached copy. The visitors it exists for are the ones least likely to
170 // hard-refresh.
171 "static/no-js.css",
172 "static/htmx.min.js",
173 "static/upload.js",
174 "static/passkey.js",
175 // `static/insertions.js` was here until 2026-08-20. The file went away
176 // in bd448cbe and the entry did not, so it was a watch on a path that
177 // could not exist -- which cargo reads as changed, re-running this
178 // script and recompiling the crate on every invocation. Same bug as the
179 // old `.git/HEAD` watch; see `git_hash`. Anything added here must exist.
180 ];
181
182 let mut hasher = DefaultHasher::new();
183 for path in &static_files {
184 // Every path in the list above is expected to exist. Assert it rather
185 // than watching a phantom: a deleted file that keeps its entry costs a
186 // full recompile per cargo invocation and is invisible otherwise.
187 assert!(
188 Path::new(path).exists(),
189 "build.rs watches {path}, which does not exist. Remove the entry, or \
190 restore the file: a missing watch path recompiles this crate on \
191 every cargo invocation.",
192 );
193 println!("cargo::rerun-if-changed={path}");
194 if let Ok(content) = fs::read(path) {
195 content.hash(&mut hasher);
196 }
197 }
198 // Fold the emitted frontend bundles into the version so `?v=` busts when
199 // the TypeScript changes. Read-only: the inputs under frontend/src are the
200 // watched trigger (in build_frontend); watching the outputs would loop.
201 hash_dir_js(Path::new("static/dist"), &mut hasher);
202 // Same treatment for the generated stylesheets: read-only, so a bump of
203 // makeover-geometry, makeover-timing or makeover-webview busts `?v=`
204 // without the build script watching a file it writes itself.
205 for path in [
206 "static/geometry.css",
207 "static/timing.css",
208 "static/layout.css",
209 "static/embed-geometry.css",
210 // The tokens and the @font-face rules. Note what this does NOT cover:
211 // the woff2 files themselves are served under fixed names with no `?v=`,
212 // so a re-cut of the same slot ships new bytes at an old URL and a
213 // browser holding a cached copy keeps it. That was equally true of the
214 // Plex and Lato files this replaced, and a face changes about as often
215 // as the glyph set version does, so it is a known edge rather than a
216 // regression. The fix, if it ever bites, is a version in the URL
217 // makeover is handed.
218 "static/typography.css",
219 ] {
220 if let Ok(content) = fs::read(path) {
221 content.hash(&mut hasher);
222 }
223 }
224 let static_hash = format!("{:016x}", hasher.finish());
225 let version = &static_hash[..8];
226
227 // The head's own assets are no longer a generated partial: crate::shell
228 // builds them into quasi-webview's Shell, which is what a described screen
229 // renders through, so both halves of the converted site emit one head. All
230 // that crosses the build boundary now is the version.
231 println!("cargo::rustc-env=STATIC_VERSION={version}");
232
233 // Per-page island loader macro. Heavy/page-specific islands (media player,
234 // uploader, ...) load on the pages that use them via
235 // `{% import "_island.html" as island %}{% call island::island("name") %}`,
236 // cache-busted by the same content hash as the head assets.
237 let island_partial = r#"{% macro island(name) -%}
238 <script type="module" src="/static/dist-__VER__/islands/{{ name }}.js"></script>
239 {%- endmacro %}
240 "#
241 .replace("__VER__", version);
242 write_if_changed(Path::new("templates/_island.html"), &island_partial);
243
244 // Per-page stylesheet loader, same idea as the island macro. wizard.css and
245 // media-player.css are linked by the pages that need them rather than by
246 // the shell, so this is how they get the content hash.
247 let sheet_partial = r#"{% macro sheet(name) -%}
248 <link rel="stylesheet" href="/static/{{ name }}?v=__VER__">
249 {%- endmacro %}
250 "#
251 .replace("__VER__", version);
252 write_if_changed(Path::new("templates/_sheet.html"), &sheet_partial);
253 }
254
255 /// Cut Quasi Mono and Quasi Body into `static/fonts/`, as woff2.
256 ///
257 /// Cut rather than committed, which is the same rule `shop-font` and the Alloy
258 /// image follow: a face in the repository is a second source of truth that
259 /// nothing rebuilds, so the glyph set and the shipped face drift and nobody
260 /// finds out until a mark looks wrong. The pipeline is the source; these are
261 /// its output, and they are gitignored.
262 ///
263 /// The base is downloaded into `OUT_DIR` once and checksummed against
264 /// quasi-type's pins, so a warm target directory needs no network. `offline` is
265 /// false because the first build on a fresh machine has to be able to fetch,
266 /// and a failure here is fatal rather than skipped: a missing face is a site
267 /// rendering in the fallback with nothing to say so.
268 fn cut_house_faces() {
269 let cache = Path::new(&std::env::var("OUT_DIR").expect("cargo sets OUT_DIR")).join("bases");
270 quasi_type::cut_web(
271 Path::new("static/fonts"),
272 &cache,
273 false,
274 &[
275 ("quasi-mono", makeover_build::WEBFONT_MONO_FILE),
276 ("quasi-body", makeover_build::WEBFONT_SANS_FILE),
277 ],
278 )
279 .expect("cut the house faces");
280 }
281
282 /// Intrinsic dimensions of the landing screenshots, read from the files.
283 ///
284 /// A picture that cannot say how big it is cannot have its space reserved, so
285 /// the browser gives it none until the bytes land and then takes its full
286 /// height at once. That was measured at a 478px jump per frame on the landing
287 /// page and 0.087 CLS for the document (2026-08-14).
288 ///
289 /// Read here rather than written down, because these files are regenerated by
290 /// `scripts/capture-landing-carousel.mjs` and a hand-maintained number would be
291 /// wrong the first time anyone re-shot them -- silently, since a wrong reserve
292 /// looks like a right one until the image lands.
293 ///
294 /// WebP only, which is what the capture script emits. A file this cannot parse
295 /// is skipped rather than guessed at: `None` reserves nothing, which is the
296 /// behaviour before this existed, while a wrong number reserves the wrong room.
297 fn shot_dimensions() -> Vec<(String, u32, u32)> {
298 let dir = Path::new("static/images/shots");
299 println!("cargo::rerun-if-changed=static/images/shots");
300 let mut out = Vec::new();
301 let Ok(entries) = fs::read_dir(dir) else {
302 return out;
303 };
304 for entry in entries.flatten() {
305 let path = entry.path();
306 if path.extension().and_then(|e| e.to_str()) != Some("webp") {
307 continue;
308 }
309 let Ok(bytes) = fs::read(&path) else { continue };
310 let Some((w, h)) = webp_dimensions(&bytes) else {
311 continue;
312 };
313 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
314 out.push((format!("/static/images/shots/{name}"), w, h));
315 }
316 }
317 out.sort();
318 out
319 }
320
321 /// Canvas size from a WebP header, for the three chunk layouts that exist.
322 ///
323 /// Header offsets only -- nothing is decoded. `None` for anything unrecognised,
324 /// which the caller treats as "this picture does not know its size".
325 fn webp_dimensions(b: &[u8]) -> Option<(u32, u32)> {
326 if b.len() < 30 || &b[0..4] != b"RIFF" || &b[8..12] != b"WEBP" {
327 return None;
328 }
329 match &b[12..16] {
330 // Lossy. Three-byte frame tag, then the 3-byte sync code, then two
331 // 14-bit dimensions.
332 b"VP8 " => {
333 let w = u16::from_le_bytes([b[26], b[27]]) & 0x3fff;
334 let h = u16::from_le_bytes([b[28], b[29]]) & 0x3fff;
335 Some((u32::from(w), u32::from(h)))
336 }
337 // Lossless. One signature byte, then 14 bits of width-1 and 14 of
338 // height-1 packed into the next four.
339 b"VP8L" => {
340 let bits = u32::from_le_bytes([b[21], b[22], b[23], b[24]]);
341 Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1))
342 }
343 // Extended. Canvas size as two 24-bit little-endian minus-ones.
344 b"VP8X" => {
345 let w = u32::from_le_bytes([b[24], b[25], b[26], 0]) + 1;
346 let h = u32::from_le_bytes([b[27], b[28], b[29], 0]) + 1;
347 Some((w, h))
348 }
349 _ => None,
350 }
351 }
352
353 /// How many generated classes may go unused before the build fails.
354 ///
355 /// One-sided: over this fails, under it warns and asks for the seal to be
356 /// lowered. A build that broke on deleting dead CSS would teach the wrong
357 /// lesson, so the number only ever ratchets down.
358 ///
359 /// Measured against [`markup_files`]. The stylesheets are deliberately out -- a
360 /// class in `style.css` is that class being styled, not that class being
361 /// emitted, and counting them would mark the whole vocabulary used by
362 /// definition. `static/dist` is out for a subtler reason: it is `tsc` output of
363 /// `frontend/src`, so including both counts one class-writing line twice, and
364 /// the source is the half a human edits.
365 ///
366 /// The server's markup is spread wider than either desktop app's -- 200-odd
367 /// Askama templates, the hand-written scripts in `static/`, the TypeScript they
368 /// are being replaced by, and the Rust that writes markup directly -- which is
369 /// the whole reason this seal took a pass of its own rather than landing beside
370 /// the check that reads the stylesheets.
371 ///
372 /// # 18 to 20, 2026-08-14: `picture-img` and `picture-caption`
373 ///
374 /// The one direction this number is allowed to move is down, so a rise wants an
375 /// argument rather than a nudge. These two are not dead vocabulary: they are
376 /// emitted by `quasi-webview` at request time, from a description, and this
377 /// scanner reads *this repo's* files. Markup written by a dependency is
378 /// invisible to it by construction.
379 ///
380 /// That category already existed and was already counted here. `figure-value`,
381 /// `figure-caption` and `figure-change` sit in the same set for the same
382 /// reason, and the nine `cell-*` classes beside them are the described table.
383 /// The carousel port (`c0b63ea9`) is the first widget to add to it.
384 ///
385 /// So the seal still measures what it was built to measure -- CSS generated for
386 /// markup nobody writes -- and the honest ratchet is downward as the
387 /// description layer takes over more of the site, at which point these classes
388 /// stop being reachable from templates *and* stay used. If this number ever
389 /// needs raising for a class MNW's own markup should have been writing, that is
390 /// the defect this exists to catch and the answer is the markup, not the seal.
391 ///
392 /// # 20 to 23, 2026-08-15: `track-entry`, `track-slot`, `track-tick`
393 ///
394 /// The same category again, and the third entry in it. The track vocabulary
395 /// arrived in quasi 0.6.0 (`describe a time axis`) and 0.8.0 (`label a track by
396 /// its unit`); this server had been pinned to quasi 0.5 and could not resolve at
397 /// all, so the classes had never been weighed here. Forward-fixing the pin is
398 /// what surfaced them.
399 ///
400 /// Emitted by `quasi-webview` from a description, like the eighteen above them,
401 /// and invisible to a scanner reading this repo's files for the same reason. No
402 /// template should be writing them.
403 ///
404 /// # 23 to 24, 2026-08-17: `row-relaxed`
405 ///
406 /// The same category a fourth time. `makeover-layout` 0.28.1 named `Flow` and
407 /// `makeover-webview` 0.48.0 emits the clamp, so a described row can say a part
408 /// may take two lines. `quasi-webview` writes the class when a description asks
409 /// for it, so no template here should be writing it and a scanner over this
410 /// repo cannot see it either way.
411 ///
412 /// # 24 to 28, 2026-08-18: the four `run-*` classes
413 ///
414 /// The same category a fifth time. `makeover-layout` 0.29.0 named `Fallback`,
415 /// `makeover-webview` 0.49.0 emits a rule per member, and quasi 0.28.0 lets a
416 /// region say its leading row is shared -- goingson's tab strip and the toolbar
417 /// beside it, which is the case the ruling was made on.
418 ///
419 /// `quasi-webview` writes the wrapper and the fallback class from a description,
420 /// so no template here should be writing either and a scanner over this repo
421 /// cannot see them.
422 ///
423 /// The denominator went 55 to 60 and only four of the five land here. `.run`
424 /// reads as used, and it is not: the scan is a word match over markup files and
425 /// "Re-run pipeline" in `admin_upload_entries.html` is enough to satisfy it.
426 /// Worth knowing rather than fixing -- this count is documented loose in the
427 /// safe direction already, and a scanner that could tell a class from a word
428 /// would need to parse every template.
429 ///
430 /// # 28 to 33, 2026-08-18: five of the seven `facet-*` classes
431 ///
432 /// The same category a sixth time, and the first rise whose subject this repo
433 /// is the reason for. `makeover-layout` 0.30.0 named `Facet` and
434 /// `makeover-webview` 0.50.0 draws one, both measured against `discover`: it
435 /// filters six ways through six mechanisms, and every filter row in
436 /// `discover_sidebar.html` carries a tick box *and* a chevron only because a
437 /// tag's selection and its browse position are separate state.
438 ///
439 /// So these are not classes a feed reader has no call for, the way `.cell-*`
440 /// is. They are the classes the discover sidebar *will* write, and it does not
441 /// write them yet because it has not been described yet. That port is its own
442 /// work and the seal tightens with it.
443 ///
444 /// The denominator went 60 to 67 and only five of the seven land here. `.facet`
445 /// and `.facet-count` read as used and are not: the scan is a word match, and
446 /// `data-facet="tag"` in the sidebar plus the word "facet" in its own comment
447 /// are enough to satisfy them. The `.run` case above, again, and documented
448 /// rather than fixed for the same reason.
449 ///
450 /// It tightens when the discover sidebar ports, not before.
451 ///
452 /// 33 to 32 on 2026-08-18: the library tab strip is described (`6b24f2df`), and
453 /// the run it declares its overflow on is the first `.run-menu` this server
454 /// emits. The `.run` case documented above stopped being hypothetical, which is
455 /// the ratchet working rather than an exception to it.
456 /// 32 to 34 on the makeover-build 0.46 to 0.47 bump, which carries
457 /// makeover-webview 0.52.0 and the chrome a markdown field gets:
458 /// `.form-editor-modes` and `.form-editor-preview` around a
459 /// `FieldKind::Rich` control. The denominator went 67 to 69 and both land here.
460 ///
461 /// The facet case above, exactly: these are classes this repo is the reason
462 /// for and does not write yet. `partial-item-text-editor.html` has the
463 /// Write/Preview pair hand-written and `partial-item-text-editor.js` renders
464 /// the preview, and the renderer emitting the same shape is what those four
465 /// section editors convert onto. It tightens when they port, not before.
466 /// 34 to 37 on the quasi 0.45.0 / makeover-webview 0.53.0 bump: a field's
467 /// suggestion list. `Field::suggests` (`71852b16`) gave a field the list of
468 /// candidates it owns, and the three classes it is drawn with are
469 /// `.form-suggestions`, `.form-suggestion` and `.form-suggestion-why`. The
470 /// denominator went 69 to 72 and all three land here.
471 ///
472 /// The facet case a third time, and this repo is the reason for these too:
473 /// discover's search box and its tag typeahead are the two measured sites the
474 /// member was designed against, and both are still bare inputs driven from
475 /// `page-discover.js`. It tightens when they port, which is B7, not before.
476 /// 37 to 36, 2026-08-21: the five embeds are described (`54d7f8cf`), and their
477 /// markup is the renderer's now rather than five hand-written `<style>` blocks.
478 /// One class the templates had no word for is written by the row they became.
479 /// Measured rather than predicted; the check asked for it on the build that
480 /// converted them.
481 /// 36 to 34, 2026-08-21: the tag typeahead ported (N8, `1503db12`), which is
482 /// the tightening the suggestion-list paragraph above said to wait for. Two of
483 /// its three classes are written now — `.form-suggestions` by the list the
484 /// field owns and `.form-suggestion` by each candidate. `.form-suggestion-why`
485 /// is not among them: it came out of makeover-webview 0.57.0 when a candidate
486 /// grew a second line (`1fcf2e9b`), so it is gone rather than dead. The search
487 /// box is still hand-written and is the site that would tighten this again.
488 /// 34 to 44, 2026-08-22: makeover-webview 0.59.0 wrote down the unruled half
489 /// of its own vocabulary, so the denominator went 73 to 88 and every one of
490 /// the fifteen new names arrives dead here by construction. They are classes
491 /// that crate's emitters write -- a cell's width and drop, a field's group,
492 /// label, hint and error -- and this server spells none of them itself,
493 /// because the markup that carries them is Rust in a dependency rather than a
494 /// template in this repo. Nothing about this server changed; what changed is
495 /// that the check can now see the half of the vocabulary it was blind to.
496 /// It tightens as screens convert, exactly as the lines above did.
497 /// 44 to 45, 2026-08-26, and this one LOOSENS -- the first line here that does.
498 /// `64b33b26` deleted the seven Askama renderings the description replaced, and
499 /// `.form-label` lost its last two spellings with them (both in
500 /// `partials/tabs/user_ssh_keys_tab.html`). The class is not gone and is not
501 /// unused: makeover-webview's field emitter still writes it, on the described
502 /// screen that replaced that template. It is dead only in the sense this check
503 /// can measure, which is "no markup in this repo spells it", and the markup
504 /// that spells it is now Rust in a dependency.
505 ///
506 /// 45 to 46, 2026-08-27: makeover-layout 0.36.0 added `Field::note` and
507 /// makeover-webview 0.62.0 draws it, so `.form-note` arrives dead here by
508 /// construction -- the same way `.form-hint` and `.form-error` did on the
509 /// 0.59.0 bump above. It is markup in a dependency, and this server spells no
510 /// field messages of its own. It stops being dead the moment a described
511 /// screen here carries a note.
512 ///
513 /// So a rising number here is not automatically a regression. Read it against
514 /// what moved: markup deleted in favour of a description will raise it, and a
515 /// class that genuinely lost its writer will too. The list the failure prints
516 /// is what tells them apart -- if a name on it is one this server still means
517 /// to render, that is the bug.
518 const DEAD_VOCABULARY_HIGH_WATER: usize = 46;
519
520 /// The scripts this build script generates into `static/`.
521 ///
522 /// One list, two consumers, and that is the whole point of it being a const.
523 /// The write loop iterates it, and [`markup_files`] excludes it. Those two have
524 /// to agree or the build recompiles on every cargo invocation forever.
525 ///
526 /// # Why a generated file may never reach `markup_files`
527 ///
528 /// `markup_files` globs `static/*.js`, and `makeover_build::check_vocabulary_use`
529 /// emits a `cargo::rerun-if-changed` for every file it reads. So a generated file
530 /// under `static/` is a file this script both **writes and watches**: the write
531 /// moves its mtime, cargo then sees a watched file newer than the build script's
532 /// own output, and the fingerprint can never settle. Nothing fails, nothing warns;
533 /// the crate just recompiles every time, which cost a second full 5m25s compile
534 /// inside every Sando `cargo_test` gate for five days.
535 ///
536 /// That has now happened twice through two different doors — `bd448cbe` left a
537 /// deleted file in the watched list, and `a52ec579` added generated ones to a
538 /// globbed directory the day after the first was fixed. `write_if_changed` fixes
539 /// an instance. This list plus the assertion in `markup_files` closes the door.
540 ///
541 /// **Adding a generated file under `static/`? Add it here too.**
542 ///
543 /// **Three were missing until 2026-08-26**, and the failure was exactly the one
544 /// goingson's copy of this list warns about: the shell links every script the
545 /// renderer ships unless the host says otherwise, and this host says otherwise
546 /// about none of them. `quasi-reveal.js` and `quasi-repeat.js` had been linked
547 /// and unserved -- a 404 per document, with nothing in the log -- and
548 /// `quasi-awaiting.js` would have joined them the moment it shipped. Found
549 /// adding the third, which is the only reason the other two were.
550 const GENERATED_STATIC_JS: [(&str, &str); 8] = [
551 ("quasi-selection.js", quasi_webview::SELECTION_JS),
552 ("quasi-clock.js", quasi_webview::CLOCK_JS),
553 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
554 ("quasi-fill.js", quasi_webview::FILL_JS),
555 ("quasi-reveal.js", quasi_webview::REVEAL_JS),
556 ("quasi-copy.js", quasi_webview::COPY_JS),
557 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
558 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
559 ];
560
561 /// Every file that can carry a class name.
562 ///
563 /// Sorted within each group, so two machines read the same set in the same
564 /// order. It makes no difference to the count and every difference to reading a
565 /// diff of the warning.
566 ///
567 /// Generated files are excluded: see [`GENERATED_STATIC_JS`] for why watching one
568 /// is a build that never settles. These four are also quasi-webview's emitted
569 /// output rather than markup this repo writes, so measuring them against this
570 /// server's own vocabulary seal was answering a question about another crate.
571 fn markup_files() -> Vec<std::path::PathBuf> {
572 let mut files = Vec::new();
573 for (dir, extension) in [
574 ("templates", "html"),
575 ("static", "js"),
576 ("frontend/src", "ts"),
577 ("src", "rs"),
578 ] {
579 let mut found = Vec::new();
580 collect(Path::new(dir), extension, &mut found);
581 found.sort();
582 files.extend(found);
583 }
584
585 // The door, closed. Excluding by file name rather than by full path because
586 // `collect` walks recursively and the generated ones sit at the root of
587 // `static/`; a name collision deeper in the tree would be a second file
588 // called `quasi-clock.js`, which is its own problem.
589 let before = files.len();
590 files.retain(|path| {
591 !path
592 .file_name()
593 .and_then(|name| name.to_str())
594 .is_some_and(|name| GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name))
595 });
596
597 // Catches a listed script that `markup_files` cannot see: an entry given a
598 // name `collect` does not glob, such as a `.mjs` or one written somewhere
599 // other than `static/`. It would be excluded from nothing while still being
600 // written and watched.
601 //
602 // It deliberately does NOT catch a deleted or renamed file, and the comment
603 // here used to claim it did. It cannot: the write loop iterates this same
604 // const and regenerates every entry before this function runs, so a missing
605 // one is recreated rather than absent. Verified by deleting
606 // `static/quasi-fill.js` and watching the build stay green. The `.gitignore`
607 // cross-check below is the half that does the real work.
608 let excluded = before - files.len();
609 assert_eq!(
610 excluded,
611 GENERATED_STATIC_JS.len(),
612 "markup_files excluded {excluded} generated scripts but GENERATED_STATIC_JS \
613 names {}. A generated file under static/ that reaches markup_files is one \
614 this build script both writes and watches, which recompiles the crate on \
615 every cargo invocation. Update GENERATED_STATIC_JS to match what is written.",
616 GENERATED_STATIC_JS.len(),
617 );
618
619 // The other half of the guard, and the one that catches the likelier
620 // mistake. The assertion above catches a listed script that stopped
621 // existing; this catches a NEW generated script nobody added to the list.
622 //
623 // `.gitignore` is the cross-check because it is the other place a generated
624 // file has to be named: it is not committed, so it either gets an entry or
625 // it shows up as untracked in every `git status` until someone adds one.
626 // Read rather than asked of `git`, so this costs no subprocess and still
627 // works where there is no git at all -- and where the file is unreadable it
628 // fails open, which is the right direction for a guard rather than a gate.
629 let ignored = fs::read_to_string("../.gitignore").unwrap_or_default();
630 for name in ignored
631 .lines()
632 .map(str::trim)
633 .filter_map(|line| line.strip_prefix("server/static/"))
634 .filter(|name| {
635 Path::new(name)
636 .extension()
637 .is_some_and(|ext| ext.eq_ignore_ascii_case("js"))
638 })
639 {
640 assert!(
641 GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name),
642 "`.gitignore` names generated script static/{name}, which \
643 GENERATED_STATIC_JS does not. markup_files therefore watches a file \
644 this build script writes, and the crate will recompile on every cargo \
645 invocation with no other symptom. Add it to GENERATED_STATIC_JS.",
646 );
647 }
648
649 files
650 }
651
652 /// Every file under `dir` with this extension, recursively.
653 fn collect(dir: &Path, extension: &str, out: &mut Vec<std::path::PathBuf>) {
654 let Ok(entries) = fs::read_dir(dir) else {
655 return;
656 };
657 for entry in entries.flatten() {
658 let path = entry.path();
659 if path.is_dir() {
660 // The bundler's output, which is `frontend/src` compiled. Reading
661 // both would count the same line twice.
662 if path.file_name().is_some_and(|name| name == "dist") {
663 continue;
664 }
665 collect(&path, extension, out);
666 } else if path.extension().is_some_and(|ext| ext == extension) {
667 out.push(path);
668 }
669 }
670 }
671
672 /// The hand-written stylesheets. Ordered, so the guard reports the same way
673 /// twice. `geometry.css` and `layout.css` are excluded: they are generated.
674 ///
675 /// `no-js.css` joined the list on 2026-08-15. It had been missed since it was
676 /// written: it is hand-authored CSS served to real visitors, so the breakpoint
677 /// and vocabulary guards apply to it exactly as they do to the other three, and
678 /// a sheet outside the list is a sheet that can diverge without the build
679 /// noticing. It is also what `tests/frontend_payload.rs` weighs, and a seal
680 /// that skipped a sheet would let bytes move between sheets and read as a
681 /// deletion.
682 const HAND_WRITTEN_CSS: [&str; 4] = [
683 "static/style.css",
684 "static/wizard.css",
685 "static/media-player.css",
686 "static/no-js.css",
687 ];
688
689 /// Write `contents` to `path` only if it differs, to avoid needless rebuilds.
690 fn write_if_changed(path: &Path, contents: &str) {
691 let needs_write = fs::read_to_string(path).map_or(true, |existing| existing != contents);
692 if needs_write {
693 fs::write(path, contents)
694 .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
695 }
696 }
697
698 /// Compile the TypeScript frontend (`frontend/`) to browser ESM in
699 /// `static/dist/` via `npm run build` (which runs `tsc`).
700 ///
701 /// Self-contained: on a fresh checkout or a new build host (no `node_modules`)
702 /// it runs `npm ci` first, so there is no manual install gate before a deploy.
703 /// Best-effort and non-fatal otherwise, an absent Node or a compile error only
704 /// emits a `cargo::warning` and leaves the Rust build to succeed against
705 /// whatever `static/dist/` already holds. Set `MNW_SKIP_FRONTEND_BUILD=1` to
706 /// opt out entirely (e.g. a Node-less CI that doesn't need the JS).
707 fn build_frontend() {
708 // Re-run the whole build script when the TS sources or its config change.
709 println!("cargo::rerun-if-changed=frontend/src");
710 println!("cargo::rerun-if-changed=frontend/package.json");
711 println!("cargo::rerun-if-changed=frontend/package-lock.json");
712 println!("cargo::rerun-if-changed=frontend/tsconfig.json");
713
714 if std::env::var_os("MNW_SKIP_FRONTEND_BUILD").is_some() {
715 println!("cargo::warning=frontend build skipped (MNW_SKIP_FRONTEND_BUILD set)");
716 return;
717 }
718 // Fresh checkout / new build host: install deps once (clean, from the
719 // lockfile) so the frontend build needs no manual `npm install` gate before
720 // a deploy. Skipped once node_modules exists; needs network on this run.
721 if !Path::new("frontend/node_modules").is_dir() {
722 match Command::new("npm")
723 .args(["ci"])
724 .current_dir("frontend")
725 .status()
726 {
727 Ok(s) if s.success() => {}
728 Ok(s) => {
729 println!(
730 "cargo::warning=npm ci failed (exit {:?}); skipping frontend build (serving existing static/dist)",
731 s.code()
732 );
733 return;
734 }
735 Err(e) => {
736 println!(
737 "cargo::warning=could not run npm ({e}); is Node installed? skipping frontend build (serving existing static/dist)"
738 );
739 return;
740 }
741 }
742 }
743 match Command::new("npm")
744 .args(["run", "build"])
745 .current_dir("frontend")
746 .status()
747 {
748 Ok(s) if s.success() => {}
749 Ok(s) => println!(
750 "cargo::warning=frontend build failed (npm run build exit {:?}); serving stale static/dist",
751 s.code()
752 ),
753 Err(e) => println!(
754 "cargo::warning=could not run npm for the frontend build ({e}); is Node installed? \
755 skipping (serving existing static/dist)"
756 ),
757 }
758 }
759
760 /// Recursively hash every `.js` file under `dir` into `hasher`, in a
761 /// deterministic order. Missing directory is a no-op (first build before the
762 /// frontend has been compiled).
763 fn hash_dir_js(dir: &Path, hasher: &mut DefaultHasher) {
764 let Ok(entries) = fs::read_dir(dir) else {
765 return;
766 };
767 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
768 paths.sort();
769 for path in paths {
770 if path.is_dir() {
771 hash_dir_js(&path, hasher);
772 } else if path.extension().and_then(|e| e.to_str()) == Some("js")
773 && let Ok(content) = fs::read(&path)
774 {
775 content.hash(hasher);
776 }
777 }
778 }
779
780 /// Class-and-property overlaps with the generated stylesheet that have been
781 /// read and kept.
782 ///
783 /// Two kinds, and only the first is what the check is really for.
784 ///
785 /// **Different selector arms.** `.badge { color }` and `.card { color }`:
786 /// makeover colours the tone and disabled arms, the server colours the base and
787 /// its own variants (`.badge.ai-tier-*`, `.badge--founder-*`). Neither touches
788 /// the other's arm. The check collapses arms, because separating them would
789 /// need a selector matcher and a checker that guesses wrong about specificity
790 /// fails correct builds, so the judgement is recorded here.
791 ///
792 /// **A deliberate pairing.** `.progress-fill { background }` is a different
793 /// element rather than a different arm: the media scrubber's fill, which lives
794 /// inside `.progress-bar` and never matches the generated
795 /// `.progress > .progress-fill`. It already carried a `respec-ok` comment
796 /// saying so.
797 ///
798 /// `.tab { box-shadow }`, `{ color }` and `{ cursor }` are this file taking the
799 /// property outright: it zeroes the base `button` shadow on a tab and dresses
800 /// the tab's own text, and a described tab gets that rather than makeover's.
801 /// The chosen arm is the opposite and is not here any more -- it is a
802 /// `revert-layer`, and since makeover-build 0.50.0 a handoff is not read as a
803 /// taking, so it needs no entry.
804 ///
805 /// **What is no longer in this list, and why that is the point.** Nineteen
806 /// entries came out on 2026-08-22, every one of them a `revert-layer`. The
807 /// check used to read property names without their values, so a later layer
808 /// handing a property back looked identical to a later layer taking it, and
809 /// each of those entries had to be written to buy silence for a remedy. The
810 /// cost was not the noise: an entry permits a real override on the same pair
811 /// for good, so the list of remedies and the list of licences were the same
812 /// list. makeover-build 0.50.0 reads the value, and the handoffs at the end of
813 /// `style.css` now speak for themselves.
814 ///
815 /// The element rules those handoffs defer past are the other half of the same
816 /// release: `REVIEWED_ELEMENT_OVERLAPS` below, and the section at the end of
817 /// `style.css` that keeps it empty.
818 ///
819 /// Still overlapping and still this file's: `.badge` and `.card` on their own
820 /// arms, and the disabled treatment's `opacity`, which is a charter decision
821 /// and a property makeover does not set.
822 ///
823 /// **A divergence taken on purpose.** `.table-row { display }` is `grid` here
824 /// against makeover's `table-row`, which is the same build-time grid story
825 /// goingson is on.
826 ///
827 /// `.tab { background }` was here and is gone. `.tab.is-selected` set
828 /// `--surface-raised` and `--bevel-raised`, byte for byte what the generated
829 /// `.tab.chosen` sets: a second name for makeover's own state, which is the
830 /// defect makeover-webview 0.27.0 exists to prevent. The tabs carry `chosen`
831 /// now, in four templates and in `frontend/src/core/tabs.ts`, and the rule is
832 /// deleted. The caret's two entries went the same way, one release later:
833 /// makeover-webview 0.31.0 emits the leading space and the reserved box itself.
834 ///
835 /// An entry that stops colliding fails the build, so this list cannot outlive
836 /// what it describes.
837 const REVIEWED_OVERLAPS: &[(&str, &str)] = &[
838 ("badge", "color"),
839 // A carousel showing one frame at a time, 2026-08-14. This was
840 // `("picture-img", "display")` until makeover-layout 0.23.0 described the
841 // showing itself; the frames are wrapped in `.showing-frame` now and the old
842 // entry stopped colliding, which this list is built to notice.
843 //
844 // The two arms are the same rule at two moments, and that is the whole of
845 // why the overlap is kept. Makeover collapses the stack on `[data-ready]`,
846 // which is the only honest default for a renderer that cannot know whether
847 // a page has script: ship every child, take them away once something binds
848 // them. This site collapses it from first paint instead, because rendering
849 // three frames and collapsing them was a measured 141px -> 58px jump that
850 // every visitor with JS paid, and `no-js.css` from a <noscript> opens the
851 // stack for the few without. That trade is this landing page's to make and
852 // a generated stylesheet has no way to reach <noscript>.
853 ("showing-frame", "display"),
854 ("current", "display"),
855 ("card", "color"),
856 ("progress-fill", "background"),
857 ("tab", "box-shadow"),
858 ("tab", "color"),
859 ("tab", "cursor"),
860 ("table-row", "display"),
861 ];
862
863 /// Bare element rules that reach a generated class and have been read and kept.
864 ///
865 /// The second pass of the same check, and the one this file had no answer to
866 /// until makeover-build 0.50.0: a rule with no class in it is invisible to the
867 /// list above, and `button { color: var(--content) }` in @layer components beat
868 /// the generated `.button[data-tone]` on every described act on the site. A
869 /// destructive act rendered identically to an ordinary one for months.
870 ///
871 /// Empty, and meant to stay that way. The remedy for every one of them is a
872 /// `revert-layer` handoff in style.css, which says which arms makeover keeps in
873 /// the file the browser reads rather than in a build script.
874 const REVIEWED_ELEMENT_OVERLAPS: &[(&str, &str, &str)] = &[];
875
876 /// The short commit sha to stamp into `GIT_HASH`, and the watches that decide
877 /// when this build script has to run again.
878 ///
879 /// The watches are the whole point. Cargo treats a `rerun-if-changed` path that
880 /// does not exist as *changed*, so a watch on a path that can never exist makes
881 /// this script re-run on every single cargo invocation, and re-running it
882 /// recompiles the crate. This file used to watch `.git/HEAD`, which resolves
883 /// against the package root: there is no `.git` in `server/` or in
884 /// `multithreaded/` because the repository root is `MNW/`. So the watch never
885 /// resolved, and the crate recompiled every time.
886 ///
887 /// It cost 347s per Sando pipeline (294s in the server, 53s in multithreaded,
888 /// measured off `/srv/sando/logs/0.11.20/cargo_test.log`), which is 35% of the
889 /// `cargo_test` gate, and it cost the same on every local `cargo build`,
890 /// `cargo test` and `cargo clippy`. The two crates carrying a `build.rs` were
891 /// the only two in the pipeline that recompiled on a second cargo invocation;
892 /// the ones without one were already free.
893 ///
894 /// Two rules follow, and both matter:
895 /// - resolve the paths through git rather than guessing them, and
896 /// - emit a watch ONLY for a path that exists, or the bug comes straight back.
897 fn git_hash() -> String {
898 // An explicit hash wins and skips git entirely, for a build system that
899 // already knows the sha. Nothing sets this today: Sando would have to set it
900 // on EVERY cargo invocation it makes, because `GIT_HASH` is a `rustc-env`
901 // and therefore part of the crate fingerprint, so a value present for the
902 // release build and absent for `cargo_test` would force the recompile this
903 // function exists to remove.
904 println!("cargo::rerun-if-env-changed=MNW_GIT_HASH");
905 if let Ok(h) = std::env::var("MNW_GIT_HASH") {
906 let h = h.trim().to_string();
907 if !h.is_empty() {
908 return h;
909 }
910 }
911
912 // Watch what git actually rewrites when the checkout moves. `.git/HEAD`
913 // alone is not enough even when resolved: committing on a branch rewrites
914 // that branch's ref, not HEAD, so a watch on HEAD by itself would go stale
915 // in the ordinary case of a commit.
916 watch_if_exists(git_path("HEAD").as_deref());
917 if let Some(r) = git_output(&["symbolic-ref", "-q", "HEAD"]) {
918 watch_if_exists(git_path(&r).as_deref());
919 }
920 // A ref that has been packed has no loose file, so this is the fallback
921 // that keeps the watch honest after a `git gc`.
922 watch_if_exists(git_path("packed-refs").as_deref());
923
924 git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default()
925 }
926
927 /// Run a git command in the package directory and return its trimmed stdout.
928 fn git_output(args: &[&str]) -> Option<String> {
929 Command::new("git")
930 .args(args)
931 .output()
932 .ok()
933 .filter(|o| o.status.success())
934 .and_then(|o| String::from_utf8(o.stdout).ok())
935 .map(|s| s.trim().to_string())
936 .filter(|s| !s.is_empty())
937 }
938
939 /// Resolve a name inside the git directory to a path, honouring worktrees and a
940 /// `.git` file that points elsewhere. `None` when this is not a checkout at all,
941 /// which is the case in a vendored or packaged build.
942 fn git_path(name: &str) -> Option<String> {
943 git_output(&["rev-parse", "--git-path", name])
944 }
945
946 /// Emit a watch for a path, but only when it exists.
947 ///
948 /// The guard is the fix. A missing path reads as changed to cargo, so emitting
949 /// one unconditionally is what caused the recompile-every-time bug.
950 fn watch_if_exists(path: Option<&str>) {
951 if let Some(p) = path
952 && Path::new(p).exists()
953 {
954 println!("cargo::rerun-if-changed={p}");
955 }
956 }
957