Skip to main content

max / makeover-geometry

Add the corner scale, the last axis the crate header promised
Author: Max Johnson <me@maxj.phd> · 2026-08-02 22:03 UTC
Signed with PGP, not checked
Commit: 78f03a94c41640241f482acfadbe43499305cd1b
Parent: ad10f42
3 files changed, +173 insertions, -3 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-geometry"
3 - version = "0.6.1"
3 + version = "0.6.2"
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 +22
@@ -57,6 +57,28 @@
57 57 text is not a tap target; the reader's own root font size is already the knob,
58 58 and it moves this whole ramp.
59 59
60 + ## Corners
61 +
62 + `Radius` names what the corner belongs to. Four rungs and a circle, which is
63 + the whole scale on purpose:
64 +
65 + | Rung | What it is | At the default base |
66 + |---|---|---|
67 + | `radius-square` | containers: cards, panels, dropdowns, page shells | 0 |
68 + | `radius-fine` | inline code, small badges, status chips | 2px |
69 + | `radius-control` | buttons, inputs, selects | 4px |
70 + | `radius-panel` | media covers, callout boxes, surfaces that round | 8px |
71 + | `radius-round` | a circle, whatever the element's size | 50% |
72 +
73 + Rounding says a thing is meant to be pressed, so it carries one bit of meaning
74 + and a long scale is one nobody can choose from. `square` is a rung rather than
75 + the absence of one: a container states that it is square, and a reader can tell
76 + that apart from a rule nobody wrote.
77 +
78 + `round` is the one value that is not a ratio of the base. 50% is a proportion of
79 + the element's own box, so it does not scale with `--geometry-base`, and
80 + `Radius::ratio` returns `None` for it rather than pretending otherwise.
81 +
60 82 ## Ratios, not pixel counts
61 83
62 84 The deliberate departure from the HIG, which is written in hard device pixels
M src/lib.rs +150 -2
@@ -54,6 +54,15 @@
54 54 //! Unlike spacing, type does not move with [`Density`]. The reason is in
55 55 //! [`Text`], and it is the same one that keeps shells out of the touch preset.
56 56 //!
57 + //! # Corners, same move again
58 + //!
59 + //! [`Radius`] names what the corner belongs to. The scale is deliberately the
60 + //! shortest of the three, because rounding carries one bit of meaning —
61 + //! whether the thing is meant to be pressed — and a long radius scale is one
62 + //! nobody can choose from. `Square` is a rung rather than the absence of one,
63 + //! so a container can state that it is square and a reader can tell that from
64 + //! a rule nobody wrote.
65 + //!
57 66 //! # Density presets
58 67 //!
59 68 //! Naming relationships instead of sizes is what makes a density preset
@@ -770,6 +779,109 @@
770 779 }
771 780 }
772 781
782 + /// How rounded a corner is, named for what the corner belongs to.
783 + ///
784 + /// The third axis to make the same move as [`Gap`] and [`Text`]: name the
785 + /// thing and let the value follow. Whether a corner should be 3px or 4px is
786 + /// unanswerable in isolation, and answering it once per component is how a
787 + /// stylesheet ends up with 2, 3, 4, 6, 8 and 12 all meaning "slightly
788 + /// rounded".
789 + ///
790 + /// # Rounding is an affordance
791 + ///
792 + /// The scale is deliberately short, because a corner radius carries one bit
793 + /// of meaning: whether the thing is meant to be pressed. [`Self::Square`]
794 + /// exists as a named rung rather than as the absence of a radius so that a
795 + /// container states that it is square, and a reader can tell a deliberate
796 + /// zero from a rule nobody wrote.
797 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
798 + pub enum Radius {
799 + /// No rounding. Containers: cards, panels, dropdowns, page shells.
800 + Square,
801 + /// An eighth of the base. The tightest corner still visible: inline code,
802 + /// small badges, status chips.
803 + Fine,
804 + /// A quarter of the base. Controls: buttons, inputs, selects.
805 + Control,
806 + /// Half the base. Surfaces that round rather than sit square: media
807 + /// covers, callout boxes, feature cards.
808 + Panel,
809 + /// A circle, whatever the element's size.
810 + Round,
811 + }
812 +
813 + impl Radius {
814 + /// This corner as a fraction of the base unit.
815 + ///
816 + /// [`Self::Round`] has none, and that is not an oversight: 50% is a
817 + /// proportion of the element's own box rather than of the base, so it
818 + /// does not scale with `--geometry-base` and cannot be written as a
819 + /// [`Ratio`]. Use [`Self::css`], which spells every rung.
820 + #[must_use]
821 + pub const fn ratio(self) -> Option<Ratio> {
822 + let (numerator, denominator) = match self {
823 + Self::Square => (0, 1),
824 + Self::Fine => (1, 8),
825 + Self::Control => (1, 4),
826 + Self::Panel => (1, 2),
827 + Self::Round => return None,
828 + };
829 + Some(Ratio {
830 + numerator,
831 + denominator,
832 + })
833 + }
834 +
835 + /// Size in CSS pixels at the default base, or `None` for [`Self::Round`].
836 + #[must_use]
837 + pub const fn px(self) -> Option<u16> {
838 + match self.ratio() {
839 + Some(r) => Some(r.px_at(DEFAULT_BASE_PX)),
840 + None => None,
841 + }
842 + }
843 +
844 + /// The CSS value for this rung.
845 + ///
846 + /// `Square` emits a bare `0` rather than a `calc()` that multiplies the
847 + /// base by nothing, and `Round` emits the percentage.
848 + #[must_use]
849 + pub fn css(self) -> String {
850 + match self {
851 + Self::Square => "0".to_owned(),
852 + Self::Round => "50%".to_owned(),
853 + other => other
854 + .ratio()
855 + .expect("every rung but Round has a ratio")
856 + .css(),
857 + }
858 + }
859 +
860 + /// The CSS custom-property name, without the leading `--`.
861 + #[must_use]
862 + pub const fn token(self) -> &'static str {
863 + match self {
864 + Self::Square => "radius-square",
865 + Self::Fine => "radius-fine",
866 + Self::Control => "radius-control",
867 + Self::Panel => "radius-panel",
868 + Self::Round => "radius-round",
869 + }
870 + }
871 +
872 + /// Every rung, squarest first.
873 + #[must_use]
874 + pub const fn all() -> [Self; 5] {
875 + [
876 + Self::Square,
877 + Self::Fine,
878 + Self::Control,
879 + Self::Panel,
880 + Self::Round,
881 + ]
882 + }
883 + }
884 +
773 885 /// Emit the base unit and the raw scale as CSS declarations, no selector.
774 886 ///
775 887 /// Density-invariant: the steps are the vocabulary, and only which step a
@@ -807,6 +919,22 @@
807 919 out
808 920 }
809 921
922 + /// Emit the corner scale as CSS declarations, no selector.
923 + ///
924 + /// Takes no [`Density`] for the same reason [`text_css_declarations`] does
925 + /// not: a corner is not a tap target.
926 + #[must_use]
927 + pub fn radius_css_declarations() -> String {
928 + let mut out = String::new();
929 + out.push_str(" /* Corners. Rounding says a thing is meant to be pressed,\n");
930 + out.push_str(" so the scale is short on purpose and square is a rung\n");
931 + out.push_str(" rather than the absence of one. */\n");
932 + for radius in Radius::all() {
933 + let _ = writeln!(out, " --{}: {};", radius.token(), radius.css());
934 + }
935 + out
936 + }
937 +
810 938 /// Emit the relational layer for one density as CSS declarations, no selector.
811 939 ///
812 940 /// Gaps reference their step rather than repeating a value, so the scale has
@@ -885,10 +1013,11 @@
885 1013 #[must_use]
886 1014 pub fn geometry_css_vars(density: Density) -> String {
887 1015 format!(
888 - ":root {{\n{}\n{}\n{}}}\n",
1016 + ":root {{\n{}\n{}\n{}\n{}}}\n",
889 1017 scale_css_declarations(),
890 1018 gap_css_declarations(density),
891 - text_css_declarations()
1019 + text_css_declarations(),
1020 + radius_css_declarations()
892 1021 )
893 1022 }
894 1023
@@ -1003,6 +1132,24 @@
1003 1132 assert!(Text::all().iter().all(|t| t.px() >= 12));
1004 1133 }
1005 1134
1135 + #[test]
1136 + fn the_corner_scale_is_short_and_ordered() {
1137 + // A radius carries one bit of meaning, whether the thing is meant to
1138 + // be pressed, so a long scale is a scale nobody can choose from.
1139 + let px: Vec<Option<u16>> = Radius::all().iter().map(|r| r.px()).collect();
1140 + assert_eq!(px, vec![Some(0), Some(2), Some(4), Some(8), None]);
1141 + }
1142 +
1143 + #[test]
1144 + fn square_and_round_are_spelled_not_calculated() {
1145 + // `calc(var(--geometry-base) * 0 / 1)` is a zero nobody can read, and
1146 + // 50% is a proportion of the element rather than of the base.
1147 + assert_eq!(Radius::Square.css(), "0");
1148 + assert_eq!(Radius::Round.css(), "50%");
1149 + assert_eq!(Radius::Round.ratio(), None);
1150 + assert!(Radius::Control.css().contains(BASE_TOKEN));
1151 + }
1152 +
1006 1153 #[test]
1007 1154 fn type_does_not_move_with_density() {
1008 1155 // Density is a claim about the contact patch, and text is not a
@@ -1314,6 +1461,7 @@
1314 1461 let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
1315 1462 names.extend(Gap::all().iter().map(|g| g.token()));
1316 1463 names.extend(Text::all().iter().map(|t| t.token()));
1464 + names.extend(Radius::all().iter().map(|r| r.token()));
1317 1465 let count = names.len();
1318 1466 names.sort_unstable();
1319 1467 names.dedup();