Skip to main content

max / makeover-geometry

Put shell tightening on the size-class axis, not on the density preset The Pointer preset carried a pending retune to pane 14 / page 16, wanted by a specimen asking for a tighter window edge. A tighter window edge is a claim about screen budget, and the 0.3.0 Touch re-derivation had already ruled that smuggling screen budget into the density axis was the whole bug the old Touch preset embodied. It had nowhere else to put it: SizeClass did not exist yet. It does, so the claim goes there and the quoted Pointer values hold. Gap::step_at_size takes a SizeClass; step_at delegates to it at Medium, so every existing caller keeps the value it already meant. Only pane and page listen, and only on Compact: 24 to 16 and 32 to 24, one step down each. Two things fall out. No Step at 7/8 = 14, which is off an eighths scale and would have wanted a public name forever; both compact values are already on the scale. And no consumer migrates, because no quoted value changed. size_class_css is a separate emitter rather than a block inside density_css, which has a test forbidding a breakpoint from appearing in it at all. A capability query answers what is pointing at this; width is what size class means. They do not belong in one file. Retune withdrawn (Max, 2026-08-09). Ships as 0.7.0.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 01:32 UTC
Signed with PGP, not checked
Commit: 73aae173aabb78dd1ab77f3c7117f50bf1882704
Parent: 6bf09e2
3 files changed, +221 insertions, -35 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-geometry"
3 - version = "0.6.2"
3 + version = "0.7.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 +220 -14
@@ -129,9 +129,11 @@
129 129 //! carries the boundaries only; what appears or disappears at each is a product
130 130 //! decision and belongs to `makeover-touch`.
131 131 //!
132 - //! Deliberately absent: size class does not feed [`Gap::step_at`] yet. Whether
133 - //! shells tighten on a compact window is a look call, and deriving it a second
134 - //! time is how the last one went wrong.
132 + //! Size class feeds [`Gap::step_at_size`], and only the two shells listen to
133 + //! it: `pane` and `page` come down one step on a compact window. That is where
134 + //! "outer margin is screen you don't get" belongs. It sat in the Pointer
135 + //! preset's pending retune until 2026-08-09, which would have been the same
136 + //! mistake the old Touch preset made, one axis over.
135 137 //!
136 138 //! # Surfaces, and why a TUI is not a third density
137 139 //!
@@ -388,10 +390,10 @@
388 390 ///
389 391 /// This enum carries the **boundaries only**. What appears, disappears or
390 392 /// reflows at each is a product decision and belongs to `makeover-touch`, not
391 - /// here. Deliberately absent for now: size class does not feed [`Gap::step_at`].
392 - /// Whether shells should tighten on a compact window is a look call of exactly
393 - /// the kind that produced the 2026-07-29 demolition, so it waits for an eyeball
394 - /// rather than being derived a second time.
393 + /// here, with one exception: shells tighten on a compact window, through
394 + /// [`Gap::step_at_size`]. That was a look call of exactly the kind that
395 + /// produced the 2026-07-29 demolition, so it waited for an eyeball rather than
396 + /// being derived a second time, and got one on 2026-08-09.
395 397 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
396 398 pub enum SizeClass {
397 399 /// Under 600px. Phones in either orientation, and any window narrowed to
@@ -580,13 +582,44 @@
580 582 }
581 583
582 584 impl Gap {
583 - /// The step this relationship resolves to at a given density.
585 + /// The step this relationship resolves to at a given density, on a
586 + /// [`SizeClass::Medium`] or wider window.
584 587 ///
585 588 /// [`Density::Pointer`]'s values are the HIG's own. [`Density::Touch`]
586 589 /// opens the three gaps that separate distinct tap targets and holds the
587 590 /// rest, per the crate-level "Density presets" section.
591 + ///
592 + /// Shells tighten on a compact window rather than at touch density. Use
593 + /// [`Self::step_at_size`] where the window width is known; this is the
594 + /// wider-window answer and the one every existing caller already meant.
588 595 #[must_use]
589 596 pub const fn step_at(self, density: Density) -> Step {
597 + self.step_at_size(density, SizeClass::Medium)
598 + }
599 +
600 + /// The step this relationship resolves to at a given density and window
601 + /// size class.
602 + ///
603 + /// Only the two shells move, and only on [`SizeClass::Compact`]: `pane`
604 + /// 24 to 16, `page` 32 to 24, one step down each. The four gaps between
605 + /// controls do not, because how much room a window has says nothing about
606 + /// how far apart two tap targets should be.
607 + ///
608 + /// **Which axis owns this** (Max, 2026-08-09). The Pointer preset was
609 + /// carrying a retune to `pane 14 / page 16` on the strength of a specimen
610 + /// that wanted a tighter window edge. A tighter window edge is a claim
611 + /// about screen budget, and the 0.3.0 Touch re-derivation had already
612 + /// ruled that smuggling screen budget into the density axis was the whole
613 + /// bug the old Touch preset embodied — it just had nowhere else to put it,
614 + /// because [`SizeClass`] did not exist yet. It does now, so the claim goes
615 + /// where it belongs and the quoted Pointer values hold.
616 + ///
617 + /// Two things fall out. There is no need for a `Step` at 7/8 = 14, which
618 + /// is off the eighths scale and would have wanted a public name; both
619 + /// compact values are already on it. And no consumer migrates, because no
620 + /// quoted value changed.
621 + #[must_use]
622 + pub const fn step_at_size(self, density: Density, size: SizeClass) -> Step {
590 623 match self {
591 624 // Binding is not a separation, so it does not open up on touch
592 625 // either: separating these would say they are two objects.
@@ -608,10 +641,16 @@
608 641 Density::Touch => Step::Loose,
609 642 },
610 643
611 - // Shells. Not tap targets, so the contact patch has no opinion,
612 - // and screen budget is a different axis than this one.
613 - Self::Pane => Step::Broad,
614 - Self::Page => Step::Vast,
644 + // Shells. Not tap targets, so the contact patch has no opinion.
645 + // Screen budget is the axis that does, and it is this one.
646 + Self::Pane => match size {
647 + SizeClass::Compact => Step::Loose,
648 + SizeClass::Medium | SizeClass::Expanded => Step::Broad,
649 + },
650 + Self::Page => match size {
651 + SizeClass::Compact => Step::Broad,
652 + SizeClass::Medium | SizeClass::Expanded => Step::Vast,
653 + },
615 654 }
616 655 }
617 656
@@ -627,6 +666,13 @@
627 666 self.step_at(density).px()
628 667 }
629 668
669 + /// Size in CSS pixels at the default base, at a given density and window
670 + /// size class.
671 + #[must_use]
672 + pub const fn px_at_size(self, density: Density, size: SizeClass) -> u16 {
673 + self.step_at_size(density, size).px()
674 + }
675 +
630 676 /// Size in CSS pixels at the default base and density.
631 677 #[must_use]
632 678 pub const fn px(self) -> u16 {
@@ -1072,6 +1118,60 @@
1072 1118 css
1073 1119 }
1074 1120
1121 + /// Emit the compact-window shell override.
1122 + ///
1123 + /// The one place this crate is allowed to ask how wide the window is. Density
1124 + /// must never be selected by width — [`density_css`] has a test forbidding a
1125 + /// breakpoint from appearing in it at all — because a capability query answers
1126 + /// "what is pointing at this" and a breakpoint does not. Size class is the
1127 + /// opposite: width is exactly what it means, so it gets its own emitter and
1128 + /// its own media query rather than being folded into that file.
1129 + ///
1130 + /// Only the two shells move, and only below [`SizeClass::Medium`]'s boundary:
1131 + /// `pane` 24 to 16, `page` 32 to 24. The four gaps between controls are absent
1132 + /// from the block, so a narrow window never reasons about tap targets.
1133 + ///
1134 + /// Emitted inside [`CSS_LAYER`] for the same reason [`density_css`] is: an
1135 + /// unlayered custom property outranks a layered one, so an app that layers its
1136 + /// own overrides would otherwise lose to this.
1137 + ///
1138 + /// ```
1139 + /// # use makeover_geometry::size_class_css;
1140 + /// let css = size_class_css();
1141 + /// assert!(css.contains("--gap-pane"));
1142 + /// assert!(!css.contains("--gap-peer"), "a control gap crept into a width query");
1143 + /// ```
1144 + #[must_use]
1145 + pub fn size_class_css() -> String {
1146 + in_css_layer(&size_class_declarations())
1147 + }
1148 +
1149 + /// [`size_class_css`] without the layer wrapper.
1150 + fn size_class_declarations() -> String {
1151 + // Compact is everything below Medium's lower bound, so the query ends one
1152 + // step under it. Fractional, because a 599.5px viewport is reachable on a
1153 + // fractional-scaling display and an integer bound would drop it into
1154 + // neither class.
1155 + let ceiling = f32::from(SizeClass::Medium.min_px()) - 0.02;
1156 + let mut css = String::new();
1157 + css.push_str("/* Compact window: shells tighten. Outer margin is screen you\n");
1158 + css.push_str(" don't get, which is a claim about the window and not about\n");
1159 + css.push_str(" what is pointing at it, so it lives here and not in the\n");
1160 + css.push_str(" density presets. Targets are untouched. */\n");
1161 + let _ = writeln!(css, "@media (max-width: {ceiling}px) {{");
1162 + css.push_str(" :root {\n");
1163 + for gap in [Gap::Pane, Gap::Page] {
1164 + let _ = writeln!(
1165 + css,
1166 + " --{}: var(--{});",
1167 + gap.token(),
1168 + gap.step_at_size(Density::Pointer, SizeClass::Compact).token()
1169 + );
1170 + }
1171 + css.push_str(" }\n}\n");
1172 + css
1173 + }
1174 +
1075 1175 /// Emit a density preset as a scoped override block.
1076 1176 ///
1077 1177 /// Only the relational layer is emitted: the scale and the base do not change
@@ -1319,11 +1419,117 @@
1319 1419 Gap::Section.px_at(Density::Touch) <= Gap::Pane.px_at(Density::Touch),
1320 1420 "Touch inverted internally, which is what set the old floor"
1321 1421 );
1322 - // The retune wants Pointer pane 14 / page 16, both under Touch's
1323 - // Section of 16. Nothing in this crate may object to that.
1422 + // The retune this guarded is WITHDRAWN (Max, 2026-08-09): shells
1423 + // tighten by size class, not by moving the quoted Pointer values, so
1424 + // Pointer pane holds at 24 and never goes under Touch's Section.
1425 + //
1426 + // The assertion stays anyway. It is not about the retune; it is about
1427 + // the direction of authority, and the day a derived preset can set a
1428 + // floor under a quoted one is the day this crate has the 2026-07-29
1429 + // bug back regardless of what anybody wanted to retune.
1324 1430 assert!(Gap::Section.px_at(Density::Touch) >= 16);
1325 1431 }
1326 1432
1433 + #[test]
1434 + fn shells_tighten_on_a_compact_window_and_nothing_else_does() {
1435 + // The whole of the 2026-08-09 ruling, in one test. Shells come down
1436 + // one step on a compact window; the four gaps that separate controls
1437 + // do not move, because how much room a window has says nothing about
1438 + // how far apart two tap targets belong.
1439 + for density in [Density::Pointer, Density::Touch] {
1440 + assert_eq!(Gap::Pane.px_at_size(density, SizeClass::Compact), 16);
1441 + assert_eq!(Gap::Page.px_at_size(density, SizeClass::Compact), 24);
1442 +
1443 + for class in [SizeClass::Medium, SizeClass::Expanded] {
1444 + assert_eq!(Gap::Pane.px_at_size(density, class), 24);
1445 + assert_eq!(Gap::Page.px_at_size(density, class), 32);
1446 + }
1447 +
1448 + for gap in [Gap::Bound, Gap::Peer, Gap::Group, Gap::Section] {
1449 + for class in SizeClass::all() {
1450 + assert_eq!(
1451 + gap.px_at_size(density, class),
1452 + gap.px_at(density),
1453 + "{gap:?} moved on {class:?}, and only shells may"
1454 + );
1455 + }
1456 + }
1457 + }
1458 + }
1459 +
1460 + #[test]
1461 + fn the_widths_never_needed_a_step_at_seven_eighths() {
1462 + // The retune wanted pane 14, which is 7/8 of the base and off an
1463 + // eighths scale that runs 2/4/6/8/10/12/16/24/32/48. It would have
1464 + // needed a new public Step variant, and naming one is a cost paid
1465 + // forever. Putting the claim on the size-class axis lands both compact
1466 + // values on steps that already exist.
1467 + for gap in [Gap::Pane, Gap::Page] {
1468 + for density in [Density::Pointer, Density::Touch] {
1469 + let step = gap.step_at_size(density, SizeClass::Compact);
1470 + assert!(
1471 + Step::all().contains(&step),
1472 + "{gap:?} compact resolved off the scale"
1473 + );
1474 + }
1475 + }
1476 + }
1477 +
1478 + #[test]
1479 + fn no_size_class_inverts_the_ordering() {
1480 + // Collapse is allowed, inversion is not — the same rule the surface
1481 + // quantum test applies, now across the third axis. Touch on a compact
1482 + // window is the tight one: Section opens to 16 and Pane comes down to
1483 + // 16, so they meet. Meeting is fine. Crossing is not.
1484 + for density in [Density::Pointer, Density::Touch] {
1485 + for class in SizeClass::all() {
1486 + let v: Vec<u16> = Gap::all()
1487 + .iter()
1488 + .map(|g| g.px_at_size(density, class))
1489 + .collect();
1490 + let mut sorted = v.clone();
1491 + sorted.sort_unstable();
1492 + assert_eq!(v, sorted, "{density:?} {class:?} inverted: {v:?}");
1493 + }
1494 + }
1495 + }
1496 +
1497 + #[test]
1498 + fn step_at_is_the_wider_window_answer() {
1499 + // Every caller that predates the size-class axis meant the wide
1500 + // window, so the old entry point has to keep resolving to it or a
1501 + // consumer tightens silently on a bump it did not read about.
1502 + for gap in Gap::all() {
1503 + for density in [Density::Pointer, Density::Touch] {
1504 + assert_eq!(
1505 + gap.step_at(density),
1506 + gap.step_at_size(density, SizeClass::Medium)
1507 + );
1508 + assert_eq!(
1509 + gap.step_at(density),
1510 + gap.step_at_size(density, SizeClass::Expanded)
1511 + );
1512 + }
1513 + }
1514 + }
1515 +
1516 + #[test]
1517 + fn the_width_query_lives_outside_the_density_file() {
1518 + // density_is_selected_by_capability_not_by_width_or_agent forbids a
1519 + // breakpoint in density_css. This is the other half of that rule: the
1520 + // width query has to exist somewhere, and somewhere is here.
1521 + let css = size_class_css();
1522 + assert!(css.contains("max-width"));
1523 + assert!(css.contains("--gap-pane"));
1524 + assert!(css.contains("--gap-page"));
1525 + // Targets never appear in a width query.
1526 + for token in ["--gap-bound", "--gap-peer", "--gap-group", "--gap-section"] {
1527 + assert!(!css.contains(token), "{token} crept into a width query");
1528 + }
1529 + // And it stays out of the density file.
1530 + assert!(!density_css(None).contains("max-width"));
1531 + }
1532 +
1327 1533 #[test]
1328 1534 fn size_classes_partition_every_width_exactly_once() {
1329 1535 // Mutually exclusive and exhaustive, or a rule lands in two classes and
@@ -1,20 +1,0 @@
1 - //! Print the geometry layer as an app would bake it in at build time, then
2 - //! the same vocabulary resolved for a terminal.
3 -
4 - use makeover_geometry::{Density, Gap, Surface, gap_css_overrides, geometry_css_vars};
5 -
6 - fn main() {
7 - print!("{}", geometry_css_vars(Density::Pointer));
8 - println!();
9 - print!("{}", gap_css_overrides(".ui-mode-mobile", Density::Touch));
10 -
11 - println!("\n/* the same relationships on a terminal, in cells */");
12 - let t = Surface::terminal();
13 - for gap in Gap::all() {
14 - println!(
15 - "/* {:<8} {} */",
16 - gap.token().trim_start_matches("gap-"),
17 - t.gap(gap, Density::Pointer)
18 - );
19 - }
20 - }