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