Skip to main content

max / makeover

Take typography: the two house font tokens and the faces behind them The suite defined no font at all, so three apps each answered --font-mono and --font-sans for themselves and got three different answers. This is layer 1 of the model in wiki typography-standard: two needs, two names, then a system generic. font_face_css states font-weight: 200 800 once. Both faces are variable in one file and the mono face's default instance is ExtraLight, so a consumer that omits the range draws its whole UI at 200. Cutting the faces stays with the consumer: quasi-type is publish = false and this crate is on crates.io, so it cannot be a dependency here.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 16:06 UTC
Signed with PGP, not checked
Commit: 7673435c8dd57b1d0fbb60352cb236e6848bdf3a
Parent: da2b2d1
2 files changed, +159 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.6.0"
3 + version = "2.7.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M src/lib.rs +158
@@ -936,6 +936,114 @@
936 936 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
937 937 }
938 938
939 + // ============================================================================
940 + // Typography — layer 1 of the house font model.
941 + //
942 + // Wiki `typography-standard`. The model is three layers: an app override, the
943 + // house default, then a system generic, and this is the middle one. Two needs,
944 + // two names, and no others in the suite:
945 + //
946 + // --font-mono Quasi Mono -> monospace
947 + // --font-sans Quasi Body -> sans-serif
948 + //
949 + // Both are cut by `quasi-type` from the Atkinson Hyperlegible superfamily plus
950 + // the house glyph set. This crate does not cut them and cannot: quasi-type is
951 + // `publish = false` and makeover is on crates.io, so the cut lives in each
952 + // consumer's own build script (`quasi_type::cut`, taken as a git dependency,
953 + // the way `shop-font` does it). What lives here is the vocabulary, which is
954 + // the half that was scattered.
955 + //
956 + // Font is not a theme's business and none of this is themeable. A theme
957 + // declares colour by role; nothing in a theme file names a face, and the two
958 + // tokens below are the same in every theme. That is why they are constants
959 + // rather than another section of `SemanticTokens`, and why they belong in a
960 + // stylesheet generated once at build time rather than in the block that gets
961 + // re-injected on a theme switch.
962 + //
963 + // The brand/display tier is out of scope, per product and by decision: Young
964 + // Serif on MNW, Reglo in GoingsOn, Departure Mono on Alloy, audiofiles' logo
965 + // face. No renderer emits them and no described screen resolves a token to
966 + // one, so they keep their own `font-family` until the app-override layer
967 + // lands and gives them a place to be declared.
968 + // ============================================================================
969 +
970 + /// The mono slot: code, data, identifiers, cell grids, anything monospaced.
971 + pub const FONT_MONO: &str = "\"Quasi Mono\", monospace";
972 +
973 + /// The body / UI slot. Everything that is not the mono slot or brand tier.
974 + pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif";
975 +
976 + /// Filename a consumer writes the cut mono face to, under its own font URL.
977 + ///
978 + /// `quasi-type` writes `QuasiMono[wght].woff2`, naming the variable axis the
979 + /// way a font tool expects. Those brackets have to be percent-encoded to
980 + /// survive a URL and are a bug waiting to be written, so the web copy takes a
981 + /// plain name and the two places that have to agree — the build script that
982 + /// writes the file and the `@font-face` that fetches it — agree through this
983 + /// constant rather than by both spelling it out.
984 + pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2";
985 +
986 + /// Filename a consumer writes the cut body face to. See [`WEBFONT_MONO_FILE`].
987 + pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2";
988 +
989 + /// The house font tokens as CSS declarations (no selector), for a caller that
990 + /// is composing its own block.
991 + pub fn typography_css_declarations() -> String {
992 + format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n")
993 + }
994 +
995 + /// The house font tokens as a `:root { … }` block.
996 + ///
997 + /// Inlined by surfaces that cannot link a stylesheet — the MNW embeds are the
998 + /// live case — and written to a file by everything else, through
999 + /// `makeover_build::typography_css`.
1000 + pub fn typography_css_vars() -> String {
1001 + format!(":root {{\n{}}}\n", typography_css_declarations())
1002 + }
1003 +
1004 + /// The `@font-face` rules for both slots, fetching from `base_url`.
1005 + ///
1006 + /// `base_url` is the directory the consumer serves its fonts from, without a
1007 + /// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri
1008 + /// frontend loading relative to its index.
1009 + ///
1010 + /// # `font-weight: 200 800`, which is the part that bites
1011 + ///
1012 + /// Both faces are variable over `wght` 200-800 in one file, and the mono
1013 + /// face's **default instance is ExtraLight** — that is upstream Atkinson's
1014 + /// default and the cut keeps the axis rather than pinning a master, so a
1015 + /// consumer that loads the file and takes what it opens at draws its whole UI
1016 + /// at 200. Declaring the range here is what makes the browser resolve `normal`
1017 + /// to 400 and `bold` to 700 instead. shop hit the same trap from the other
1018 + /// side and names `wght` 400 explicitly in its shaper; this is the web's
1019 + /// version of that fix, stated once for every consumer.
1020 + ///
1021 + /// `font-display: swap` on both: the faces are 31KB and 50KB, they are cached
1022 + /// hard after the first paint, and a flash of the fallback beats invisible
1023 + /// text either way.
1024 + pub fn font_face_css(base_url: &str) -> String {
1025 + use std::fmt::Write as _;
1026 +
1027 + let base = base_url.trim_end_matches('/');
1028 + let mut out = String::new();
1029 + for (family, file) in [
1030 + ("Quasi Mono", WEBFONT_MONO_FILE),
1031 + ("Quasi Body", WEBFONT_SANS_FILE),
1032 + ] {
1033 + let _ = write!(
1034 + out,
1035 + "@font-face {{\n \
1036 + font-family: \"{family}\";\n \
1037 + src: url(\"{base}/{file}\") format(\"woff2\");\n \
1038 + font-weight: 200 800;\n \
1039 + font-style: normal;\n \
1040 + font-display: swap;\n\
1041 + }}\n\n"
1042 + );
1043 + }
1044 + out
1045 + }
1046 +
939 1047 // ============================================================================
940 1048 // Loading / parsing
941 1049 // ============================================================================
@@ -2547,6 +2655,56 @@
2547 2655 assert!(css.trim_end().ends_with('}'));
2548 2656 }
2549 2657
2658 + // ---- typography ----
2659 +
2660 + #[test]
2661 + fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() {
2662 + let css = typography_css_vars();
2663 + assert!(css.starts_with(":root {\n"));
2664 + assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n"));
2665 + assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n"));
2666 +
2667 + // Layer 2 is one hop and no further. A third entry in either stack is
2668 + // the shape the standard exists to delete: a chain nobody can predict
2669 + // the metrics of, which is what `--font-sans: -apple-system,
2670 + // BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps.
2671 + for stack in [FONT_MONO, FONT_SANS] {
2672 + assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop");
2673 + }
2674 +
2675 + // Two tokens, and no others. `--font-body`, `--font-heading` and
2676 + // `--font-display` are gone or out of scope; a token appearing here
2677 + // is a fifth answer to a question that has two.
2678 + assert_eq!(css.matches("--font-").count(), 2);
2679 + }
2680 +
2681 + #[test]
2682 + fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() {
2683 + let css = font_face_css("/static/fonts");
2684 +
2685 + assert_eq!(css.matches("@font-face").count(), 2);
2686 + assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");"));
2687 + assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");"));
2688 +
2689 + // The trap. Atkinson Hyperlegible Mono's default instance is
2690 + // ExtraLight and the cut keeps the axis, so a `@font-face` that omits
2691 + // the range draws the whole UI at 200.
2692 + assert_eq!(css.matches("font-weight: 200 800;").count(), 2);
2693 +
2694 + // The families have to be exactly what the tokens ask for, or the
2695 + // stack falls through to the generic and the face is dead weight.
2696 + for family in [FONT_MONO, FONT_SANS] {
2697 + let quoted = family.split(',').next().unwrap();
2698 + assert!(css.contains(&format!("font-family: {quoted};")));
2699 + }
2700 + }
2701 +
2702 + #[test]
2703 + fn a_trailing_slash_on_the_base_url_does_not_double_it() {
2704 + assert_eq!(font_face_css("fonts/"), font_face_css("fonts"));
2705 + assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")"));
2706 + }
2707 +
2550 2708 // ---- loading / fs ----
2551 2709
2552 2710 #[test]