Skip to main content

max / makeover

Add the app-override layer, so a product declares its brand face once Layer 0 of the font model, closing GO makeover 174ab3c1. The brand tier was already exempt by decision, but the exemption was enforced by those faces not being in the vocabulary at all, so every product reached its own face through a hardcoded font-family and an @font-face block it maintained by hand. That is the shape the unification deletes everywhere else. FontSlot names the three slots; display is deliberately empty by default, so a product that does not override it leaves the token undefined and the consumer's own fallback renders. Typography carries a product's overrides and resolves them, and it permits overriding mono and sans too: a layer that only allows overriding the slot nobody describes is the exemption restated. One declaration per product per slot, enforced by a panic rather than last-one-wins. Two answers for a slot is a vocabulary bug, not a merge. Renderers honour what they can. Only the webview surface has a face to choose today, so an override reaches the generated stylesheet and is correctly ignored by makeover-tui and makeover-immediate; a renderer that gains font control reads Typography::resolve rather than the CSS.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 18:56 UTC
Signed with PGP, not checked
Commit: 28bb7a09c50486d6779abe1267433756f415643d
Parent: 7673435
2 files changed, +426 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.7.0"
3 + version = "2.8.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 +425
@@ -1044,6 +1044,315 @@
1044 1044 out
1045 1045 }
1046 1046
1047 + // ============================================================================
1048 + // Typography — layer 0, the app override.
1049 + //
1050 + // Wiki `typography-standard`, GO makeover `174ab3c1`. Layer 1 above is what
1051 + // every product shares; this is the one declaration a product is allowed to
1052 + // make for itself:
1053 + //
1054 + // layer 0 app override per product, optional MNW display -> Young Serif
1055 + // layer 1 house default the quasi-* slot font quasi-mono -> Quasi Mono
1056 + // layer 2 system generic one hop, no further monospace / sans-serif
1057 + //
1058 + // The brand tier was already exempt by decision (`cdf8ac09`), and the exemption
1059 + // was enforced by those faces simply not being in the vocabulary — so each
1060 + // product reached its own face through a hardcoded `font-family` and an
1061 + // `@font-face` block it maintained by hand, which is the exact shape the
1062 + // unification is deleting everywhere else. This turns the carve-out into a
1063 + // mechanism: the per-product face is declared once, in the build script that
1064 + // already writes the typography layer, and is readable as an override rather
1065 + // than as a stylesheet nobody unified.
1066 + //
1067 + // It permits overriding `mono` and `sans` too. No product wants that today,
1068 + // and a layer that only allows overriding the slot nobody describes is not a
1069 + // layer, it is the exemption restated.
1070 + //
1071 + // **One declaration per product per slot.** [`Typography::with_override`]
1072 + // panics on a second override of the same slot rather than letting the last
1073 + // one win: a product with two answers for a slot has the vocabulary wrong, and
1074 + // that is the thing to fix.
1075 + //
1076 + // # What a renderer does when it cannot honour one
1077 + //
1078 + // Declare once, renderers honour what they can. Today only the webview surface
1079 + // has a face to honour at all — neither `makeover-tui` nor `makeover-immediate`
1080 + // emits a `font-family` from anywhere, because the terminal owns the face in
1081 + // one and the app loads its own font stack in the other. So an override is
1082 + // honoured by the generated stylesheet and ignored, silently and correctly, by
1083 + // the other two. A renderer that gains font control later reads
1084 + // [`Typography::resolve`] rather than the CSS, which is why the resolution is
1085 + // a method on the data and not a string-building detail.
1086 + // ============================================================================
1087 +
1088 + /// A slot in the house font vocabulary — the unit an override replaces.
1089 + ///
1090 + /// Three, and the third is deliberately empty by default: `display` is the
1091 + /// brand tier, it has no house answer, and a product that does not override it
1092 + /// leaves the token undefined so whatever the consumer wrote as a fallback
1093 + /// renders. The MNW embeds rely on exactly that.
1094 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1095 + pub enum FontSlot {
1096 + /// Code, data, identifiers, cell grids. [`FONT_MONO`] by default.
1097 + Mono,
1098 + /// Body and UI text: everything that is not mono or brand. [`FONT_SANS`].
1099 + Sans,
1100 + /// The brand / display tier. No house default, per `cdf8ac09`.
1101 + Display,
1102 + }
1103 +
1104 + impl FontSlot {
1105 + /// Every slot, in the order they are emitted.
1106 + pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display];
1107 +
1108 + /// The custom property this slot is read through.
1109 + pub fn token(self) -> &'static str {
1110 + match self {
1111 + FontSlot::Mono => "--font-mono",
1112 + FontSlot::Sans => "--font-sans",
1113 + FontSlot::Display => "--font-display",
1114 + }
1115 + }
1116 +
1117 + /// The house stack, or `None` for the brand tier.
1118 + pub fn house_default(self) -> Option<&'static str> {
1119 + match self {
1120 + FontSlot::Mono => Some(FONT_MONO),
1121 + FontSlot::Sans => Some(FONT_SANS),
1122 + FontSlot::Display => None,
1123 + }
1124 + }
1125 + }
1126 +
1127 + /// One `@font-face` an override brings with it.
1128 + ///
1129 + /// A product overriding a slot usually has to ship the face too, and the two
1130 + /// halves have to agree on a family name. Declaring them together is what
1131 + /// makes that agreement structural rather than a string typed twice.
1132 + #[derive(Debug, Clone)]
1133 + pub struct FontFace {
1134 + family: String,
1135 + sources: Vec<String>,
1136 + weight: Option<String>,
1137 + style: Option<String>,
1138 + }
1139 +
1140 + impl FontFace {
1141 + /// A face named `family`, fetched from `sources`.
1142 + ///
1143 + /// Each source is either a bare filename, resolved against the
1144 + /// [`Typography`] base URL, or an absolute one (`/…` or `https://…`) taken
1145 + /// as written. The `format()` hint is inferred from the extension —
1146 + /// `woff2`, `woff`, `ttf`, `otf` — and omitted for anything else rather
1147 + /// than guessed, since a wrong hint is worse than none.
1148 + pub fn new<S: Into<String>>(
1149 + family: impl Into<String>,
1150 + sources: impl IntoIterator<Item = S>,
1151 + ) -> Self {
1152 + Self {
1153 + family: family.into(),
1154 + sources: sources.into_iter().map(Into::into).collect(),
1155 + weight: None,
1156 + style: None,
1157 + }
1158 + }
1159 +
1160 + /// `font-weight`, as CSS writes it: `"700"`, or `"200 800"` for a variable
1161 + /// axis. Omitted when unset, which means `normal`.
1162 + ///
1163 + /// A variable face MUST name its range here for the same reason the house
1164 + /// faces do: a `@font-face` with no range makes the browser resolve every
1165 + /// weight to the file's default instance.
1166 + #[must_use]
1167 + pub fn weight(mut self, weight: impl Into<String>) -> Self {
1168 + self.weight = Some(weight.into());
1169 + self
1170 + }
1171 +
1172 + /// `font-style`. Omitted when unset, which means `normal`.
1173 + #[must_use]
1174 + pub fn style(mut self, style: impl Into<String>) -> Self {
1175 + self.style = Some(style.into());
1176 + self
1177 + }
1178 +
1179 + fn css(&self, base: &str) -> String {
1180 + use std::fmt::Write as _;
1181 +
1182 + let src = self
1183 + .sources
1184 + .iter()
1185 + .map(|s| {
1186 + let url = if s.starts_with('/') || s.contains("://") {
1187 + s.clone()
1188 + } else {
1189 + format!("{base}/{s}")
1190 + };
1191 + match font_format(s) {
1192 + Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"),
1193 + None => format!("url(\"{url}\")"),
1194 + }
1195 + })
1196 + .collect::<Vec<_>>()
1197 + .join(",\n ");
1198 +
1199 + let mut out = format!(
1200 + "@font-face {{\n font-family: \"{}\";\n src: {src};\n",
1201 + self.family
1202 + );
1203 + if let Some(w) = &self.weight {
1204 + let _ = writeln!(out, " font-weight: {w};");
1205 + }
1206 + if let Some(s) = &self.style {
1207 + let _ = writeln!(out, " font-style: {s};");
1208 + }
1209 + out.push_str(" font-display: swap;\n}\n\n");
1210 + out
1211 + }
1212 + }
1213 +
1214 + /// The `format()` hint for a source, by extension. `None` when unrecognised.
1215 + fn font_format(source: &str) -> Option<&'static str> {
1216 + match source.rsplit('.').next()?.to_ascii_lowercase().as_str() {
1217 + "woff2" => Some("woff2"),
1218 + "woff" => Some("woff"),
1219 + "ttf" => Some("truetype"),
1220 + "otf" => Some("opentype"),
1221 + _ => None,
1222 + }
1223 + }
1224 +
1225 + /// One product's answer for one slot: the stack, and any faces it ships.
1226 + #[derive(Debug, Clone)]
1227 + pub struct FontOverride {
1228 + slot: FontSlot,
1229 + stack: String,
1230 + faces: Vec<FontFace>,
1231 + }
1232 +
1233 + impl FontOverride {
1234 + /// Point `slot` at `stack`.
1235 + ///
1236 + /// `stack` is the CSS value the token takes, written the way the house
1237 + /// stacks are: the family, then one hop to a system generic. Layer 2 is
1238 + /// still one hop and no further — an override is a different answer to the
1239 + /// slot, not a licence to write the fallback chain the standard deleted.
1240 + pub fn new(slot: FontSlot, stack: impl Into<String>) -> Self {
1241 + Self {
1242 + slot,
1243 + stack: stack.into(),
1244 + faces: Vec::new(),
1245 + }
1246 + }
1247 +
1248 + /// Ship a face with the override.
1249 + #[must_use]
1250 + pub fn with_face(mut self, face: FontFace) -> Self {
1251 + self.faces.push(face);
1252 + self
1253 + }
1254 +
1255 + /// The slot this answers.
1256 + pub fn slot(&self) -> FontSlot {
1257 + self.slot
1258 + }
1259 +
1260 + /// The stack it resolves to.
1261 + pub fn stack(&self) -> &str {
1262 + &self.stack
1263 + }
1264 + }
1265 +
1266 + /// The whole typography layer for one product: the house defaults, plus
1267 + /// whatever it overrides.
1268 + ///
1269 + /// This is what a build script composes and what
1270 + /// `makeover_build::typography_css_from` writes. [`typography_css_vars`] and
1271 + /// [`font_face_css`] are the no-override case of it and stay for callers that
1272 + /// have nothing to declare.
1273 + #[derive(Debug, Clone)]
1274 + pub struct Typography {
1275 + base_url: String,
1276 + overrides: Vec<FontOverride>,
1277 + }
1278 +
1279 + impl Typography {
1280 + /// The house layer alone, fetching faces from `base_url` — the directory
1281 + /// the consumer serves fonts from, with or without a trailing slash.
1282 + pub fn house(base_url: impl Into<String>) -> Self {
1283 + Self {
1284 + base_url: base_url.into(),
1285 + overrides: Vec::new(),
1286 + }
1287 + }
1288 +
1289 + /// Add one product override.
1290 + ///
1291 + /// # Panics
1292 + ///
1293 + /// If the slot is already overridden. One declaration per product per
1294 + /// slot: a second is not a merge to resolve, it is two answers to a
1295 + /// question that has one, and the vocabulary is what wants fixing.
1296 + #[must_use]
1297 + pub fn with_override(mut self, ov: FontOverride) -> Self {
1298 + assert!(
1299 + !self.overrides.iter().any(|o| o.slot == ov.slot),
1300 + "{} is overridden twice; one declaration per product per slot",
1301 + ov.slot.token()
1302 + );
1303 + self.overrides.push(ov);
1304 + self
1305 + }
1306 +
1307 + /// What `slot` resolves to under this layer, or `None` for a brand slot
1308 + /// nobody overrode.
1309 + ///
1310 + /// The resolution, for a renderer that has a face to choose rather than a
1311 + /// stylesheet to emit.
1312 + pub fn resolve(&self, slot: FontSlot) -> Option<&str> {
1313 + self.overrides
1314 + .iter()
1315 + .find(|o| o.slot == slot)
1316 + .map(|o| o.stack.as_str())
1317 + .or_else(|| slot.house_default())
1318 + }
1319 +
1320 + /// The `@font-face` rules: the two house faces, then each override's.
1321 + pub fn font_face_css(&self) -> String {
1322 + let base = self.base_url.trim_end_matches('/');
1323 + let mut out = font_face_css(base);
1324 + for ov in &self.overrides {
1325 + for face in &ov.faces {
1326 + out.push_str(&face.css(base));
1327 + }
1328 + }
1329 + out
1330 + }
1331 +
1332 + /// The resolved tokens as CSS declarations, no selector.
1333 + pub fn css_declarations(&self) -> String {
1334 + use std::fmt::Write as _;
1335 +
1336 + let mut out = String::new();
1337 + for slot in FontSlot::ALL {
1338 + if let Some(stack) = self.resolve(slot) {
1339 + let _ = writeln!(out, " {}: {stack};", slot.token());
1340 + }
1341 + }
1342 + out
1343 + }
1344 +
1345 + /// The resolved tokens as a `:root { … }` block.
1346 + pub fn css_vars(&self) -> String {
1347 + format!(":root {{\n{}}}\n", self.css_declarations())
1348 + }
1349 +
1350 + /// Faces then tokens, in the order a stylesheet wants them.
1351 + pub fn css(&self) -> String {
1352 + format!("{}{}", self.font_face_css(), self.css_vars())
1353 + }
1354 + }
1355 +
1047 1356 // ============================================================================
1048 1357 // Loading / parsing
1049 1358 // ============================================================================
@@ -2705,6 +3014,122 @@
2705 3014 assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")"));
2706 3015 }
2707 3016
3017 + // ---- typography, layer 0 ----
3018 +
3019 + /// The live case: MNW's Young Serif, which reached the page through a
3020 + /// hand-maintained `@font-face` and a `--font-heading` nothing else knew
3021 + /// about.
3022 + fn young_serif() -> FontOverride {
3023 + FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
3024 + .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"]))
3025 + }
3026 +
3027 + #[test]
3028 + fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() {
3029 + let t = Typography::house("/static/fonts");
3030 + assert_eq!(t.font_face_css(), font_face_css("/static/fonts"));
3031 + assert_eq!(t.css_vars(), typography_css_vars());
3032 + }
3033 +
3034 + #[test]
3035 + fn an_unoverridden_display_slot_defines_no_token_at_all() {
3036 + // Not "defined empty": undefined, so the consumer's own fallback in
3037 + // `var(--font-display, …)` renders. The MNW embeds depend on it.
3038 + let t = Typography::house("fonts");
3039 + assert!(!t.css_vars().contains("--font-display"));
3040 + assert_eq!(t.resolve(FontSlot::Display), None);
3041 + assert_eq!(t.css_vars().matches("--font-").count(), 2);
3042 + }
3043 +
3044 + #[test]
3045 + fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() {
3046 + let t = Typography::house("/static/fonts").with_override(young_serif());
3047 +
3048 + assert!(
3049 + t.css_vars()
3050 + .contains(" --font-display: \"Young Serif\", serif;\n")
3051 + );
3052 + assert!(
3053 + t.css_vars()
3054 + .contains(" --font-mono: \"Quasi Mono\", monospace;\n")
3055 + );
3056 + assert!(
3057 + t.css_vars()
3058 + .contains(" --font-sans: \"Quasi Body\", sans-serif;\n")
3059 + );
3060 + assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif"));
3061 +
3062 + let faces = t.font_face_css();
3063 + assert_eq!(faces.matches("@font-face").count(), 3);
3064 + assert!(faces.contains("font-family: \"Young Serif\";"));
3065 + assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")"));
3066 + assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
3067 +
3068 + // The house faces still come first, so a product face never shadows a
3069 + // slot it did not claim.
3070 + assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap());
3071 + }
3072 +
3073 + #[test]
3074 + fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() {
3075 + // Nobody wants this today. A layer that only permits overriding the
3076 + // slot nobody describes is the exemption restated, not a layer.
3077 + let t = Typography::house("fonts").with_override(FontOverride::new(
3078 + FontSlot::Mono,
3079 + "\"Departure Mono\", monospace",
3080 + ));
3081 +
3082 + assert!(
3083 + t.css_vars()
3084 + .contains(" --font-mono: \"Departure Mono\", monospace;\n")
3085 + );
3086 + assert!(!t.css_vars().contains("Quasi Mono"));
3087 + assert_eq!(t.css_vars().matches("--font-").count(), 2);
3088 + }
3089 +
3090 + #[test]
3091 + #[should_panic(expected = "--font-display is overridden twice")]
3092 + fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() {
3093 + let _ = Typography::house("fonts")
3094 + .with_override(young_serif())
3095 + .with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif"));
3096 + }
3097 +
3098 + #[test]
3099 + fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() {
3100 + let t = Typography::house("/static/fonts").with_override(
3101 + FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face(
3102 + FontFace::new(
3103 + "Reglo",
3104 + ["Reglo-Bold.woff2", "https://cdn.example/reglo.woff2"],
3105 + )
3106 + .weight("700"),
3107 + ),
3108 + );
3109 + let faces = t.font_face_css();
3110 + assert!(faces.contains("url(\"/static/fonts/Reglo-Bold.woff2\")"));
3111 + assert!(faces.contains("url(\"https://cdn.example/reglo.woff2\")"));
3112 + assert!(faces.contains(" font-weight: 700;\n"));
3113 + }
3114 +
3115 + #[test]
3116 + fn an_unrecognised_extension_gets_no_format_hint_rather_than_a_guessed_one() {
3117 + let t = Typography::house("fonts").with_override(
3118 + FontOverride::new(FontSlot::Display, "\"Odd\", serif")
3119 + .with_face(FontFace::new("Odd", ["odd.eot"])),
3120 + );
3121 + assert!(t.font_face_css().contains("url(\"fonts/odd.eot\");"));
3122 + assert!(!t.font_face_css().contains("format(\"eot\")"));
3123 + }
3124 +
3125 + #[test]
3126 + fn css_puts_the_faces_before_the_tokens_that_name_them() {
3127 + let t = Typography::house("fonts").with_override(young_serif());
3128 + let css = t.css();
3129 + assert!(css.starts_with("@font-face"));
3130 + assert!(css.find("@font-face").unwrap() < css.find(":root").unwrap());
3131 + }
3132 +
2708 3133 // ---- loading / fs ----
2709 3134
2710 3135 #[test]