| 936 |
936 |
|
format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
|
| 937 |
937 |
|
}
|
| 938 |
938 |
|
|
|
939 |
+ |
// ============================================================================
|
|
940 |
+ |
// Typography — layer 1 of the house font model.
|
|
941 |
+ |
//
|
|
942 |
+ |
// Wiki `typography-standard`. The model is three layers: an app override, the
|
|
943 |
+ |
// house default, then a system generic, and this is the middle one. Two needs,
|
|
944 |
+ |
// two names, and no others in the suite:
|
|
945 |
+ |
//
|
|
946 |
+ |
// --font-mono Quasi Mono -> monospace
|
|
947 |
+ |
// --font-sans Quasi Body -> sans-serif
|
|
948 |
+ |
//
|
|
949 |
+ |
// Both are cut by `quasi-type` from the Atkinson Hyperlegible superfamily plus
|
|
950 |
+ |
// the house glyph set. This crate does not cut them and cannot: quasi-type is
|
|
951 |
+ |
// `publish = false` and makeover is on crates.io, so the cut lives in each
|
|
952 |
+ |
// consumer's own build script (`quasi_type::cut`, taken as a git dependency,
|
|
953 |
+ |
// the way `shop-font` does it). What lives here is the vocabulary, which is
|
|
954 |
+ |
// the half that was scattered.
|
|
955 |
+ |
//
|
|
956 |
+ |
// Font is not a theme's business and none of this is themeable. A theme
|
|
957 |
+ |
// declares colour by role; nothing in a theme file names a face, and the two
|
|
958 |
+ |
// tokens below are the same in every theme. That is why they are constants
|
|
959 |
+ |
// rather than another section of `SemanticTokens`, and why they belong in a
|
|
960 |
+ |
// stylesheet generated once at build time rather than in the block that gets
|
|
961 |
+ |
// re-injected on a theme switch.
|
|
962 |
+ |
//
|
|
963 |
+ |
// The brand/display tier is out of scope, per product and by decision: Young
|
|
964 |
+ |
// Serif on MNW, Reglo in GoingsOn, Departure Mono on Alloy, audiofiles' logo
|
|
965 |
+ |
// face. No renderer emits them and no described screen resolves a token to
|
|
966 |
+ |
// one, so they keep their own `font-family` until the app-override layer
|
|
967 |
+ |
// lands and gives them a place to be declared.
|
|
968 |
+ |
// ============================================================================
|
|
969 |
+ |
|
|
970 |
+ |
/// The mono slot: code, data, identifiers, cell grids, anything monospaced.
|
|
971 |
+ |
pub const FONT_MONO: &str = "\"Quasi Mono\", monospace";
|
|
972 |
+ |
|
|
973 |
+ |
/// The body / UI slot. Everything that is not the mono slot or brand tier.
|
|
974 |
+ |
pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif";
|
|
975 |
+ |
|
|
976 |
+ |
/// Filename a consumer writes the cut mono face to, under its own font URL.
|
|
977 |
+ |
///
|
|
978 |
+ |
/// `quasi-type` writes `QuasiMono[wght].woff2`, naming the variable axis the
|
|
979 |
+ |
/// way a font tool expects. Those brackets have to be percent-encoded to
|
|
980 |
+ |
/// survive a URL and are a bug waiting to be written, so the web copy takes a
|
|
981 |
+ |
/// plain name and the two places that have to agree — the build script that
|
|
982 |
+ |
/// writes the file and the `@font-face` that fetches it — agree through this
|
|
983 |
+ |
/// constant rather than by both spelling it out.
|
|
984 |
+ |
pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2";
|
|
985 |
+ |
|
|
986 |
+ |
/// Filename a consumer writes the cut body face to. See [`WEBFONT_MONO_FILE`].
|
|
987 |
+ |
pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2";
|
|
988 |
+ |
|
|
989 |
+ |
/// The house font tokens as CSS declarations (no selector), for a caller that
|
|
990 |
+ |
/// is composing its own block.
|
|
991 |
+ |
pub fn typography_css_declarations() -> String {
|
|
992 |
+ |
format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n")
|
|
993 |
+ |
}
|
|
994 |
+ |
|
|
995 |
+ |
/// The house font tokens as a `:root { … }` block.
|
|
996 |
+ |
///
|
|
997 |
+ |
/// Inlined by surfaces that cannot link a stylesheet — the MNW embeds are the
|
|
998 |
+ |
/// live case — and written to a file by everything else, through
|
|
999 |
+ |
/// `makeover_build::typography_css`.
|
|
1000 |
+ |
pub fn typography_css_vars() -> String {
|
|
1001 |
+ |
format!(":root {{\n{}}}\n", typography_css_declarations())
|
|
1002 |
+ |
}
|
|
1003 |
+ |
|
|
1004 |
+ |
/// The `@font-face` rules for both slots, fetching from `base_url`.
|
|
1005 |
+ |
///
|
|
1006 |
+ |
/// `base_url` is the directory the consumer serves its fonts from, without a
|
|
1007 |
+ |
/// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri
|
|
1008 |
+ |
/// frontend loading relative to its index.
|
|
1009 |
+ |
///
|
|
1010 |
+ |
/// # `font-weight: 200 800`, which is the part that bites
|
|
1011 |
+ |
///
|
|
1012 |
+ |
/// Both faces are variable over `wght` 200-800 in one file, and the mono
|
|
1013 |
+ |
/// face's **default instance is ExtraLight** — that is upstream Atkinson's
|
|
1014 |
+ |
/// default and the cut keeps the axis rather than pinning a master, so a
|
|
1015 |
+ |
/// consumer that loads the file and takes what it opens at draws its whole UI
|
|
1016 |
+ |
/// at 200. Declaring the range here is what makes the browser resolve `normal`
|
|
1017 |
+ |
/// to 400 and `bold` to 700 instead. shop hit the same trap from the other
|
|
1018 |
+ |
/// side and names `wght` 400 explicitly in its shaper; this is the web's
|
|
1019 |
+ |
/// version of that fix, stated once for every consumer.
|
|
1020 |
+ |
///
|
|
1021 |
+ |
/// `font-display: swap` on both: the faces are 31KB and 50KB, they are cached
|
|
1022 |
+ |
/// hard after the first paint, and a flash of the fallback beats invisible
|
|
1023 |
+ |
/// text either way.
|
|
1024 |
+ |
pub fn font_face_css(base_url: &str) -> String {
|
|
1025 |
+ |
use std::fmt::Write as _;
|
|
1026 |
+ |
|
|
1027 |
+ |
let base = base_url.trim_end_matches('/');
|
|
1028 |
+ |
let mut out = String::new();
|
|
1029 |
+ |
for (family, file) in [
|
|
1030 |
+ |
("Quasi Mono", WEBFONT_MONO_FILE),
|
|
1031 |
+ |
("Quasi Body", WEBFONT_SANS_FILE),
|
|
1032 |
+ |
] {
|
|
1033 |
+ |
let _ = write!(
|
|
1034 |
+ |
out,
|
|
1035 |
+ |
"@font-face {{\n \
|
|
1036 |
+ |
font-family: \"{family}\";\n \
|
|
1037 |
+ |
src: url(\"{base}/{file}\") format(\"woff2\");\n \
|
|
1038 |
+ |
font-weight: 200 800;\n \
|
|
1039 |
+ |
font-style: normal;\n \
|
|
1040 |
+ |
font-display: swap;\n\
|
|
1041 |
+ |
}}\n\n"
|
|
1042 |
+ |
);
|
|
1043 |
+ |
}
|
|
1044 |
+ |
out
|
|
1045 |
+ |
}
|
|
1046 |
+ |
|
| 939 |
1047 |
|
// ============================================================================
|
| 940 |
1048 |
|
// Loading / parsing
|
| 941 |
1049 |
|
// ============================================================================
|
| 2547 |
2655 |
|
assert!(css.trim_end().ends_with('}'));
|
| 2548 |
2656 |
|
}
|
| 2549 |
2657 |
|
|
|
2658 |
+ |
// ---- typography ----
|
|
2659 |
+ |
|
|
2660 |
+ |
#[test]
|
|
2661 |
+ |
fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() {
|
|
2662 |
+ |
let css = typography_css_vars();
|
|
2663 |
+ |
assert!(css.starts_with(":root {\n"));
|
|
2664 |
+ |
assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n"));
|
|
2665 |
+ |
assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n"));
|
|
2666 |
+ |
|
|
2667 |
+ |
// Layer 2 is one hop and no further. A third entry in either stack is
|
|
2668 |
+ |
// the shape the standard exists to delete: a chain nobody can predict
|
|
2669 |
+ |
// the metrics of, which is what `--font-sans: -apple-system,
|
|
2670 |
+ |
// BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps.
|
|
2671 |
+ |
for stack in [FONT_MONO, FONT_SANS] {
|
|
2672 |
+ |
assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop");
|
|
2673 |
+ |
}
|
|
2674 |
+ |
|
|
2675 |
+ |
// Two tokens, and no others. `--font-body`, `--font-heading` and
|
|
2676 |
+ |
// `--font-display` are gone or out of scope; a token appearing here
|
|
2677 |
+ |
// is a fifth answer to a question that has two.
|
|
2678 |
+ |
assert_eq!(css.matches("--font-").count(), 2);
|
|
2679 |
+ |
}
|
|
2680 |
+ |
|
|
2681 |
+ |
#[test]
|
|
2682 |
+ |
fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() {
|
|
2683 |
+ |
let css = font_face_css("/static/fonts");
|
|
2684 |
+ |
|
|
2685 |
+ |
assert_eq!(css.matches("@font-face").count(), 2);
|
|
2686 |
+ |
assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");"));
|
|
2687 |
+ |
assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");"));
|
|
2688 |
+ |
|
|
2689 |
+ |
// The trap. Atkinson Hyperlegible Mono's default instance is
|
|
2690 |
+ |
// ExtraLight and the cut keeps the axis, so a `@font-face` that omits
|
|
2691 |
+ |
// the range draws the whole UI at 200.
|
|
2692 |
+ |
assert_eq!(css.matches("font-weight: 200 800;").count(), 2);
|
|
2693 |
+ |
|
|
2694 |
+ |
// The families have to be exactly what the tokens ask for, or the
|
|
2695 |
+ |
// stack falls through to the generic and the face is dead weight.
|
|
2696 |
+ |
for family in [FONT_MONO, FONT_SANS] {
|
|
2697 |
+ |
let quoted = family.split(',').next().unwrap();
|
|
2698 |
+ |
assert!(css.contains(&format!("font-family: {quoted};")));
|
|
2699 |
+ |
}
|
|
2700 |
+ |
}
|
|
2701 |
+ |
|
|
2702 |
+ |
#[test]
|
|
2703 |
+ |
fn a_trailing_slash_on_the_base_url_does_not_double_it() {
|
|
2704 |
+ |
assert_eq!(font_face_css("fonts/"), font_face_css("fonts"));
|
|
2705 |
+ |
assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")"));
|
|
2706 |
+ |
}
|
|
2707 |
+ |
|
| 2550 |
2708 |
|
// ---- loading / fs ----
|
| 2551 |
2709 |
|
|
| 2552 |
2710 |
|
#[test]
|