//! Typography — layer 1 of the house font model. //! //! Wiki `typography-standard`. The model is three layers: an app override, the //! house default, then a system generic, and this is the middle one. Two needs, //! two names, and no others in the suite: //! //! ```text //! --font-mono Quasi Mono -> monospace //! --font-sans Quasi Body -> sans-serif //! ``` //! //! Both are cut by `quasi-type` from the Atkinson Hyperlegible superfamily plus //! the house glyph set. This crate does not cut them and cannot: quasi-type is //! `publish = false` and makeover is on crates.io, so the cut lives in each //! consumer's own build script (`quasi_type::cut`, taken as a git dependency, //! the way `shop-font` does it). What lives here is the vocabulary, which is //! the half that was scattered. //! //! Font is not a theme's business and none of this is themeable. A theme //! declares colour by role; nothing in a theme file names a face, and the two //! tokens below are the same in every theme. That is why they are constants //! rather than another section of `SemanticTokens`, and why they belong in a //! stylesheet generated once at build time rather than in the block that gets //! re-injected on a theme switch. //! //! The brand/display tier is out of scope, per product and by decision: Young //! Serif on MNW, Reglo in GoingsOn, Departure Mono on Alloy, audiofiles' logo //! face. No renderer emits them and no described screen resolves a token to //! one, so they keep their own `font-family` until the app-override layer //! lands and gives them a place to be declared. use crate::FontSlot; /// The mono slot: code, data, identifiers, cell grids, anything monospaced. pub const FONT_MONO: &str = "\"Quasi Mono\", monospace"; /// The body / UI slot. Everything that is not the mono slot or brand tier. pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif"; /// The family name inside [`FONT_MONO`], on its own, for a consumer that needs /// the name rather than the stack. A test asserts the two agree. pub const HOUSE_MONO_FAMILY: &str = "Quasi Mono"; /// The family name inside [`FONT_SANS`]. See [`HOUSE_MONO_FAMILY`]. pub const HOUSE_SANS_FAMILY: &str = "Quasi Body"; /// The weight range both house faces carry. /// /// They are variable, `wght` 200-800, and a declaration that omits the range /// makes every weight resolve to the file's default instance — which is /// ExtraLight, because a cut keeps its base's default. pub const HOUSE_WEIGHT_RANGE: &str = "200 800"; /// Filename a consumer writes the cut mono face to, under its own font URL. /// /// `quasi-type` writes `QuasiMono[wght].woff2`, naming the variable axis the /// way a font tool expects. Those brackets have to be percent-encoded to /// survive a URL and are a bug waiting to be written, so the web copy takes a /// plain name and the two places that have to agree — the build script that /// writes the file and the `@font-face` that fetches it — agree through this /// constant rather than by both spelling it out. pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2"; /// Filename a consumer writes the cut body face to. See [`WEBFONT_MONO_FILE`]. pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2"; /// The house font tokens as CSS declarations (no selector), for a caller that /// is composing its own block. pub fn typography_css_declarations() -> String { format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n") } /// The house font tokens as a `:root { … }` block. /// /// Inlined by surfaces that cannot link a stylesheet — the MNW embeds are the /// live case — and written to a file by everything else, through /// `makeover_build::typography_css`. pub fn typography_css_vars() -> String { format!(":root {{\n{}}}\n", typography_css_declarations()) } /// The `@font-face` rules for both slots, fetching from `base_url`. /// /// `base_url` is the directory the consumer serves its fonts from, without a /// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri /// frontend loading relative to its index. /// /// # `font-weight: 200 800`, which is the part that bites /// /// Both faces are variable over `wght` 200-800 in one file, and the mono /// face's **default instance is ExtraLight** — that is upstream Atkinson's /// default and the cut keeps the axis rather than pinning a master, so a /// consumer that loads the file and takes what it opens at draws its whole UI /// at 200. Declaring the range here is what makes the browser resolve `normal` /// to 400 and `bold` to 700 instead. shop hit the same trap from the other /// side and names `wght` 400 explicitly in its shaper; this is the web's /// version of that fix, stated once for every consumer. /// /// `font-display: swap` on both: the faces are 31KB and 50KB, they are cached /// hard after the first paint, and a flash of the fallback beats invisible /// text either way. pub fn font_face_css(base_url: &str) -> String { // Rendered from the same `FontFace` a product override uses, rather than // written out here a second time. It used to be a format string, which is // why the house tier could be emitted and not read. let base = base_url.trim_end_matches('/'); FontSlot::ALL .iter() .filter_map(|slot| slot.house_face()) .map(|face| face.css(base)) .collect() } #[cfg(test)] mod tests { use super::*; // ---- typography ---- #[test] fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() { let css = typography_css_vars(); assert!(css.starts_with(":root {\n")); assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n")); assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n")); // Layer 2 is one hop and no further. A third entry in either stack is // the shape the standard exists to delete: a chain nobody can predict // the metrics of, which is what `--font-sans: -apple-system, // BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps. for stack in [FONT_MONO, FONT_SANS] { assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop"); } // Two tokens, and no others. `--font-body`, `--font-heading` and // `--font-display` are gone or out of scope; a token appearing here // is a fifth answer to a question that has two. assert_eq!(css.matches("--font-").count(), 2); } #[test] fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() { let css = font_face_css("/static/fonts"); assert_eq!(css.matches("@font-face").count(), 2); assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");")); assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");")); // The trap. Atkinson Hyperlegible Mono's default instance is // ExtraLight and the cut keeps the axis, so a `@font-face` that omits // the range draws the whole UI at 200. assert_eq!(css.matches("font-weight: 200 800;").count(), 2); // The families have to be exactly what the tokens ask for, or the // stack falls through to the generic and the face is dead weight. for family in [FONT_MONO, FONT_SANS] { let quoted = family.split(',').next().unwrap(); assert!(css.contains(&format!("font-family: {quoted};"))); } } #[test] fn a_trailing_slash_on_the_base_url_does_not_double_it() { assert_eq!(font_face_css("fonts/"), font_face_css("fonts")); assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")")); } }