makeover 3.3.1

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
//! 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\")"));
    }
}