Skip to main content

max / makeover-geometry

Add the SizeClass axis, boundaries only, 0.4.0 The second axis, and the one Density kept being asked to carry. A phone is small and touch; a tablet and a touchscreen laptop are big and touch; a half-width desktop window is small and pointer. Four combinations, and one axis cannot name them. This is the home for the claim the old Touch preset was thrown out for making: outer margin is screen you do not get. That claim was never wrong, it was on the wrong axis, and putting it on the input device is what let a derived preset set a floor under a quoted one. Boundaries are quoted rather than derived, for the same reason the Gap values are: Material 3's window size classes, 600 and 840. Apple's are two-tier and per-axis, so they give no middle to aim at. Deliberately absent: SizeClass does not feed Gap::step_at. Whether a compact window tightens its shells is a look call, and deriving it a second time is how the last one went wrong. Asserted by size_class_does_not_reach_the_gap_scale so wiring it in has to come to the test and say so. Affordances stay out too: what appears or disappears at each class is a product decision and belongs to makeover-touch. This crate carries the boundaries.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 19:57 UTC
Signed with PGP, not checked
Commit: 09d9058ec24792233a76749a026d8cfd051bfff7
Parent: ec27c3b
2 files changed, +176 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-geometry"
3 - version = "0.3.0"
3 + version = "0.4.0"
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 src/lib.rs +175 -1
@@ -88,9 +88,30 @@
88 88 //! is the exact failure this replaces. Only a Pointer move upward can push
89 89 //! Touch, and that is the correct direction of authority.
90 90 //!
91 + //! # Size class: the axis Density kept being asked to carry
92 + //!
93 + //! [`SizeClass`] answers **how much screen there is**, which is not the same
94 + //! question as what is pointing at it. A phone is small and touch; a tablet and
95 + //! a touchscreen laptop are big and touch; a half-width desktop window is small
96 + //! and pointer. Four real combinations, and one axis cannot name them.
97 + //!
98 + //! This is the home for the claim the old Touch preset was thrown out for
99 + //! making: *outer margin is screen you do not get*. That claim was never wrong,
100 + //! it was on the wrong axis, and putting it on the input device is what let a
101 + //! derived preset set a floor under a quoted one.
102 + //!
103 + //! Boundaries are **quoted** (Material 3 window size classes: 600 and 840)
104 + //! rather than derived, for the same reason the [`Gap`] values are. This crate
105 + //! carries the boundaries only; what appears or disappears at each is a product
106 + //! decision and belongs to `makeover-touch`.
107 + //!
108 + //! Deliberately absent: size class does not feed [`Gap::step_at`] yet. Whether
109 + //! shells tighten on a compact window is a look call, and deriving it a second
110 + //! time is how the last one went wrong.
111 + //!
91 112 //! # Surfaces, and why a TUI is not a third density
92 113 //!
93 - //! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
114 + //! [`Surface`] is the fourth axis and the one that carries this to alloy_tui. A
94 115 //! terminal is not a density preset; it is a surface whose smallest
95 116 //! representable step is one cell rather than one pixel. Give
96 117 //! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
@@ -298,6 +319,98 @@
298 319 Touch,
299 320 }
300 321
322 + /// How much screen there is, independent of what is pointing at it.
323 + ///
324 + /// The second axis, and the one [`Density`] kept being asked to carry. A phone
325 + /// is small *and* touch; a tablet and a touchscreen laptop are big and touch; a
326 + /// half-width window on a desktop is small and pointer. Those are four real
327 + /// combinations and one axis cannot name them, which is what made the old Touch
328 + /// preset tighten shells it had no business tightening.
329 + ///
330 + /// **Boundaries are quoted, not derived**, for the same reason the [`Gap`]
331 + /// values are: a derived boundary is one nobody can check. They are Material 3's
332 + /// window size classes, the best-known three-tier split with published numbers.
333 + /// Apple's size classes are two-tier and expressed as regular/compact per axis,
334 + /// which does not give a middle to aim at.
335 + ///
336 + /// Source: <https://m3.material.io/foundations/layout/applying-layout/window-size-classes>
337 + ///
338 + /// This enum carries the **boundaries only**. What appears, disappears or
339 + /// reflows at each is a product decision and belongs to `makeover-touch`, not
340 + /// here. Deliberately absent for now: size class does not feed [`Gap::step_at`].
341 + /// Whether shells should tighten on a compact window is a look call of exactly
342 + /// the kind that produced the 2026-07-29 demolition, so it waits for an eyeball
343 + /// rather than being derived a second time.
344 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
345 + pub enum SizeClass {
346 + /// Under 600px. Phones in either orientation, and any window narrowed to
347 + /// phone width regardless of what is pointing at it.
348 + Compact,
349 + /// 600px to 839px. Small tablets, split-screen panes, half-width windows.
350 + #[default]
351 + Medium,
352 + /// 840px and up. Laptops, desktops, tablets in landscape.
353 + Expanded,
354 + }
355 +
356 + impl SizeClass {
357 + /// Lower bound in CSS pixels, inclusive. [`Self::Compact`] starts at zero.
358 + #[must_use]
359 + pub const fn min_px(self) -> u16 {
360 + match self {
361 + Self::Compact => 0,
362 + Self::Medium => 600,
363 + Self::Expanded => 840,
364 + }
365 + }
366 +
367 + /// The media condition selecting exactly this class, without the `@media`.
368 + ///
369 + /// Bounded on both sides for the middle class, so the three are mutually
370 + /// exclusive and a rule cannot land in two of them. `max-width` is one below
371 + /// the next class's `min_px`, because CSS width ranges are inclusive.
372 + #[must_use]
373 + pub fn media_condition(self) -> String {
374 + match self {
375 + Self::Compact => format!("(max-width: {}px)", Self::Medium.min_px() - 1),
376 + Self::Medium => format!(
377 + "(min-width: {}px) and (max-width: {}px)",
378 + Self::Medium.min_px(),
379 + Self::Expanded.min_px() - 1
380 + ),
381 + Self::Expanded => format!("(min-width: {}px)", Self::Expanded.min_px()),
382 + }
383 + }
384 +
385 + /// The CSS class name an app may hang off this, without the leading dot.
386 + #[must_use]
387 + pub const fn token(self) -> &'static str {
388 + match self {
389 + Self::Compact => "size-compact",
390 + Self::Medium => "size-medium",
391 + Self::Expanded => "size-expanded",
392 + }
393 + }
394 +
395 + /// Every class, narrowest first.
396 + #[must_use]
397 + pub const fn all() -> [Self; 3] {
398 + [Self::Compact, Self::Medium, Self::Expanded]
399 + }
400 +
401 + /// The class a given viewport width falls in.
402 + #[must_use]
403 + pub const fn at_width(px: u16) -> Self {
404 + if px >= Self::Expanded.min_px() {
405 + Self::Expanded
406 + } else if px >= Self::Medium.min_px() {
407 + Self::Medium
408 + } else {
409 + Self::Compact
410 + }
411 + }
412 + }
413 +
301 414 /// A named separation between two things.
302 415 ///
303 416 /// Pick by relationship. The size is a consequence of the name, not the other
@@ -738,6 +851,67 @@
738 851 assert!(Gap::Section.px_at(Density::Touch) >= 16);
739 852 }
740 853
854 + #[test]
855 + fn size_classes_partition_every_width_exactly_once() {
856 + // Mutually exclusive and exhaustive, or a rule lands in two classes and
857 + // whichever is emitted last silently wins. Checked at every width up to
858 + // well past the top boundary rather than at the boundaries alone.
859 + for px in 0..=4000u16 {
860 + let hits: Vec<SizeClass> = SizeClass::all()
861 + .into_iter()
862 + .filter(|c| {
863 + let lo = c.min_px();
864 + let hi = match c {
865 + SizeClass::Compact => SizeClass::Medium.min_px() - 1,
866 + SizeClass::Medium => SizeClass::Expanded.min_px() - 1,
867 + SizeClass::Expanded => u16::MAX,
868 + };
869 + px >= lo && px <= hi
870 + })
871 + .collect();
872 + assert_eq!(hits.len(), 1, "{px}px matched {hits:?}");
873 + assert_eq!(hits[0], SizeClass::at_width(px), "{px}px disagrees");
874 + }
875 + }
876 +
877 + #[test]
878 + fn the_quoted_boundaries_are_the_ones_material_publishes() {
879 + // Quoted, not derived. Changing these means departing from the source,
880 + // which is a decision to record rather than a value to nudge.
881 + assert_eq!(SizeClass::Compact.min_px(), 0);
882 + assert_eq!(SizeClass::Medium.min_px(), 600);
883 + assert_eq!(SizeClass::Expanded.min_px(), 840);
884 + }
885 +
886 + #[test]
887 + fn the_media_conditions_do_not_overlap_at_the_boundary() {
888 + // The off-by-one that makes CSS width ranges overlap: max-width is
889 + // inclusive, so it must be one below the next class's min-width.
890 + assert_eq!(
891 + SizeClass::Compact.media_condition(),
892 + "(max-width: 599px)",
893 + "Compact must stop one pixel below Medium"
894 + );
895 + assert_eq!(
896 + SizeClass::Medium.media_condition(),
897 + "(min-width: 600px) and (max-width: 839px)"
898 + );
899 + assert_eq!(SizeClass::Expanded.media_condition(), "(min-width: 840px)");
900 + }
901 +
902 + #[test]
903 + fn size_class_does_not_reach_the_gap_scale() {
904 + // Deliberately absent, asserted so that wiring it in has to come here
905 + // and say so. Whether a compact window tightens its shells is a look
906 + // call; deriving it is what went wrong with Touch on 2026-07-29.
907 + //
908 + // This test does not check a value. It checks that the whole spacing
909 + // layer is reachable without naming a size class at all.
910 + let _ = geometry_css_vars(Density::Pointer);
911 + let _ = Gap::Page.px_at(Density::Touch);
912 + assert_eq!(SizeClass::all().len(), 3);
913 + }
914 +
741 915 #[test]
742 916 fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
743 917 let t = Surface::terminal();