Skip to main content

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}