makeover/font.rs
1//! Typography — layer 0, the app override.
2//!
3//! Wiki `typography-standard`, GO makeover `174ab3c1`. Layer 1 above is what
4//! every product shares; this is the one declaration a product is allowed to
5//! make for itself:
6//!
7//! ```text
8//! layer 0 app override per product, optional MNW display -> Young Serif
9//! layer 1 house default the quasi-* slot font quasi-mono -> Quasi Mono
10//! layer 2 system generic one hop, no further monospace / sans-serif
11//! ```
12//!
13//! The brand tier was already exempt by decision (`cdf8ac09`), and the exemption
14//! was enforced by those faces simply not being in the vocabulary — so each
15//! product reached its own face through a hardcoded `font-family` and an
16//! `@font-face` block it maintained by hand, which is the exact shape the
17//! unification is deleting everywhere else. This turns the carve-out into a
18//! mechanism: the per-product face is declared once, in the build script that
19//! already writes the typography layer, and is readable as an override rather
20//! than as a stylesheet nobody unified.
21//!
22//! It permits overriding `mono` and `sans` too. No product wants that today,
23//! and a layer that only allows overriding the slot nobody describes is not a
24//! layer, it is the exemption restated.
25//!
26//! **One declaration per product per slot.** [`Typography::with_override`]
27//! panics on a second override of the same slot rather than letting the last
28//! one win: a product with two answers for a slot has the vocabulary wrong, and
29//! that is the thing to fix.
30//!
31//! # What a renderer does when it cannot honour one
32//!
33//! Declare once, renderers honour what they can. Today only the webview surface
34//! has a face to honour at all — neither `makeover-tui` nor `makeover-immediate`
35//! emits a `font-family` from anywhere, because the terminal owns the face in
36//! one and the app loads its own font stack in the other. So an override is
37//! honoured by the generated stylesheet and ignored, silently and correctly, by
38//! the other two. That last clause was too strong and 2.10.0 corrected it: egui
39//! can reach a face perfectly well, it just needs the file rather than a stack.
40//! audiofiles honours its override with no stylesheet anywhere in the path. A renderer that gains font control later reads
41//! [`Typography::resolve`] rather than the CSS, which is why the resolution is
42//! a method on the data and not a string-building detail. Loading a file needs
43//! one thing more than the stack — the family name and the source to load it
44//! from — so [`Typography::faces`] is the same data read the other way, and
45//! between them an egui or TUI surface can honour an override without a
46//! stylesheet anywhere in the path. audiofiles is the first to do it.
47
48use crate::{
49 FONT_MONO, FONT_SANS, HOUSE_MONO_FAMILY, HOUSE_SANS_FAMILY, HOUSE_WEIGHT_RANGE,
50 WEBFONT_MONO_FILE, WEBFONT_SANS_FILE, font_face_css,
51};
52
53// Names this module's prose links to, resolved for rustdoc.
54#[allow(unused_imports)]
55use crate::typography_css_vars;
56
57/// A slot in the house font vocabulary — the unit an override replaces.
58///
59/// Three, and the third is deliberately empty by default: `display` is the
60/// brand tier, it has no house answer, and a product that does not override it
61/// leaves the token undefined so whatever the consumer wrote as a fallback
62/// renders. The MNW embeds rely on exactly that.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum FontSlot {
65 /// Code, data, identifiers, cell grids. [`FONT_MONO`] by default.
66 Mono,
67 /// Body and UI text: everything that is not mono or brand. [`FONT_SANS`].
68 Sans,
69 /// The brand / display tier. No house default.
70 Display,
71}
72
73impl FontSlot {
74 /// Every slot, in the order they are emitted.
75 pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display];
76
77 /// The token this slot is read through: `--{token}` in a stylesheet, the
78 /// same bare-name shape as geometry's and timing's tokens.
79 pub fn token(self) -> &'static str {
80 match self {
81 FontSlot::Mono => "font-mono",
82 FontSlot::Sans => "font-sans",
83 FontSlot::Display => "font-display",
84 }
85 }
86
87 /// The house stack, or `None` for the brand tier.
88 pub fn house_default(self) -> Option<&'static str> {
89 match self {
90 FontSlot::Mono => Some(FONT_MONO),
91 FontSlot::Sans => Some(FONT_SANS),
92 FontSlot::Display => None,
93 }
94 }
95
96 /// The house face behind that stack, or `None` for the brand tier.
97 ///
98 /// The counterpart of [`house_default`](Self::house_default), and the same
99 /// split as [`Typography::resolve`] against [`Typography::faces`]: one
100 /// names the family that wins, the other names the file behind it. The
101 /// house tier was a format string until this existed, so it could be
102 /// emitted and not read — which made [`Typography::faces`] answer for the
103 /// brand tier and stay silent about the other two.
104 ///
105 /// The sources are the **web** copies. A native loader wants a `ttf` and
106 /// cuts its own through `quasi-type`, whose `cut_native` writes the file
107 /// and hands back the family, style and default weight to register it
108 /// under; there is no house `ttf` named here, and there should not be. This
109 /// crate is on crates.io and `quasi-type` is `publish = false`, so naming a
110 /// native file here would assert a path the web build does not write and
111 /// save the consumer nothing, since it still has to run the pipeline.
112 ///
113 /// One hazard travels with that arrangement and is not solved: a native
114 /// consumer takes `quasi-type` as a git dep and pins a rev, so a stale pin
115 /// ships an older glyph set silently. Advance it deliberately.
116 pub fn house_face(self) -> Option<FontFace> {
117 let (family, file) = match self {
118 FontSlot::Mono => (HOUSE_MONO_FAMILY, WEBFONT_MONO_FILE),
119 FontSlot::Sans => (HOUSE_SANS_FAMILY, WEBFONT_SANS_FILE),
120 FontSlot::Display => return None,
121 };
122 Some(
123 FontFace::new(family, [file])
124 .with_weight(HOUSE_WEIGHT_RANGE)
125 .with_style("normal"),
126 )
127 }
128}
129
130/// One `@font-face` an override brings with it.
131///
132/// A product overriding a slot usually has to ship the face too, and the two
133/// halves have to agree on a family name. Declaring them together is what
134/// makes that agreement structural rather than a string typed twice.
135#[derive(Debug, Clone)]
136pub struct FontFace {
137 family: String,
138 sources: Vec<String>,
139 weight: Option<String>,
140 style: Option<String>,
141}
142
143impl FontFace {
144 /// A face named `family`, fetched from `sources`.
145 ///
146 /// Each source is either a bare filename, resolved against the
147 /// [`Typography`] base URL, or an absolute one (`/…` or `https://…`) taken
148 /// as written. The `format()` hint is inferred from the extension —
149 /// `woff2`, `woff`, `ttf`, `otf` — and omitted for anything else rather
150 /// than guessed, since a wrong hint is worse than none.
151 pub fn new<S: Into<String>>(
152 family: impl Into<String>,
153 sources: impl IntoIterator<Item = S>,
154 ) -> Self {
155 Self {
156 family: family.into(),
157 sources: sources.into_iter().map(Into::into).collect(),
158 weight: None,
159 style: None,
160 }
161 }
162
163 /// `font-weight`, as CSS writes it: `"700"`, or `"200 800"` for a variable
164 /// axis. Omitted when unset, which means `normal`.
165 ///
166 /// A variable face MUST name its range here for the same reason the house
167 /// faces do: a `@font-face` with no range makes the browser resolve every
168 /// weight to the file's default instance.
169 #[must_use]
170 pub fn with_weight(mut self, weight: impl Into<String>) -> Self {
171 self.weight = Some(weight.into());
172 self
173 }
174
175 /// `font-style`. Omitted when unset, which means `normal`.
176 #[must_use]
177 pub fn with_style(mut self, style: impl Into<String>) -> Self {
178 self.style = Some(style.into());
179 self
180 }
181
182 /// The declared `font-weight`, or `None` when the face never named one.
183 ///
184 /// A renderer loading a variable face directly has to name a weight — the
185 /// file's own default instance is whatever the base shipped, which for the
186 /// house faces is ExtraLight — so this is the half of the declaration that
187 /// stops the load from being a guess.
188 pub fn weight(&self) -> Option<&str> {
189 self.weight.as_deref()
190 }
191
192 /// The declared `font-style`, or `None`, which means `normal`.
193 pub fn style(&self) -> Option<&str> {
194 self.style.as_deref()
195 }
196
197 /// The family name, as the stack has to spell it.
198 ///
199 /// For a renderer that loads faces rather than emitting CSS this is the
200 /// name it registers the file under, and reading it here is what keeps
201 /// that name from being typed a second time.
202 pub fn family(&self) -> &str {
203 &self.family
204 }
205
206 /// The sources, unresolved — bare filenames as they were declared, not
207 /// joined to any base URL. A renderer loading from disk or from an
208 /// `include_bytes!` wants the filename; only the CSS wants the URL.
209 pub fn sources(&self) -> &[String] {
210 &self.sources
211 }
212
213 pub(crate) fn css(&self, base: &str) -> String {
214 use std::fmt::Write as _;
215
216 let src = self
217 .sources
218 .iter()
219 .map(|s| {
220 let url = if s.starts_with('/') || s.contains("://") {
221 s.clone()
222 } else {
223 format!("{base}/{s}")
224 };
225 let url = crate::css::string_body(&url);
226 match font_format(s) {
227 Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"),
228 None => format!("url(\"{url}\")"),
229 }
230 })
231 .collect::<Vec<_>>()
232 .join(",\n ");
233
234 // Every caller string goes through `css`: a family and a URL as the
235 // bodies of quoted strings, a weight and a style only if they are bare
236 // keywords. A theme file is user-writable.
237 let mut out = format!(
238 "@font-face {{\n font-family: \"{}\";\n src: {src};\n",
239 crate::css::string_body(&self.family)
240 );
241 if let Some(w) = self.weight.as_deref().and_then(crate::css::keyword) {
242 let _ = writeln!(out, " font-weight: {w};");
243 }
244 if let Some(s) = self.style.as_deref().and_then(crate::css::keyword) {
245 let _ = writeln!(out, " font-style: {s};");
246 }
247 // `optional`: see `typography::font_face_css`.
248 out.push_str(" font-display: optional;\n}\n\n");
249 out
250 }
251}
252
253/// The `format()` hint for a source, by extension. `None` when unrecognised.
254fn font_format(source: &str) -> Option<&'static str> {
255 match source.rsplit('.').next()?.to_ascii_lowercase().as_str() {
256 "woff2" => Some("woff2"),
257 "woff" => Some("woff"),
258 "ttf" => Some("truetype"),
259 "otf" => Some("opentype"),
260 _ => None,
261 }
262}
263
264/// One product's answer for one slot: the stack, and any faces it ships.
265#[derive(Debug, Clone)]
266pub struct FontOverride {
267 slot: FontSlot,
268 stack: String,
269 faces: Vec<FontFace>,
270}
271
272impl FontOverride {
273 /// Point `slot` at `stack`.
274 ///
275 /// `stack` is the CSS value the token takes, written the way the house
276 /// stacks are: the family, then one hop to a system generic. Layer 2 is
277 /// still one hop and no further — an override is a different answer to the
278 /// slot, not a licence to write the fallback chain the standard deleted.
279 ///
280 /// # Panics
281 ///
282 /// If `stack` is not a font-family list — a comma-separated run of quoted
283 /// names or bare identifiers. This is emitted as the bare value of
284 /// `--font-…`, so unlike a quoted string it cannot be escaped, and a stack
285 /// that is not a family list would write into the stylesheet as code.
286 pub fn new(slot: FontSlot, stack: impl Into<String>) -> Self {
287 let stack = stack.into();
288 assert!(
289 crate::css::font_stack(&stack).is_some(),
290 "{stack:?} is not a font-family list"
291 );
292 Self {
293 slot,
294 stack,
295 faces: Vec::new(),
296 }
297 }
298
299 /// Ship a face with the override.
300 #[must_use]
301 pub fn with_face(mut self, face: FontFace) -> Self {
302 self.faces.push(face);
303 self
304 }
305
306 /// The slot this answers.
307 pub fn slot(&self) -> FontSlot {
308 self.slot
309 }
310
311 /// The stack it resolves to.
312 pub fn stack(&self) -> &str {
313 &self.stack
314 }
315
316 /// The faces it ships, in declaration order.
317 pub fn faces(&self) -> &[FontFace] {
318 &self.faces
319 }
320}
321
322/// The whole typography layer for one product: the house defaults, plus
323/// whatever it overrides.
324///
325/// This is what a build script composes and what
326/// `makeover_build::typography_css_from` writes. [`typography_css_vars`] and
327/// [`font_face_css`] are the no-override case of it and stay for callers that
328/// have nothing to declare.
329#[derive(Debug, Clone)]
330pub struct Typography {
331 base_url: String,
332 overrides: Vec<FontOverride>,
333}
334
335impl Typography {
336 /// The house layer alone, fetching faces from `base_url` — the directory
337 /// the consumer serves fonts from, with or without a trailing slash.
338 pub fn house(base_url: impl Into<String>) -> Self {
339 Self {
340 base_url: base_url.into(),
341 overrides: Vec::new(),
342 }
343 }
344
345 /// Add one product override.
346 ///
347 /// # Panics
348 ///
349 /// If the slot is already overridden. One declaration per product per
350 /// slot: a second is not a merge to resolve, it is two answers to a
351 /// question that has one, and the vocabulary is what wants fixing.
352 #[must_use]
353 pub fn with_override(mut self, ov: FontOverride) -> Self {
354 assert!(
355 !self.overrides.iter().any(|o| o.slot == ov.slot),
356 "--{} is overridden twice; one declaration per product per slot",
357 ov.slot.token()
358 );
359 self.overrides.push(ov);
360 self
361 }
362
363 /// What `slot` resolves to under this layer, or `None` for a brand slot
364 /// nobody overrode.
365 ///
366 /// The resolution, for a renderer that has a face to choose rather than a
367 /// stylesheet to emit.
368 pub fn resolve(&self, slot: FontSlot) -> Option<&str> {
369 self.overrides
370 .iter()
371 .find(|o| o.slot == slot)
372 .map(|o| o.stack.as_str())
373 .or_else(|| slot.house_default())
374 }
375
376 /// The faces a product ships for `slot`, in declaration order, or an
377 /// empty slice for a slot it did not override.
378 ///
379 /// The other half of [`resolve`](Self::resolve), for a renderer that has
380 /// to load a file rather than name a stack: `resolve` says which family
381 /// wins, this says where the bytes come from, what to call them, and at
382 /// what weight. The
383 /// house faces are not here — they belong to the slot rather than to any
384 /// one product, and [`FontSlot::house_face`] is where they answer.
385 pub fn faces(&self, slot: FontSlot) -> &[FontFace] {
386 self.overrides
387 .iter()
388 .find(|o| o.slot == slot)
389 .map_or(&[], |o| o.faces())
390 }
391
392 /// The `@font-face` rules: the two house faces, then each override's.
393 pub fn font_face_css(&self) -> String {
394 let base = self.base_url.trim_end_matches('/');
395 let mut out = font_face_css(base);
396 for ov in &self.overrides {
397 for face in &ov.faces {
398 out.push_str(&face.css(base));
399 }
400 }
401 out
402 }
403
404 /// The resolved tokens as CSS declarations, no selector.
405 pub fn css_declarations(&self) -> String {
406 use std::fmt::Write as _;
407
408 let mut out = String::new();
409 for slot in FontSlot::ALL {
410 if let Some(stack) = self.resolve(slot) {
411 let _ = writeln!(out, " --{}: {stack};", slot.token());
412 }
413 }
414 out
415 }
416
417 /// The resolved tokens as a `:root { … }` block.
418 pub fn css_vars(&self) -> String {
419 format!(":root {{\n{}}}\n", self.css_declarations())
420 }
421
422 /// Faces then tokens, in the order a stylesheet wants them.
423 pub fn css(&self) -> String {
424 format!("{}{}", self.font_face_css(), self.css_vars())
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 #[allow(unused_imports)]
432 use crate::{ThemeDirs, ThemeId, Token};
433
434 // ---- typography, layer 0 ----
435
436 /// The live case: MNW's Young Serif, which reached the page through a
437 /// hand-maintained `@font-face` and a `--font-heading` nothing else knew
438 /// about.
439 fn young_serif() -> FontOverride {
440 FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
441 .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"]))
442 }
443
444 #[test]
445 fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() {
446 let t = Typography::house("/static/fonts");
447 assert_eq!(t.font_face_css(), font_face_css("/static/fonts"));
448 assert_eq!(t.css_vars(), typography_css_vars());
449 }
450
451 #[test]
452 fn an_unoverridden_display_slot_defines_no_token_at_all() {
453 // Not "defined empty": undefined, so the consumer's own fallback in
454 // `var(--font-display, …)` renders. The MNW embeds depend on it.
455 let t = Typography::house("fonts");
456 assert!(!t.css_vars().contains("--font-display"));
457 assert_eq!(t.resolve(FontSlot::Display), None);
458 assert_eq!(t.css_vars().matches("--font-").count(), 2);
459 }
460
461 #[test]
462 fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() {
463 let t = Typography::house("/static/fonts").with_override(young_serif());
464
465 assert!(
466 t.css_vars()
467 .contains(" --font-display: \"Young Serif\", serif;\n")
468 );
469 assert!(
470 t.css_vars()
471 .contains(" --font-mono: \"Quasi Mono\", monospace;\n")
472 );
473 assert!(
474 t.css_vars()
475 .contains(" --font-sans: \"Quasi Body\", sans-serif;\n")
476 );
477 assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif"));
478
479 let faces = t.font_face_css();
480 assert_eq!(faces.matches("@font-face").count(), 3);
481 assert!(faces.contains("font-family: \"Young Serif\";"));
482 assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")"));
483 assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
484
485 // The house faces still come first, so a product face never shadows a
486 // slot it did not claim.
487 assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap());
488 }
489
490 #[test]
491 fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() {
492 // Nobody wants this today. A layer that only permits overriding the
493 // slot nobody describes is the exemption restated, not a layer.
494 let t = Typography::house("fonts").with_override(FontOverride::new(
495 FontSlot::Mono,
496 "\"Departure Mono\", monospace",
497 ));
498
499 assert!(
500 t.css_vars()
501 .contains(" --font-mono: \"Departure Mono\", monospace;\n")
502 );
503 assert!(!t.css_vars().contains("Quasi Mono"));
504 assert_eq!(t.css_vars().matches("--font-").count(), 2);
505 }
506
507 #[test]
508 #[should_panic(expected = "is not a font-family list")]
509 fn a_stack_that_is_not_a_family_list_cannot_reach_the_stylesheet() {
510 let _ = FontOverride::new(FontSlot::Display, "\"Reglo\", serif; } body { x: y");
511 }
512
513 #[test]
514 fn a_stack_is_emitted_verbatim_when_it_is_a_family_list() {
515 let t = Typography::house("fonts").with_override(FontOverride::new(
516 FontSlot::Display,
517 "system-ui, sans-serif",
518 ));
519 assert!(
520 t.css_declarations()
521 .contains(" --font-display: system-ui, sans-serif;\n")
522 );
523 }
524
525 #[test]
526 #[should_panic(expected = "--font-display is overridden twice")]
527 fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() {
528 let _ = Typography::house("fonts")
529 .with_override(young_serif())
530 .with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif"));
531 }
532
533 #[test]
534 fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() {
535 let t = Typography::house("/static/fonts").with_override(
536 FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face(
537 FontFace::new(
538 "Reglo",
539 ["Reglo-Bold.woff2", "https://cdn.example/reglo.woff2"],
540 )
541 .with_weight("700"),
542 ),
543 );
544 let faces = t.font_face_css();
545 assert!(faces.contains("url(\"/static/fonts/Reglo-Bold.woff2\")"));
546 assert!(faces.contains("url(\"https://cdn.example/reglo.woff2\")"));
547 assert!(faces.contains(" font-weight: 700;\n"));
548 }
549
550 #[test]
551 fn the_house_tier_renders_byte_for_byte_what_the_format_string_wrote() {
552 // The house faces became `FontFace` values so they could be read as
553 // well as emitted. Nothing about the sheet was meant to move, and this
554 // is the whole of that claim: the literal the format string produced.
555 let expected = concat!(
556 "@font-face {\n",
557 " font-family: \"Quasi Mono\";\n",
558 " src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");\n",
559 " font-weight: 200 800;\n",
560 " font-style: normal;\n",
561 " font-display: optional;\n",
562 "}\n\n",
563 "@font-face {\n",
564 " font-family: \"Quasi Body\";\n",
565 " src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");\n",
566 " font-weight: 200 800;\n",
567 " font-style: normal;\n",
568 " font-display: optional;\n",
569 "}\n\n",
570 );
571 assert_eq!(font_face_css("/static/fonts"), expected);
572 }
573
574 #[test]
575 fn a_house_slot_names_the_same_family_in_its_stack_and_in_its_face() {
576 // The family is spelled once as a bare name and once inside a CSS
577 // stack, because a stack cannot be built from a const at compile time.
578 // A face whose family is not the one the stack names loads and is
579 // never asked for.
580 for (slot, family) in [
581 (FontSlot::Mono, HOUSE_MONO_FAMILY),
582 (FontSlot::Sans, HOUSE_SANS_FAMILY),
583 ] {
584 let face = slot.house_face().expect("a house slot has a house face");
585 assert_eq!(face.family(), family);
586 assert!(
587 slot.house_default()
588 .unwrap()
589 .starts_with(&format!("\"{family}\""))
590 );
591 }
592 }
593
594 #[test]
595 fn the_brand_tier_has_no_house_face_the_way_it_has_no_house_stack() {
596 assert!(FontSlot::Display.house_face().is_none());
597 assert!(FontSlot::Display.house_default().is_none());
598 }
599
600 #[test]
601 fn a_face_loading_renderer_reads_the_family_and_the_source_off_the_layer() {
602 // The egui case, which has no stylesheet in the path at all: the
603 // renderer registers the file under a name, and the name has to be
604 // the one the stack spells or the two halves drift.
605 let t = Typography::house("fonts").with_override(
606 FontOverride::new(FontSlot::Display, "\"RecursiveMono\", monospace").with_face(
607 FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).with_weight("700"),
608 ),
609 );
610
611 let [face] = t.faces(FontSlot::Display) else {
612 panic!("the display slot ships exactly one face");
613 };
614 assert_eq!(face.family(), "RecursiveMono");
615 assert_eq!(face.sources(), ["RecursiveMonoLnrSt-Bold.ttf"]);
616 assert!(
617 t.resolve(FontSlot::Display)
618 .unwrap()
619 .contains(face.family())
620 );
621 }
622
623 #[test]
624 fn a_weight_and_style_are_readable_now_that_the_builders_are_not_using_the_names() {
625 let bold = FontFace::new("Reglo", ["Reglo-Bold.woff2"]).with_weight("700");
626 assert_eq!(bold.weight(), Some("700"));
627 assert_eq!(
628 bold.style(),
629 None,
630 "unset means normal, not a stated normal"
631 );
632
633 let italic = FontFace::new("Odd", ["odd.woff2"]).with_style("italic");
634 assert_eq!(italic.weight(), None);
635 assert_eq!(italic.style(), Some("italic"));
636 }
637
638 #[test]
639 fn the_house_faces_state_the_variable_range_a_direct_loader_has_to_name() {
640 // The trap this closes: a variable face's own default instance is
641 // whatever the base shipped, which for these is ExtraLight. A loader
642 // that does not name a weight gets that and nothing says so.
643 for slot in [FontSlot::Mono, FontSlot::Sans] {
644 let face = slot.house_face().unwrap();
645 assert_eq!(face.weight(), Some(HOUSE_WEIGHT_RANGE));
646 assert_eq!(face.style(), Some("normal"));
647 }
648 }
649
650 #[test]
651 fn a_source_is_read_back_unresolved_because_only_the_css_wants_a_url() {
652 let t = Typography::house("/static/fonts").with_override(young_serif());
653 assert_eq!(
654 t.faces(FontSlot::Display)[0].sources(),
655 ["ysrf.woff2", "ysrf.ttf"]
656 );
657 // The same face, joined to the base, in the sheet.
658 assert!(
659 t.font_face_css()
660 .contains("url(\"/static/fonts/ysrf.woff2\")")
661 );
662 }
663
664 #[test]
665 fn a_slot_nobody_overrode_ships_no_faces_including_the_house_two() {
666 let t = Typography::house("fonts").with_override(young_serif());
667 assert!(t.faces(FontSlot::Mono).is_empty());
668 assert!(t.faces(FontSlot::Sans).is_empty());
669 assert_eq!(t.faces(FontSlot::Display).len(), 1);
670 }
671
672 #[test]
673 fn an_unrecognised_extension_gets_no_format_hint_rather_than_a_guessed_one() {
674 let t = Typography::house("fonts").with_override(
675 FontOverride::new(FontSlot::Display, "\"Odd\", serif")
676 .with_face(FontFace::new("Odd", ["odd.eot"])),
677 );
678 assert!(t.font_face_css().contains("url(\"fonts/odd.eot\");"));
679 assert!(!t.font_face_css().contains("format(\"eot\")"));
680 }
681
682 #[test]
683 fn css_puts_the_faces_before_the_tokens_that_name_them() {
684 let t = Typography::house("fonts").with_override(young_serif());
685 let css = t.css();
686 assert!(css.starts_with("@font-face"));
687 assert!(css.find("@font-face").unwrap() < css.find(":root").unwrap());
688 }
689}