Skip to main content

max / makeover-geometry

Re-derive the Touch density preset, 0.3.0 Touch is a claim about the contact patch and nothing else. A coarse fingertip raises mis-tap cost between adjacent things that do different things, so the gaps that separate tap targets open: peer 6 to 10, group 10 to 12, section 12 to 16. Shells are not tap targets, so pane and page hold at Pointer's values. Bound never moves. The preset thrown out on 2026-07-29 also tightened pane and page, on the argument that outer margin is screen you do not get. That is a claim about screen budget, not about the input device, and smuggling it into this axis is what broke: an opened section at 16 above a tightened pane made any Pointer pane at or below 16 an inversion, so the derived preset set a floor under the one quoted from the HIG. The replacement states one cross-density rule, chosen for its direction: Touch never resolves tighter than Pointer. That constrains the derived preset by the quoted one and never the reverse, so the Pointer retune to pane 14 / page 16 moves freely. touch_has_no_preset_of_its_own is replaced by three tests: the derivation itself, the cross-density rule, and a guard that the retune stays unblocked.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 19:33 UTC
Signed with PGP, not checked
Commit: ec27c3b1d8ed53c23ca93d832ed2a371d0388ef7
Parent: 807d278
3 files changed, +139 insertions, -48 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-geometry"
3 - version = "0.2.0"
3 + version = "0.3.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 README.md +21 -5
@@ -53,11 +53,27 @@
53 53 differ mostly by which preset they emit.
54 54
55 55 The presets are not scaled copies of each other, which is also why a single
56 - base scalar could not express this. Touch wants **more** room between things
57 - you tap and **less** around the edges, since a fingertip is coarse and outer
58 - margin is screen you do not get to use. `gap-peer` and `gap-section` open up;
59 - `gap-pane` and `gap-page` tighten. `gap-bound` never moves — it is the one
60 - relationship that says "these are one object".
56 + base scalar could not express this. Touch is a claim about the contact patch
57 + and nothing else: a fingertip is coarse, so adjacent things that do different
58 + things need more room between them to be hit reliably.
59 +
60 + | Gap | Pointer | Touch |
61 + |---|---|---|
62 + | bound | 4 | 4 |
63 + | peer | 6 | 10 |
64 + | group | 10 | 12 |
65 + | section | 12 | 16 |
66 + | pane | 24 | 24 |
67 + | page | 32 | 32 |
68 +
69 + `gap-peer`, `gap-group` and `gap-section` separate distinct tap targets, so
70 + they open. `gap-pane` and `gap-page` are shells rather than targets, so the
71 + input device has no opinion about them and they hold. How much screen you have
72 + is a separate question from what you are pointing with, and it does not belong
73 + on this axis. `gap-bound` never moves: it is the one relationship that says
74 + "these are one object", and separating it would say the opposite.
75 +
76 + The one rule across presets is that Touch never resolves tighter than Pointer.
61 77
62 78 ```rust
63 79 use makeover_geometry::{Density, geometry_css_vars, gap_css_overrides};
M src/lib.rs +117 -42
@@ -47,17 +47,46 @@
47 47 //! The mobile and desktop builds of a Tauri app should differ mostly by which
48 48 //! preset they emit, not by a parallel set of hand-written mode-scoped rules.
49 49 //!
50 - //! **[`Density::Touch`] has no preset of its own right now.** It resolves to
51 - //! the same steps as [`Density::Pointer`] until one is derived. The previous
52 - //! Touch numbers were thrown out on 2026-07-29: they opened `Peer` and
53 - //! `Section` while tightening `Pane` and `Page`, which put Touch's `Section` at
54 - //! 16 and made any Pointer `Pane` at or below 16 an inversion. That blocked the
55 - //! Pointer retune to `pane 14 / page 16` and the values were derived rather
56 - //! than quoted, so the preset goes rather than the retune. The axis stays: the
57 - //! [`Density`] argument, [`density_css`] and every consumer's mode-scoped block
58 - //! are all still wired, and a re-derived preset lands here without touching a
59 - //! call site. That is the claim this section opens with, demonstrated the hard
60 - //! way.
50 + //! ## What Touch is a claim about
51 + //!
52 + //! Touch is a claim about the **contact patch and nothing else**. A fingertip
53 + //! is coarse where a cursor hotspot is a point, and the only consequence of
54 + //! that is mis-tap cost: when two adjacent things do different things, an
55 + //! imprecise contact needs more room between them to land on the intended one.
56 + //!
57 + //! Shells are not tap targets. Panel padding and the outer page margin separate
58 + //! a region from the edge of the screen, and no amount of coarseness in the
59 + //! pointing device makes that separation riskier. So **Touch opens the gaps
60 + //! that separate targets and leaves the shells exactly where Pointer put
61 + //! them**:
62 + //!
63 + //! | Gap | Pointer | Touch | why |
64 + //! |---|---|---|---|
65 + //! | bound | 4 | 4 | not a separation at all |
66 + //! | peer | 6 | 10 | adjacent distinct targets, the whole point |
67 + //! | group | 10 | 12 | holds the peer/group distinction open |
68 + //! | section | 12 | 16 | a deliberate break stays legible as one |
69 + //! | pane | 24 | 24 | a shell is not a target |
70 + //! | page | 32 | 32 | a shell is not a target |
71 + //!
72 + //! The previous Touch preset was thrown out on 2026-07-29 because it also
73 + //! *tightened* `Pane` and `Page`, on the argument that outer margin is screen
74 + //! you do not get. **That is a claim about screen budget, not about the input
75 + //! device**, and smuggling it into this axis is what broke: opening `Section`
76 + //! to 16 while tightening `Pane` below it made any Pointer `Pane` at or under
77 + //! 16 an inversion, so a derived preset silently set a floor under the one
78 + //! quoted from the HIG. A phone is small *and* touch; a tablet and a
79 + //! touchscreen laptop are big and touch. Screen budget is a separate axis and
80 + //! does not belong here.
81 + //!
82 + //! ## The one cross-density rule
83 + //!
84 + //! **Touch never resolves tighter than Pointer**, at any gap. Stated as a
85 + //! deliberate claim rather than inherited, and chosen for its direction: it
86 + //! constrains the *derived* preset by the *quoted* one, never the reverse. A
87 + //! Pointer retune downward moves freely and cannot be blocked by Touch, which
88 + //! is the exact failure this replaces. Only a Pointer move upward can push
89 + //! Touch, and that is the correct direction of authority.
61 90 //!
62 91 //! # Surfaces, and why a TUI is not a third density
63 92 //!
@@ -262,9 +291,10 @@
262 291 /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
263 292 #[default]
264 293 Pointer,
265 - /// Finger. **No preset of its own yet**: resolves to [`Self::Pointer`]'s
266 - /// steps until one is derived. See the crate-level "Density presets"
267 - /// section for why the previous one was thrown out.
294 + /// Finger. Opens the gaps that separate distinct tap targets and leaves
295 + /// the shells where Pointer put them, because a coarse contact patch
296 + /// raises mis-tap cost and a panel margin is not something you tap. See
297 + /// the crate-level "Density presets" section for the derivation.
268 298 Touch,
269 299 }
270 300
@@ -388,21 +418,34 @@
388 418 impl Gap {
389 419 /// The step this relationship resolves to at a given density.
390 420 ///
391 - /// Only [`Density::Pointer`] has values, and they are the HIG's own.
392 - /// [`Density::Touch`] was thrown out on 2026-07-29 and falls through to the
393 - /// same steps; the `density` argument is kept because the axis is still
394 - /// real, and re-deriving Touch means adding arms here and nothing else.
421 + /// [`Density::Pointer`]'s values are the HIG's own. [`Density::Touch`]
422 + /// opens the three gaps that separate distinct tap targets and holds the
423 + /// rest, per the crate-level "Density presets" section.
395 424 #[must_use]
396 425 pub const fn step_at(self, density: Density) -> Step {
397 - let _ = density;
398 426 match self {
399 - // Binding is not a separation, so it would not open up on touch
400 - // either. The one arm a re-derived preset should leave alone.
427 + // Binding is not a separation, so it does not open up on touch
428 + // either: separating these would say they are two objects.
401 429 Self::Bound => Step::Tight,
402 430
403 - Self::Peer => Step::Snug,
404 - Self::Group => Step::Roomy,
405 - Self::Section => Step::Wide,
431 + // The three that carry mis-tap cost. Peer is the one that matters
432 + // most (stacked rows, adjacent chips) and moves furthest; Group
433 + // and Section follow only far enough to stay distinct from it.
434 + Self::Peer => match density {
435 + Density::Pointer => Step::Snug,
436 + Density::Touch => Step::Roomy,
437 + },
438 + Self::Group => match density {
439 + Density::Pointer => Step::Roomy,
440 + Density::Touch => Step::Wide,
441 + },
442 + Self::Section => match density {
443 + Density::Pointer => Step::Wide,
444 + Density::Touch => Step::Loose,
445 + },
446 +
447 + // Shells. Not tap targets, so the contact patch has no opinion,
448 + // and screen budget is a different axis than this one.
406 449 Self::Pane => Step::Broad,
407 450 Self::Page => Step::Vast,
408 451 }
@@ -528,11 +571,7 @@
528 571 #[must_use]
529 572 pub fn density_css(explicit_touch: Option<&str>) -> String {
530 573 let mut css = geometry_css_vars(Density::Pointer);
531 - // The block is emitted even though Touch currently resolves to Pointer's
532 - // steps, so it is a no-op repeat of the base layer. Deliberate: it keeps the
533 - // selection mechanism proven and in front of a reader, and a re-derived
534 - // Touch preset then reaches every consumer on a rebuild.
535 - css.push_str("\n/* Touch has no preset of its own yet: identical to the base layer. */\n");
574 + css.push_str("\n/* Touch: targets separate, shells hold. */\n");
536 575 css.push_str("@media (hover: none), (pointer: coarse) {\n");
537 576 for line in gap_css_overrides(":root", Density::Touch).lines() {
538 577 css.push_str(" ");
@@ -646,24 +685,59 @@
646 685 }
647 686
648 687 #[test]
649 - fn touch_has_no_preset_of_its_own() {
650 - // Replaces touch_separates_targets_and_tightens_shells, which asserted
651 - // the derived preset thrown out on 2026-07-29: Peer and Section opened
652 - // while Pane and Page tightened. That put Touch's Section at 16 and made
653 - // any Pointer Pane at or below 16 an inversion, blocking the retune to
654 - // pane 14 / page 16.
655 - //
656 - // Asserted rather than left implicit so re-deriving Touch has to come
657 - // here and say so, the same way the theme degeneracy lists work.
658 - for gap in Gap::all() {
688 + fn touch_separates_targets_and_holds_the_shells() {
689 + // The derivation, asserted so that changing it has to come here and say
690 + // so. Touch is a claim about the contact patch: the gaps between
691 + // distinct tap targets open, and the gaps that are not tap targets do
692 + // not move.
693 + for gap in [Gap::Peer, Gap::Group, Gap::Section] {
694 + assert!(
695 + gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
696 + "{gap:?} separates tap targets and must open on touch"
697 + );
698 + }
699 + for gap in [Gap::Bound, Gap::Pane, Gap::Page] {
659 700 assert_eq!(
660 701 gap.px_at(Density::Touch),
661 702 gap.px_at(Density::Pointer),
662 - "{gap:?}: Touch resolves to Pointer until a preset is derived"
703 + "{gap:?} is not a tap target and must not move with the input device"
663 704 );
664 705 }
665 706 }
666 707
708 + #[test]
709 + fn touch_never_resolves_tighter_than_pointer() {
710 + // The one cross-density rule, and its direction is the point. The
711 + // preset thrown out on 2026-07-29 tightened Pane and Page on touch,
712 + // which combined with an opened Section to make any Pointer Pane at or
713 + // below 16 an inversion: a derived preset set a floor under the one
714 + // quoted from the HIG, blocking the retune to pane 14 / page 16.
715 + //
716 + // Constraining Touch by Pointer instead cannot do that. A Pointer
717 + // retune downward moves freely; only a Pointer move upward pushes
718 + // Touch, which is the correct direction of authority.
719 + for gap in Gap::all() {
720 + assert!(
721 + gap.px_at(Density::Touch) >= gap.px_at(Density::Pointer),
722 + "{gap:?}: Touch resolved tighter than Pointer"
723 + );
724 + }
725 + }
726 +
727 + #[test]
728 + fn the_pointer_retune_is_not_blocked_by_touch() {
729 + // Guards the specific regression above rather than trusting the general
730 + // rule to imply it. Touch's own ordering must hold using Touch values
731 + // only, so that a Pointer Pane at or below Touch's Section is legal.
732 + assert!(
733 + Gap::Section.px_at(Density::Touch) <= Gap::Pane.px_at(Density::Touch),
734 + "Touch inverted internally, which is what set the old floor"
735 + );
736 + // The retune wants Pointer pane 14 / page 16, both under Touch's
737 + // Section of 16. Nothing in this crate may object to that.
738 + assert!(Gap::Section.px_at(Density::Touch) >= 16);
739 + }
740 +
667 741 #[test]
668 742 fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
669 743 let t = Surface::terminal();
@@ -783,8 +857,9 @@
783 857 #[test]
784 858 fn a_density_override_emits_only_the_relational_layer() {
785 859 let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
786 - // Whichever step it is: Touch has no preset of its own at the moment, so
787 - // this says the gap is emitted and references a step, not which one.
860 + // Which step Peer lands on is the preset's business, asserted in
861 + // touch_separates_targets_and_holds_the_shells. This says only that the
862 + // gap is emitted and references a step.
788 863 assert!(css.contains("--gap-peer: var(--step-"));
789 864 // Referencing a step is the point; re-declaring one would fork the
790 865 // scale, so the check is on declarations, not on mentions.