Skip to main content

max / makeover-geometry

Add the type axis: roles that name what text is, sizes that follow
Author: Max Johnson <me@maxj.phd> · 2026-08-02 21:08 UTC
Signed with PGP, not checked
Commit: ad10f428e143146b40833cd48f7440996a074803
Parent: e241d29
3 files changed, +224 insertions, -3 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-geometry"
3 - version = "0.6.0"
3 + version = "0.6.1"
4 4 edition = "2024"
5 5 description = "The invariant half of the make-family design system: relational spacing, radius, border width and type scale. Geometry never varies by theme, which is why it does not live in makeover."
6 6 license = "MIT"
M README.md +27
@@ -30,6 +30,33 @@
30 30 such as an optical nudge inside a badge. Reaching for it is a smell worth a
31 31 second look.
32 32
33 + ## Type, named the same way
34 +
35 + The type axis makes the same move. `Text` names what a piece of text is and the
36 + size follows:
37 +
38 + | Role | What it is | At the default base |
39 + |---|---|---|
40 + | `text-fine` | timestamps, badges, legal lines | 12px |
41 + | `text-note` | metadata, table cells, captions, form help | 14px |
42 + | `text-body` | running copy | 16px |
43 + | `text-lead` | emphasised copy, card titles | 18px |
44 + | `text-subhead` | the third heading level | 20px |
45 + | `text-head` | section headings, the second level | 24px |
46 + | `text-title` | the page's own title | 32px |
47 + | `text-display` | display copy, above the hierarchy | 40px |
48 + | `text-hero` | a landing hero, at most one per page | 48px |
49 +
50 + Whether a caption should be 13px or 14px cannot be reviewed. Whether a piece of
51 + text is a caption can.
52 +
53 + Nothing below 12px. A size under that is a legibility problem rather than a
54 + tier, and text that should recede can recede by colour or weight instead.
55 +
56 + Type does not shift on touch. Density is a claim about the contact patch, and
57 + text is not a tap target; the reader's own root font size is already the knob,
58 + and it moves this whole ramp.
59 +
33 60 ## Ratios, not pixel counts
34 61
35 62 The deliberate departure from the HIG, which is written in hard device pixels
M src/lib.rs +196 -2
@@ -39,6 +39,21 @@
39 39 //! instead of fighting it, an accessibility setting becomes one value rather
40 40 //! than a sweep, and the scale means the same thing at any display density.
41 41 //!
42 + //! # Type is relational too
43 + //!
44 + //! [`Text`] names what a piece of text is — body, note, head — and the size
45 + //! follows, exactly as [`Gap`] names what is being separated. It is the same
46 + //! argument: whether a caption should be 13px or 14px cannot be reviewed,
47 + //! whether a piece of text is a caption can.
48 + //!
49 + //! Type has its own rungs rather than reusing [`Step`], because the spacing
50 + //! scale is eighths of the base to match the HIG's distances and a type ramp
51 + //! wants different fractions. What the two axes share is the base, so the
52 + //! reader's root font size moves the text and the space around it together.
53 + //!
54 + //! Unlike spacing, type does not move with [`Density`]. The reason is in
55 + //! [`Text`], and it is the same one that keeps shells out of the touch preset.
56 + //!
42 57 //! # Density presets
43 58 //!
44 59 //! Naming relationships instead of sizes is what makes a density preset
@@ -636,6 +651,125 @@
636 651 }
637 652 }
638 653
654 + /// What a piece of text is, from which its size follows.
655 + ///
656 + /// The type axis, and the same move [`Gap`] makes on the spacing axis: name
657 + /// the role and let the size follow, so the choice is reviewable. Whether a
658 + /// caption should be 13px or 14px is unanswerable in isolation; whether a
659 + /// piece of text is a caption is not.
660 + ///
661 + /// # Why the ratios are their own ramp
662 + ///
663 + /// Type does not reuse [`Step`]. The spacing scale is built in eighths of the
664 + /// base because that is what the HIG's distances land on, and a type ramp
665 + /// needs different rungs — 7/8 and 9/8 sit either side of body copy and have
666 + /// no spacing meaning at all, while `Hair` and `Tight` are far below any
667 + /// legible size. Sharing the enum would have meant widening it for rungs
668 + /// spacing never asks for.
669 + ///
670 + /// What is shared is the thing that matters: every rung here is a [`Ratio`]
671 + /// of `--geometry-base`, so text tracks the user's chosen root size exactly
672 + /// as spacing does, and one knob still moves the whole design.
673 + ///
674 + /// # Why the floor is 3/4
675 + ///
676 + /// Twelve pixels at the default base, and nothing below it. Sizes under that
677 + /// are a legibility problem rather than a tier, and a scale that offers one
678 + /// is a scale that invites it. Text that needs to recede should recede by
679 + /// colour or weight, which cost no legibility.
680 + ///
681 + /// # Why type does not shift on touch
682 + ///
683 + /// [`Density`] is a claim about the contact patch and nothing else, and text
684 + /// is not a tap target. The reader's own root font size is already the knob
685 + /// for how large text should be, and it already moves this whole ramp. So the
686 + /// type axis is density-invariant, and a phone gets the same tiers a desktop
687 + /// does.
688 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
689 + pub enum Text {
690 + /// Three quarters of the base. Timestamps, badges, legal lines.
691 + Fine,
692 + /// Seven eighths of the base. Secondary text: metadata, table cells,
693 + /// captions, form help.
694 + Note,
695 + /// The base itself. Running copy, and the size everything else is read
696 + /// against.
697 + Body,
698 + /// Nine eighths of the base. Emphasised copy: intros, card titles.
699 + Lead,
700 + /// Five quarters of the base. The third heading level.
701 + Subhead,
702 + /// One and a half times the base. Section headings, the second level.
703 + Head,
704 + /// Twice the base. The page's own title, the first level.
705 + Title,
706 + /// Two and a half times the base. Display copy, above the document
707 + /// hierarchy rather than at the top of it.
708 + Display,
709 + /// Three times the base. One per page at most: a landing hero.
710 + Hero,
711 + }
712 +
713 + impl Text {
714 + /// This role's size as a fraction of the base unit.
715 + #[must_use]
716 + pub const fn ratio(self) -> Ratio {
717 + let (numerator, denominator) = match self {
718 + Self::Fine => (3, 4),
719 + Self::Note => (7, 8),
720 + Self::Body => (1, 1),
721 + Self::Lead => (9, 8),
722 + Self::Subhead => (5, 4),
723 + Self::Head => (3, 2),
724 + Self::Title => (2, 1),
725 + Self::Display => (5, 2),
726 + Self::Hero => (3, 1),
727 + };
728 + Ratio {
729 + numerator,
730 + denominator,
731 + }
732 + }
733 +
734 + /// Size in CSS pixels at the default base.
735 + #[must_use]
736 + pub const fn px(self) -> u16 {
737 + self.ratio().px_at(DEFAULT_BASE_PX)
738 + }
739 +
740 + /// The CSS custom-property name, without the leading `--`.
741 + #[must_use]
742 + pub const fn token(self) -> &'static str {
743 + match self {
744 + Self::Fine => "text-fine",
745 + Self::Note => "text-note",
746 + Self::Body => "text-body",
747 + Self::Lead => "text-lead",
748 + Self::Subhead => "text-subhead",
749 + Self::Head => "text-head",
750 + Self::Title => "text-title",
751 + Self::Display => "text-display",
752 + Self::Hero => "text-hero",
753 + }
754 + }
755 +
756 + /// Every role, smallest first.
757 + #[must_use]
758 + pub const fn all() -> [Self; 9] {
759 + [
760 + Self::Fine,
761 + Self::Note,
762 + Self::Body,
763 + Self::Lead,
764 + Self::Subhead,
765 + Self::Head,
766 + Self::Title,
767 + Self::Display,
768 + Self::Hero,
769 + ]
770 + }
771 + }
772 +
639 773 /// Emit the base unit and the raw scale as CSS declarations, no selector.
640 774 ///
641 775 /// Density-invariant: the steps are the vocabulary, and only which step a
@@ -657,6 +791,22 @@
657 791 out
658 792 }
659 793
794 + /// Emit the type axis as CSS declarations, no selector.
795 + ///
796 + /// Takes no [`Density`]: text is not a tap target, so the contact patch has no
797 + /// opinion on it. See [`Text`] for the derivation.
798 + #[must_use]
799 + pub fn text_css_declarations() -> String {
800 + let mut out = String::new();
801 + out.push_str(" /* Type. Named for what the text is; the size follows.\n");
802 + out.push_str(" Ratios of the base, so text tracks the reader's own\n");
803 + out.push_str(" root size. Density-invariant: text is not a target. */\n");
804 + for text in Text::all() {
805 + let _ = writeln!(out, " --{}: {};", text.token(), text.ratio().css());
806 + }
807 + out
808 + }
809 +
660 810 /// Emit the relational layer for one density as CSS declarations, no selector.
661 811 ///
662 812 /// Gaps reference their step rather than repeating a value, so the scale has
@@ -729,12 +879,16 @@
729 879 /// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
730 880 /// constant, so a web consumer should bake it in at build time rather than
731 881 /// apply it from JS on every load.
882 + ///
883 + /// The density argument reaches the gaps only. The scale and the type ramp are
884 + /// the same at every density, which is why neither takes one.
732 885 #[must_use]
733 886 pub fn geometry_css_vars(density: Density) -> String {
734 887 format!(
735 - ":root {{\n{}\n{}}}\n",
888 + ":root {{\n{}\n{}\n{}}}\n",
736 889 scale_css_declarations(),
737 - gap_css_declarations(density)
890 + gap_css_declarations(density),
891 + text_css_declarations()
738 892 )
739 893 }
740 894
@@ -831,6 +985,45 @@
831 985 assert!(css.contains("--gap-peer"));
832 986 }
833 987
988 + #[test]
989 + fn the_type_ramp_ascends_and_never_repeats_a_size() {
990 + // A tier that resolves to the same size as its neighbour is a name
991 + // with no distinction behind it, which is how a scale grows rungs
992 + // nobody can choose between.
993 + let sizes: Vec<u16> = Text::all().iter().map(|t| t.px()).collect();
994 + assert!(sizes.windows(2).all(|w| w[0] < w[1]), "{sizes:?}");
995 + assert_eq!(sizes, vec![12, 14, 16, 18, 20, 24, 32, 40, 48]);
996 + }
997 +
998 + #[test]
999 + fn the_type_ramp_has_a_legibility_floor() {
1000 + // Nothing under 12px at the default base. Text that should recede
1001 + // recedes by colour or weight, not by shrinking out of legibility.
1002 + assert_eq!(Text::Fine.px(), 12);
1003 + assert!(Text::all().iter().all(|t| t.px() >= 12));
1004 + }
1005 +
1006 + #[test]
1007 + fn type_does_not_move_with_density() {
1008 + // Density is a claim about the contact patch, and text is not a
1009 + // target. A --text-* inside the touch override means that argument
1010 + // was lost somewhere.
1011 + let css = density_css(Some(".ui-mode-mobile"));
1012 + let root_end = css.find("@media").expect("a touch block");
1013 + assert!(css[..root_end].contains("--text-body"));
1014 + assert!(!css[root_end..].contains("--text-"), "{}", &css[root_end..]);
1015 + }
1016 +
1017 + #[test]
1018 + fn every_type_token_scales_from_the_one_base() {
1019 + // A literal rem here would be a size that stops tracking the reader's
1020 + // root font size, which is the whole point of the base.
1021 + for text in Text::all() {
1022 + let css = text.ratio().css();
1023 + assert!(css.contains(BASE_TOKEN), "{}: {css}", text.token());
1024 + }
1025 + }
1026 +
834 1027 #[test]
835 1028 fn wrapping_leaves_no_trailing_whitespace_on_blank_lines() {
836 1029 // A formatter strips these later and calls it a diff.
@@ -1120,6 +1313,7 @@
1120 1313 fn tokens_are_unique() {
1121 1314 let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
1122 1315 names.extend(Gap::all().iter().map(|g| g.token()));
1316 + names.extend(Text::all().iter().map(|t| t.token()));
1123 1317 let count = names.len();
1124 1318 names.sort_unstable();
1125 1319 names.dedup();