Skip to main content

max / makeover

7.5 KB · 168 lines History Blame Raw
1 //! Typography — layer 1 of the house font model.
2 //!
3 //! Wiki `typography-standard`. The model is three layers: an app override, the
4 //! house default, then a system generic, and this is the middle one. Two needs,
5 //! two names, and no others in the suite:
6 //!
7 //! ```text
8 //! --font-mono Quasi Mono -> monospace
9 //! --font-sans Quasi Body -> sans-serif
10 //! ```
11 //!
12 //! Both are cut by `quasi-type` from the Atkinson Hyperlegible superfamily plus
13 //! the house glyph set. This crate does not cut them and cannot: quasi-type is
14 //! `publish = false` and makeover is on crates.io, so the cut lives in each
15 //! consumer's own build script (`quasi_type::cut`, taken as a git dependency,
16 //! the way `shop-font` does it). What lives here is the vocabulary, which is
17 //! the half that was scattered.
18 //!
19 //! Font is not a theme's business and none of this is themeable. A theme
20 //! declares colour by role; nothing in a theme file names a face, and the two
21 //! tokens below are the same in every theme. That is why they are constants
22 //! rather than another section of `SemanticTokens`, and why they belong in a
23 //! stylesheet generated once at build time rather than in the block that gets
24 //! re-injected on a theme switch.
25 //!
26 //! The brand/display tier is out of scope, per product and by decision: Young
27 //! Serif on MNW, Reglo in GoingsOn, Departure Mono on Alloy, audiofiles' logo
28 //! face. No renderer emits them and no described screen resolves a token to
29 //! one, so they keep their own `font-family` until the app-override layer
30 //! lands and gives them a place to be declared.
31
32 use crate::FontSlot;
33
34 /// The mono slot: code, data, identifiers, cell grids, anything monospaced.
35 pub const FONT_MONO: &str = "\"Quasi Mono\", monospace";
36
37 /// The body / UI slot. Everything that is not the mono slot or brand tier.
38 pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif";
39
40 /// The family name inside [`FONT_MONO`], on its own, for a consumer that needs
41 /// the name rather than the stack. A test asserts the two agree.
42 pub const HOUSE_MONO_FAMILY: &str = "Quasi Mono";
43
44 /// The family name inside [`FONT_SANS`]. See [`HOUSE_MONO_FAMILY`].
45 pub const HOUSE_SANS_FAMILY: &str = "Quasi Body";
46
47 /// The weight range both house faces carry.
48 ///
49 /// They are variable, `wght` 200-800, and a declaration that omits the range
50 /// makes every weight resolve to the file's default instance — which is
51 /// ExtraLight, because a cut keeps its base's default.
52 pub const HOUSE_WEIGHT_RANGE: &str = "200 800";
53
54 /// Filename a consumer writes the cut mono face to, under its own font URL.
55 ///
56 /// `quasi-type` writes `QuasiMono[wght].woff2`, naming the variable axis the
57 /// way a font tool expects. Those brackets have to be percent-encoded to
58 /// survive a URL and are a bug waiting to be written, so the web copy takes a
59 /// plain name and the two places that have to agree — the build script that
60 /// writes the file and the `@font-face` that fetches it — agree through this
61 /// constant rather than by both spelling it out.
62 pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2";
63
64 /// Filename a consumer writes the cut body face to. See [`WEBFONT_MONO_FILE`].
65 pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2";
66
67 /// The house font tokens as CSS declarations (no selector), for a caller that
68 /// is composing its own block.
69 pub fn typography_css_declarations() -> String {
70 format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n")
71 }
72
73 /// The house font tokens as a `:root { … }` block.
74 ///
75 /// Inlined by surfaces that cannot link a stylesheet — the MNW embeds are the
76 /// live case — and written to a file by everything else, through
77 /// `makeover_build::typography_css`.
78 pub fn typography_css_vars() -> String {
79 format!(":root {{\n{}}}\n", typography_css_declarations())
80 }
81
82 /// The `@font-face` rules for both slots, fetching from `base_url`.
83 ///
84 /// `base_url` is the directory the consumer serves its fonts from, without a
85 /// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri
86 /// frontend loading relative to its index.
87 ///
88 /// # `font-weight: 200 800`, which is the part that bites
89 ///
90 /// Both faces are variable over `wght` 200-800 in one file, and the mono
91 /// face's **default instance is ExtraLight** — that is upstream Atkinson's
92 /// default and the cut keeps the axis rather than pinning a master, so a
93 /// consumer that loads the file and takes what it opens at draws its whole UI
94 /// at 200. Declaring the range here is what makes the browser resolve `normal`
95 /// to 400 and `bold` to 700 instead. shop hit the same trap from the other
96 /// side and names `wght` 400 explicitly in its shaper; this is the web's
97 /// version of that fix, stated once for every consumer.
98 ///
99 /// `font-display: swap` on both: the faces are 31KB and 50KB, they are cached
100 /// hard after the first paint, and a flash of the fallback beats invisible
101 /// text either way.
102 pub fn font_face_css(base_url: &str) -> String {
103 // Rendered from the same `FontFace` a product override uses, rather than
104 // written out here a second time. It used to be a format string, which is
105 // why the house tier could be emitted and not read.
106 let base = base_url.trim_end_matches('/');
107 FontSlot::ALL
108 .iter()
109 .filter_map(|slot| slot.house_face())
110 .map(|face| face.css(base))
111 .collect()
112 }
113
114 #[cfg(test)]
115 mod tests {
116 use super::*;
117
118 // ---- typography ----
119
120 #[test]
121 fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() {
122 let css = typography_css_vars();
123 assert!(css.starts_with(":root {\n"));
124 assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n"));
125 assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n"));
126
127 // Layer 2 is one hop and no further. A third entry in either stack is
128 // the shape the standard exists to delete: a chain nobody can predict
129 // the metrics of, which is what `--font-sans: -apple-system,
130 // BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps.
131 for stack in [FONT_MONO, FONT_SANS] {
132 assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop");
133 }
134
135 // Two tokens, and no others. `--font-body`, `--font-heading` and
136 // `--font-display` are gone or out of scope; a token appearing here
137 // is a fifth answer to a question that has two.
138 assert_eq!(css.matches("--font-").count(), 2);
139 }
140
141 #[test]
142 fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() {
143 let css = font_face_css("/static/fonts");
144
145 assert_eq!(css.matches("@font-face").count(), 2);
146 assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");"));
147 assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");"));
148
149 // The trap. Atkinson Hyperlegible Mono's default instance is
150 // ExtraLight and the cut keeps the axis, so a `@font-face` that omits
151 // the range draws the whole UI at 200.
152 assert_eq!(css.matches("font-weight: 200 800;").count(), 2);
153
154 // The families have to be exactly what the tokens ask for, or the
155 // stack falls through to the generic and the face is dead weight.
156 for family in [FONT_MONO, FONT_SANS] {
157 let quoted = family.split(',').next().unwrap();
158 assert!(css.contains(&format!("font-family: {quoted};")));
159 }
160 }
161
162 #[test]
163 fn a_trailing_slash_on_the_base_url_does_not_double_it() {
164 assert_eq!(font_face_css("fonts/"), font_face_css("fonts"));
165 assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")"));
166 }
167 }
168