Skip to main content

max / makeover

24.8 KB · 652 lines History Blame Raw
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
48 use 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)]
55 use 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)]
64 pub 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
73 impl 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)]
135 pub struct FontFace {
136 family: String,
137 sources: Vec<String>,
138 weight: Option<String>,
139 style: Option<String>,
140 }
141
142 impl 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.
248 fn 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)]
260 pub struct FontOverride {
261 slot: FontSlot,
262 stack: String,
263 faces: Vec<FontFace>,
264 }
265
266 impl 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)]
312 pub struct Typography {
313 base_url: String,
314 overrides: Vec<FontOverride>,
315 }
316
317 impl 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)]
411 mod 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 }
652