Skip to main content

makeover_geometry/
lib.rs

1//! The invariant half of the make-family design system.
2//!
3//! <!-- wiki: makeover-geometry -->
4//!
5//! [`makeover`] resolves colour, which varies by theme. This crate carries
6//! everything that does not: spacing, radius, border width and the type scale.
7//! The split is the same one Balanced Breakfast's theme contract has always
8//! drawn — *a theme overrides colour tokens only* — moved out of two app
9//! stylesheets so the three consumers stop maintaining three copies of it.
10//!
11//! # Spacing is relational, not numeric
12//!
13//! The Mac OS 8 Human Interface Guidelines specify white space by *what two
14//! things are being separated*, never by a size name, and define no base grid
15//! unit. A control and its satellite pop-up are set 4 pixels apart; peers
16//! stacked in a list get 6; a group box's inner margin is 10; separated groups
17//! and rows of push buttons get 12.
18//!
19//! That vocabulary is the primary interface here. [`Gap`] names the
20//! relationship and the size follows from it, exactly as `surface-raised`
21//! names an intent and the hex follows from it. The raw [`Step`] scale exists
22//! underneath for distances a relationship does not describe, but reaching for
23//! it is a smell worth a second look.
24//!
25//! Naming the relationship is what makes the rule reviewable. Whether a gap
26//! should be 6px or 8px is unanswerable in isolation; whether two things are
27//! peers is not.
28//!
29//! # Ratios, not pixel counts
30//!
31//! This is the deliberate departure from the HIG, which is specified in hard
32//! device pixels because in 1997 there was one pixel density and one text
33//! size. Every [`Step`] here is a [`Ratio`] of a single base unit, so the
34//! whole system scales from one knob: `--geometry-base`, `1rem` by default.
35//!
36//! At the default base the ratios land exactly on the HIG's numbers — `Snug`
37//! is three eighths of 16px, which is 6px — so nothing is lost in the
38//! translation. What is gained is that the layout tracks the user's text size
39//! instead of fighting it, an accessibility setting becomes one value rather
40//! than a sweep, and the scale means the same thing at any display density.
41//!
42//! # Density presets
43//!
44//! Naming relationships instead of sizes is what makes a density preset
45//! possible at all. [`Density`] changes what each relationship resolves to
46//! without touching a single call site, because no call site names a size.
47//! The mobile and desktop builds of a Tauri app should differ mostly by which
48//! preset they emit, not by a parallel set of hand-written mode-scoped rules.
49//!
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.
90//!
91//! # Surfaces, and why a TUI is not a third density
92//!
93//! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
94//! terminal is not a density preset; it is a surface whose smallest
95//! representable step is one cell rather than one pixel. Give
96//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
97//! relational vocabulary crosses to a character grid with nothing added.
98//!
99//! Quantising is not a terminal special case either — a display quantises to
100//! the pixel. It is only that rounding 6.0 to the nearest pixel is
101//! uninteresting, while rounding three eighths of a cell to the nearest cell
102//! decides the layout.
103//!
104//! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2
105//! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy:
106//! in a grid that dense, both relationships are expressed by adjacency. A
107//! coarse surface genuinely has fewer distinctions available, and the model
108//! should say so instead of inventing a gap to keep six names distinct.
109//!
110//! So the three axes are: [`Gap`] is what is being separated, [`Density`] is
111//! who is operating it, [`Surface`] is what it is drawn on.
112
113#![forbid(unsafe_code)]
114
115use std::fmt::Write as _;
116
117/// The default base unit in CSS pixels, at a 16px root font size.
118pub const DEFAULT_BASE_PX: u16 = 16;
119
120/// The CSS custom property every ratio scales from.
121pub const BASE_TOKEN: &str = "geometry-base";
122
123/// A fraction of the base unit.
124///
125/// Rational rather than floating point so the scale is exact, comparable and
126/// usable in a `const`. At the default base every ratio below divides evenly.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128pub struct Ratio {
129    /// Top of the fraction.
130    pub numerator: u16,
131    /// Bottom of the fraction. Never zero for any ratio this crate defines.
132    pub denominator: u16,
133}
134
135impl Ratio {
136    /// Resolve against a base measured in whole pixels, rounding to nearest.
137    ///
138    /// Integer maths throughout, and exact for every [`Step`] at
139    /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is
140    /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the
141    /// common case stays `const`.
142    #[must_use]
143    pub const fn px_at(self, base_px: u16) -> u16 {
144        let (n, d) = (self.numerator as u32, self.denominator as u32);
145        let scaled = base_px as u32 * n;
146        // Round half away from zero without leaving integer arithmetic.
147        ((scaled * 2 + d) / (d * 2)) as u16
148    }
149
150    /// Resolve against an arbitrary base, keeping the fraction.
151    ///
152    /// The exact value, before any surface gets a say. Prefer
153    /// [`Surface::resolve`] unless you specifically want the unsnapped number.
154    #[must_use]
155    pub fn scale(self, base: f32) -> f32 {
156        base * f32::from(self.numerator) / f32::from(self.denominator)
157    }
158
159    /// How many whole quanta this ratio is worth on a surface whose smallest
160    /// representable step is `quantum`.
161    ///
162    /// The generalisation of "round to a pixel". A display quantises to one
163    /// pixel and the answer is usually uninteresting; a terminal quantises to
164    /// one cell and the answer is the whole design. Rounds to nearest, and
165    /// does not floor at one: a gap that lands below half a quantum should
166    /// collapse to nothing, because on that surface it *is* nothing.
167    ///
168    /// A `quantum` that is zero, negative or not finite yields `0` rather than
169    /// panicking or returning infinity — a surface with no smallest step is a
170    /// caller error, not a layout to guess at.
171    #[must_use]
172    pub fn quanta(self, base: f32, quantum: f32) -> u32 {
173        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
174            return 0;
175        }
176        let exact = self.scale(base) / quantum;
177        if exact <= 0.0 {
178            0
179        } else {
180            // `as` saturates at the integer bound, so a wild base cannot wrap.
181            exact.round() as u32
182        }
183    }
184
185    /// Resolve against a base and snap to a whole number of `quantum`.
186    ///
187    /// The value [`Self::quanta`] counts, back in the surface's own units.
188    /// Guards the degenerate quantum in its own right rather than leaning on
189    /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN,
190    /// not zero.
191    #[must_use]
192    pub fn quantize(self, base: f32, quantum: f32) -> f32 {
193        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
194            return 0.0;
195        }
196        self.quanta(base, quantum) as f32 * quantum
197    }
198
199    /// The CSS value, as an expression over [`BASE_TOKEN`].
200    ///
201    /// A whole multiple of the base emits without a division, and 1:1 emits
202    /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
203    #[must_use]
204    pub fn css(self) -> String {
205        match (self.numerator, self.denominator) {
206            (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
207            (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
208            (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
209        }
210    }
211}
212
213/// What the layout is being drawn on: a base unit, and the smallest step the
214/// surface can actually represent.
215///
216/// Both are in the surface's own units, and the crate never assumes those are
217/// pixels. A display measures in pixels and can represent one of them; a
218/// terminal measures in cells and cannot represent less than one. That single
219/// difference is the whole of the terminal story — a TUI is not a density, it
220/// is a surface with a coarse quantum, and the relational vocabulary above
221/// crosses over untouched.
222///
223/// Quantising is not a terminal special case. A display does it too; it is
224/// just that rounding 6.0 to the nearest pixel is uninteresting, whereas
225/// rounding three eighths of a cell to the nearest cell is a design decision
226/// the surface makes for you.
227#[derive(Debug, Clone, Copy, PartialEq)]
228pub struct Surface {
229    /// The base unit, in this surface's units.
230    pub base: f32,
231    /// The smallest step this surface can represent, in the same units.
232    pub quantum: f32,
233}
234
235impl Surface {
236    /// A display measuring in CSS pixels: a 16px base, one-pixel quantum.
237    #[must_use]
238    pub fn web() -> Self {
239        Self {
240            base: f32::from(DEFAULT_BASE_PX),
241            quantum: 1.0,
242        }
243    }
244
245    /// A terminal measuring in cells: a one-cell base, one-cell quantum.
246    ///
247    /// The coarsest surface in the family, and the one that proves the
248    /// vocabulary. `bound` and `peer` collapse to no cells at all, which is
249    /// correct — in a grid this dense, "belongs to" and "is a peer of" are
250    /// both expressed by adjacency, not by a gap.
251    #[must_use]
252    pub fn terminal() -> Self {
253        Self {
254            base: 1.0,
255            quantum: 1.0,
256        }
257    }
258
259    /// Resolve a ratio on this surface, snapped to its quantum.
260    #[must_use]
261    pub fn resolve(self, ratio: Ratio) -> f32 {
262        ratio.quantize(self.base, self.quantum)
263    }
264
265    /// How many whole quanta a ratio is worth here.
266    ///
267    /// What a cell-addressed layout actually wants: the count, not the size.
268    #[must_use]
269    pub fn quanta(self, ratio: Ratio) -> u32 {
270        ratio.quanta(self.base, self.quantum)
271    }
272
273    /// Resolve a relationship on this surface at a given density, in quanta.
274    ///
275    /// The whole model in one call: *what* is being separated, *who* is
276    /// operating it, *what* it is drawn on.
277    #[must_use]
278    pub fn gap(self, gap: Gap, density: Density) -> u32 {
279        self.quanta(gap.step_at(density).ratio())
280    }
281}
282
283/// Which input the layout is being sized for.
284///
285/// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides
286/// which step a relationship picks, the surface decides how that step lands.
287/// Which density applies is the app's call — GoingsOn and Balanced Breakfast
288/// already decide it once and hang a `ui-mode-*` class off the result.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
290pub enum Density {
291    /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
292    #[default]
293    Pointer,
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.
298    Touch,
299}
300
301/// A named separation between two things.
302///
303/// Pick by relationship. The size is a consequence of the name, not the other
304/// way round, and callers should never care what it is.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
306pub enum Gap {
307    /// A control and the thing it belongs to: an edit field and its pop-up, a
308    /// checkbox and its label, an icon and the text it labels. Reads as one
309    /// object.
310    Bound,
311    /// Items of the same kind in a list: stacked checkboxes, radio buttons,
312    /// rows, chips in a row. Reads as a set.
313    Peer,
314    /// A container's inner margin, and the distance between sibling groups
315    /// side by side. Reads as "inside this box".
316    Group,
317    /// Separated groups, and rows of actions. The first gap that reads as a
318    /// deliberate break rather than as breathing room.
319    Section,
320    /// Panel padding and content shells. Layout, not controls.
321    Pane,
322    /// The outermost shell margin. One per screen, usually.
323    Page,
324}
325
326/// A raw step on the underlying scale.
327///
328/// Present because not every distance is a relationship between two controls —
329/// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
330/// fits: a step name says how big, a gap name says why.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
332pub enum Step {
333    /// An eighth of the base. Optical nudges inside small inline elements.
334    Hair,
335    /// A quarter of the base.
336    Tight,
337    /// Three eighths of the base.
338    Snug,
339    /// Half the base.
340    Base,
341    /// Five eighths of the base.
342    Roomy,
343    /// Three quarters of the base.
344    Wide,
345    /// The base itself.
346    Loose,
347    /// One and a half times the base.
348    Broad,
349    /// Twice the base.
350    Vast,
351    /// Three times the base.
352    Colossal,
353}
354
355impl Step {
356    /// This step as a fraction of the base unit.
357    #[must_use]
358    pub const fn ratio(self) -> Ratio {
359        let (numerator, denominator) = match self {
360            Self::Hair => (1, 8),
361            Self::Tight => (1, 4),
362            Self::Snug => (3, 8),
363            Self::Base => (1, 2),
364            Self::Roomy => (5, 8),
365            Self::Wide => (3, 4),
366            Self::Loose => (1, 1),
367            Self::Broad => (3, 2),
368            Self::Vast => (2, 1),
369            Self::Colossal => (3, 1),
370        };
371        Ratio {
372            numerator,
373            denominator,
374        }
375    }
376
377    /// Size in CSS pixels at the default base.
378    #[must_use]
379    pub const fn px(self) -> u16 {
380        self.ratio().px_at(DEFAULT_BASE_PX)
381    }
382
383    /// The CSS custom-property name, without the leading `--`.
384    #[must_use]
385    pub const fn token(self) -> &'static str {
386        match self {
387            Self::Hair => "step-hair",
388            Self::Tight => "step-tight",
389            Self::Snug => "step-snug",
390            Self::Base => "step-base",
391            Self::Roomy => "step-roomy",
392            Self::Wide => "step-wide",
393            Self::Loose => "step-loose",
394            Self::Broad => "step-broad",
395            Self::Vast => "step-vast",
396            Self::Colossal => "step-colossal",
397        }
398    }
399
400    /// Every step, smallest first.
401    #[must_use]
402    pub const fn all() -> [Self; 10] {
403        [
404            Self::Hair,
405            Self::Tight,
406            Self::Snug,
407            Self::Base,
408            Self::Roomy,
409            Self::Wide,
410            Self::Loose,
411            Self::Broad,
412            Self::Vast,
413            Self::Colossal,
414        ]
415    }
416}
417
418impl Gap {
419    /// The step this relationship resolves to at a given density.
420    ///
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.
424    #[must_use]
425    pub const fn step_at(self, density: Density) -> Step {
426        match self {
427            // Binding is not a separation, so it does not open up on touch
428            // either: separating these would say they are two objects.
429            Self::Bound => Step::Tight,
430
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.
449            Self::Pane => Step::Broad,
450            Self::Page => Step::Vast,
451        }
452    }
453
454    /// The step this relationship resolves to at the default density.
455    #[must_use]
456    pub const fn step(self) -> Step {
457        self.step_at(Density::Pointer)
458    }
459
460    /// Size in CSS pixels at the default base, at a given density.
461    #[must_use]
462    pub const fn px_at(self, density: Density) -> u16 {
463        self.step_at(density).px()
464    }
465
466    /// Size in CSS pixels at the default base and density.
467    #[must_use]
468    pub const fn px(self) -> u16 {
469        self.step().px()
470    }
471
472    /// The CSS custom-property name, without the leading `--`.
473    #[must_use]
474    pub const fn token(self) -> &'static str {
475        match self {
476            Self::Bound => "gap-bound",
477            Self::Peer => "gap-peer",
478            Self::Group => "gap-group",
479            Self::Section => "gap-section",
480            Self::Pane => "gap-pane",
481            Self::Page => "gap-page",
482        }
483    }
484
485    /// Every relationship, tightest first.
486    #[must_use]
487    pub const fn all() -> [Self; 6] {
488        [
489            Self::Bound,
490            Self::Peer,
491            Self::Group,
492            Self::Section,
493            Self::Pane,
494            Self::Page,
495        ]
496    }
497}
498
499/// Emit the base unit and the raw scale as CSS declarations, no selector.
500///
501/// Density-invariant: the steps are the vocabulary, and only which step a
502/// relationship picks changes between presets.
503#[must_use]
504pub fn scale_css_declarations() -> String {
505    let mut out = String::new();
506    let _ = writeln!(
507        out,
508        "  /* Every size below is a ratio of this. Scale it and the whole\n     \
509         layout scales with it, including for a user who has asked for\n     \
510         larger text. */\n  --{BASE_TOKEN}: 1rem;\n"
511    );
512    out.push_str("  /* Raw scale. Prefer a --gap-* below; reach here only when\n");
513    out.push_str("     no relationship describes the distance. */\n");
514    for step in Step::all() {
515        let _ = writeln!(out, "  --{}: {};", step.token(), step.ratio().css());
516    }
517    out
518}
519
520/// Emit the relational layer for one density as CSS declarations, no selector.
521///
522/// Gaps reference their step rather than repeating a value, so the scale has
523/// exactly one definition and a reader can see which relationship maps where.
524#[must_use]
525pub fn gap_css_declarations(density: Density) -> String {
526    let mut out = String::new();
527    for gap in Gap::all() {
528        let _ = writeln!(
529            out,
530            "  --{}: var(--{});",
531            gap.token(),
532            gap.step_at(density).token()
533        );
534    }
535    out
536}
537
538/// Emit the whole geometry layer as a `:root { … }` block at one density.
539///
540/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
541/// constant, so a web consumer should bake it in at build time rather than
542/// apply it from JS on every load.
543#[must_use]
544pub fn geometry_css_vars(density: Density) -> String {
545    format!(
546        ":root {{\n{}\n{}}}\n",
547        scale_css_declarations(),
548        gap_css_declarations(density)
549    )
550}
551
552/// The whole spacing layer with the canonical density selection, as CSS.
553///
554/// **Density is a capability, not a device and not a width.** A narrow window
555/// on a desktop still has a pointer in it and a tablet at full width still has
556/// a finger, so the touch preset hangs off `(hover: none), (pointer: coarse)`
557/// rather than off a breakpoint or a user-agent string. That is the question
558/// the platform actually answers, and it is the one [`Density`] is asking.
559///
560/// `explicit_touch` names a selector an app sets when the *user* has chosen.
561/// It is emitted last and therefore wins at equal specificity, because
562/// detection is a default rather than a verdict: a touchscreen laptop and
563/// someone who simply wants roomier targets are both real, and neither is
564/// visible to a media query.
565///
566/// Settles a policy three consumers previously answered three ways. GoingsOn
567/// sniffed the user agent behind a mode class, Balanced Breakfast used
568/// `(hover: none)` alone, and audiofiles had no switch at all; the first of
569/// those asked what device this is as a proxy for a capability already
570/// reported.
571#[must_use]
572pub fn density_css(explicit_touch: Option<&str>) -> String {
573    let mut css = geometry_css_vars(Density::Pointer);
574    css.push_str("\n/* Touch: targets separate, shells hold. */\n");
575    css.push_str("@media (hover: none), (pointer: coarse) {\n");
576    for line in gap_css_overrides(":root", Density::Touch).lines() {
577        css.push_str("    ");
578        css.push_str(line);
579        css.push('\n');
580    }
581    css.push_str("}\n");
582    if let Some(selector) = explicit_touch {
583        css.push_str("\n/* An explicit user choice, last so it wins over detection. */\n");
584        css.push_str(&gap_css_overrides(selector, Density::Touch));
585    }
586    css
587}
588
589/// Emit a density preset as a scoped override block.
590///
591/// Only the relational layer is emitted: the scale and the base do not change
592/// between presets, so an app ships [`geometry_css_vars`] at its default
593/// density and one of these per mode class it supports.
594///
595/// ```
596/// # use makeover_geometry::{Density, gap_css_overrides};
597/// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
598/// assert!(css.starts_with(".ui-mode-mobile {\n"));
599/// ```
600#[must_use]
601pub fn gap_css_overrides(selector: &str, density: Density) -> String {
602    format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn density_is_selected_by_capability_not_by_width_or_agent() {
611        let css = density_css(None);
612        assert!(css.contains("@media (hover: none), (pointer: coarse)"));
613        // The three things density must never be selected by.
614        assert!(!css.contains("max-width"), "a breakpoint crept in");
615        assert!(!css.contains("min-width"), "a breakpoint crept in");
616        assert!(!css.contains("ui-mode"), "a device mode crept in");
617    }
618
619    #[test]
620    fn an_explicit_choice_is_emitted_after_the_detection() {
621        let css = density_css(Some(".ui-mode-mobile"));
622        let media = css.find("@media").expect("media query");
623        let explicit = css.find(".ui-mode-mobile").expect("explicit selector");
624        // Equal specificity, so order is the whole mechanism: the user's
625        // choice has to come last or detection quietly overrides it.
626        assert!(explicit > media, "the explicit selector must come last");
627    }
628
629    #[test]
630    fn without_an_explicit_selector_there_are_exactly_two_presets() {
631        assert_eq!(density_css(None).matches("--gap-peer").count(), 2);
632    }
633
634    #[test]
635    fn the_hig_relationships_land_on_the_hig_values() {
636        // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
637        // the default base they must resolve to the numbers the HIG specifies,
638        // or the departure has cost us the thing it was translating.
639        assert_eq!(Gap::Bound.px(), 4);
640        assert_eq!(Gap::Peer.px(), 6);
641        assert_eq!(Gap::Group.px(), 10);
642        assert_eq!(Gap::Section.px(), 12);
643    }
644
645    #[test]
646    fn every_ratio_divides_the_default_base_exactly() {
647        for step in Step::all() {
648            let r = step.ratio();
649            assert_eq!(
650                u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
651                0,
652                "{step:?} is fractional at the default base"
653            );
654        }
655    }
656
657    #[test]
658    fn ratios_scale_linearly() {
659        for step in Step::all() {
660            assert_eq!(
661                step.ratio().px_at(DEFAULT_BASE_PX * 2),
662                step.px() * 2,
663                "{step:?} does not double with the base"
664            );
665        }
666    }
667
668    #[test]
669    fn steps_ascend_and_never_repeat() {
670        let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
671        let mut sorted = px.clone();
672        sorted.sort_unstable();
673        sorted.dedup();
674        assert_eq!(px, sorted, "steps must be strictly ascending");
675    }
676
677    #[test]
678    fn gaps_ascend_with_their_relationships_at_every_density() {
679        for density in [Density::Pointer, Density::Touch] {
680            let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
681            let mut sorted = px.clone();
682            sorted.sort_unstable();
683            assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
684        }
685    }
686
687    #[test]
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] {
700            assert_eq!(
701                gap.px_at(Density::Touch),
702                gap.px_at(Density::Pointer),
703                "{gap:?} is not a tap target and must not move with the input device"
704            );
705        }
706    }
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
741    #[test]
742    fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
743        let t = Surface::terminal();
744        let cells: Vec<u32> = Gap::all()
745            .iter()
746            .map(|g| t.gap(*g, Density::Pointer))
747            .collect();
748        // bound, peer | group, section | pane, page
749        assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
750    }
751
752    #[test]
753    fn collapsing_is_allowed_but_inverting_is_not() {
754        // A coarse surface has fewer distinctions, so neighbouring gaps may
755        // land on the same quantum. What must never happen is a looser
756        // relationship coming out tighter than a closer one.
757        for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
758            for density in [Density::Pointer, Density::Touch] {
759                let s = Surface {
760                    base: 16.0,
761                    quantum,
762                };
763                let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
764                let mut sorted = v.clone();
765                sorted.sort_unstable();
766                assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
767            }
768        }
769    }
770
771    #[test]
772    fn the_web_surface_agrees_with_the_pixel_helper() {
773        let w = Surface::web();
774        for step in Step::all() {
775            assert_eq!(
776                w.resolve(step.ratio()) as u16,
777                step.px(),
778                "{step:?} disagrees between surface and px_at"
779            );
780        }
781    }
782
783    #[test]
784    fn quantising_is_monotonic_in_the_ratio() {
785        let (base, quantum) = (16.0, 1.0);
786        let mut previous = 0;
787        for step in Step::all() {
788            let q = step.ratio().quanta(base, quantum);
789            assert!(q >= previous, "{step:?} went backwards");
790            previous = q;
791        }
792    }
793
794    #[test]
795    fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
796        let r = Step::Loose.ratio();
797        for bad in [0.0_f32, -1.0, f32::NAN] {
798            assert_eq!(r.quanta(16.0, bad), 0);
799            assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
800        }
801        assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
802    }
803
804    #[test]
805    fn px_at_rounds_rather_than_truncating() {
806        // Eighths divide 16 exactly, so the rounding only shows on a base
807        // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
808        assert_eq!(Step::Snug.ratio().px_at(15), 6);
809        assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
810    }
811
812    #[test]
813    fn tokens_are_unique() {
814        let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
815        names.extend(Gap::all().iter().map(|g| g.token()));
816        let count = names.len();
817        names.sort_unstable();
818        names.dedup();
819        assert_eq!(names.len(), count, "token names collide");
820    }
821
822    #[test]
823    fn css_is_expressed_over_the_base_never_in_pixels() {
824        let css = geometry_css_vars(Density::Pointer);
825        assert!(css.starts_with(":root {\n"));
826        assert!(css.trim_end().ends_with('}'));
827        assert!(css.contains("--geometry-base: 1rem;"));
828        for step in Step::all() {
829            let line = format!("--{}: {}", step.token(), step.ratio().css());
830            assert!(css.contains(&line), "missing or wrong: {line}");
831        }
832        // A hard pixel count anywhere in the scale defeats the point.
833        let scale = scale_css_declarations();
834        assert!(
835            !scale.contains("px;"),
836            "the scale must not emit pixel literals:\n{scale}"
837        );
838    }
839
840    #[test]
841    fn ratio_css_drops_redundant_arithmetic() {
842        assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
843        assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
844        assert_eq!(
845            Step::Snug.ratio().css(),
846            "calc(var(--geometry-base) * 3 / 8)"
847        );
848    }
849
850    #[test]
851    fn gaps_reference_steps_rather_than_repeating_values() {
852        let css = geometry_css_vars(Density::Pointer);
853        assert!(css.contains("--gap-peer: var(--step-snug);"));
854        assert!(!css.contains("--gap-peer: calc"));
855    }
856
857    #[test]
858    fn a_density_override_emits_only_the_relational_layer() {
859        let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
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.
863        assert!(css.contains("--gap-peer: var(--step-"));
864        // Referencing a step is the point; re-declaring one would fork the
865        // scale, so the check is on declarations, not on mentions.
866        let declared: Vec<&str> = css
867            .lines()
868            .filter_map(|l| l.trim().strip_prefix("--"))
869            .filter_map(|l| l.split(':').next())
870            .collect();
871        assert!(
872            declared.iter().all(|t| t.starts_with("gap-")),
873            "only the relational layer may be overridden, got {declared:?}"
874        );
875    }
876}