makeover-geometry 0.2.0

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.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
//! The invariant half of the make-family design system.
//!
//! <!-- wiki: makeover-geometry -->
//!
//! [`makeover`] resolves colour, which varies by theme. This crate carries
//! everything that does not: spacing, radius, border width and the type scale.
//! The split is the same one Balanced Breakfast's theme contract has always
//! drawn — *a theme overrides colour tokens only* — moved out of two app
//! stylesheets so the three consumers stop maintaining three copies of it.
//!
//! # Spacing is relational, not numeric
//!
//! The Mac OS 8 Human Interface Guidelines specify white space by *what two
//! things are being separated*, never by a size name, and define no base grid
//! unit. A control and its satellite pop-up are set 4 pixels apart; peers
//! stacked in a list get 6; a group box's inner margin is 10; separated groups
//! and rows of push buttons get 12.
//!
//! That vocabulary is the primary interface here. [`Gap`] names the
//! relationship and the size follows from it, exactly as `surface-raised`
//! names an intent and the hex follows from it. The raw [`Step`] scale exists
//! underneath for distances a relationship does not describe, but reaching for
//! it is a smell worth a second look.
//!
//! Naming the relationship is what makes the rule reviewable. Whether a gap
//! should be 6px or 8px is unanswerable in isolation; whether two things are
//! peers is not.
//!
//! # Ratios, not pixel counts
//!
//! This is the deliberate departure from the HIG, which is specified in hard
//! device pixels because in 1997 there was one pixel density and one text
//! size. Every [`Step`] here is a [`Ratio`] of a single base unit, so the
//! whole system scales from one knob: `--geometry-base`, `1rem` by default.
//!
//! At the default base the ratios land exactly on the HIG's numbers — `Snug`
//! is three eighths of 16px, which is 6px — so nothing is lost in the
//! translation. What is gained is that the layout tracks the user's text size
//! instead of fighting it, an accessibility setting becomes one value rather
//! than a sweep, and the scale means the same thing at any display density.
//!
//! # Density presets
//!
//! Naming relationships instead of sizes is what makes a density preset
//! possible at all. [`Density`] changes what each relationship resolves to
//! without touching a single call site, because no call site names a size.
//! The mobile and desktop builds of a Tauri app should differ mostly by which
//! preset they emit, not by a parallel set of hand-written mode-scoped rules.
//!
//! The two presets are not one scaled copy of the other, and the asymmetry is
//! the point — it is also why a base scalar alone cannot express this. Touch
//! needs *more* room between things you tap, because a fingertip is coarser
//! than a pointer, and *less* room around the edges, because the screen is
//! small and outer margin is screen you do not get to use. So `Peer` and
//! `Section` open up under [`Density::Touch`] while `Pane` and `Page` tighten.
//! `Bound` never moves: it is the one relationship that says "these are one
//! object", and separating them on touch would say the opposite.
//!
//! # Surfaces, and why a TUI is not a third density
//!
//! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
//! terminal is not a density preset; it is a surface whose smallest
//! representable step is one cell rather than one pixel. Give
//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
//! relational vocabulary crosses to a character grid with nothing added.
//!
//! Quantising is not a terminal special case either — a display quantises to
//! the pixel. It is only that rounding 6.0 to the nearest pixel is
//! uninteresting, while rounding three eighths of a cell to the nearest cell
//! decides the layout.
//!
//! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2
//! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy:
//! in a grid that dense, both relationships are expressed by adjacency. A
//! coarse surface genuinely has fewer distinctions available, and the model
//! should say so instead of inventing a gap to keep six names distinct.
//!
//! So the three axes are: [`Gap`] is what is being separated, [`Density`] is
//! who is operating it, [`Surface`] is what it is drawn on.

#![forbid(unsafe_code)]

use std::fmt::Write as _;

/// The default base unit in CSS pixels, at a 16px root font size.
pub const DEFAULT_BASE_PX: u16 = 16;

/// The CSS custom property every ratio scales from.
pub const BASE_TOKEN: &str = "geometry-base";

/// A fraction of the base unit.
///
/// Rational rather than floating point so the scale is exact, comparable and
/// usable in a `const`. At the default base every ratio below divides evenly.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Ratio {
    /// Top of the fraction.
    pub numerator: u16,
    /// Bottom of the fraction. Never zero for any ratio this crate defines.
    pub denominator: u16,
}

impl Ratio {
    /// Resolve against a base measured in whole pixels, rounding to nearest.
    ///
    /// Integer maths throughout, and exact for every [`Step`] at
    /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is
    /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the
    /// common case stays `const`.
    #[must_use]
    pub const fn px_at(self, base_px: u16) -> u16 {
        let (n, d) = (self.numerator as u32, self.denominator as u32);
        let scaled = base_px as u32 * n;
        // Round half away from zero without leaving integer arithmetic.
        ((scaled * 2 + d) / (d * 2)) as u16
    }

    /// Resolve against an arbitrary base, keeping the fraction.
    ///
    /// The exact value, before any surface gets a say. Prefer
    /// [`Surface::resolve`] unless you specifically want the unsnapped number.
    #[must_use]
    pub fn scale(self, base: f32) -> f32 {
        base * f32::from(self.numerator) / f32::from(self.denominator)
    }

    /// How many whole quanta this ratio is worth on a surface whose smallest
    /// representable step is `quantum`.
    ///
    /// The generalisation of "round to a pixel". A display quantises to one
    /// pixel and the answer is usually uninteresting; a terminal quantises to
    /// one cell and the answer is the whole design. Rounds to nearest, and
    /// does not floor at one: a gap that lands below half a quantum should
    /// collapse to nothing, because on that surface it *is* nothing.
    ///
    /// A `quantum` that is zero, negative or not finite yields `0` rather than
    /// panicking or returning infinity — a surface with no smallest step is a
    /// caller error, not a layout to guess at.
    #[must_use]
    pub fn quanta(self, base: f32, quantum: f32) -> u32 {
        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
            return 0;
        }
        let exact = self.scale(base) / quantum;
        if exact <= 0.0 {
            0
        } else {
            // `as` saturates at the integer bound, so a wild base cannot wrap.
            exact.round() as u32
        }
    }

    /// Resolve against a base and snap to a whole number of `quantum`.
    ///
    /// The value [`Self::quanta`] counts, back in the surface's own units.
    /// Guards the degenerate quantum in its own right rather than leaning on
    /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN,
    /// not zero.
    #[must_use]
    pub fn quantize(self, base: f32, quantum: f32) -> f32 {
        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
            return 0.0;
        }
        self.quanta(base, quantum) as f32 * quantum
    }

    /// The CSS value, as an expression over [`BASE_TOKEN`].
    ///
    /// A whole multiple of the base emits without a division, and 1:1 emits
    /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
    #[must_use]
    pub fn css(self) -> String {
        match (self.numerator, self.denominator) {
            (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
            (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
            (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
        }
    }
}

/// What the layout is being drawn on: a base unit, and the smallest step the
/// surface can actually represent.
///
/// Both are in the surface's own units, and the crate never assumes those are
/// pixels. A display measures in pixels and can represent one of them; a
/// terminal measures in cells and cannot represent less than one. That single
/// difference is the whole of the terminal story — a TUI is not a density, it
/// is a surface with a coarse quantum, and the relational vocabulary above
/// crosses over untouched.
///
/// Quantising is not a terminal special case. A display does it too; it is
/// just that rounding 6.0 to the nearest pixel is uninteresting, whereas
/// rounding three eighths of a cell to the nearest cell is a design decision
/// the surface makes for you.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Surface {
    /// The base unit, in this surface's units.
    pub base: f32,
    /// The smallest step this surface can represent, in the same units.
    pub quantum: f32,
}

impl Surface {
    /// A display measuring in CSS pixels: a 16px base, one-pixel quantum.
    #[must_use]
    pub fn web() -> Self {
        Self {
            base: f32::from(DEFAULT_BASE_PX),
            quantum: 1.0,
        }
    }

    /// A terminal measuring in cells: a one-cell base, one-cell quantum.
    ///
    /// The coarsest surface in the family, and the one that proves the
    /// vocabulary. `bound` and `peer` collapse to no cells at all, which is
    /// correct — in a grid this dense, "belongs to" and "is a peer of" are
    /// both expressed by adjacency, not by a gap.
    #[must_use]
    pub fn terminal() -> Self {
        Self {
            base: 1.0,
            quantum: 1.0,
        }
    }

    /// Resolve a ratio on this surface, snapped to its quantum.
    #[must_use]
    pub fn resolve(self, ratio: Ratio) -> f32 {
        ratio.quantize(self.base, self.quantum)
    }

    /// How many whole quanta a ratio is worth here.
    ///
    /// What a cell-addressed layout actually wants: the count, not the size.
    #[must_use]
    pub fn quanta(self, ratio: Ratio) -> u32 {
        ratio.quanta(self.base, self.quantum)
    }

    /// Resolve a relationship on this surface at a given density, in quanta.
    ///
    /// The whole model in one call: *what* is being separated, *who* is
    /// operating it, *what* it is drawn on.
    #[must_use]
    pub fn gap(self, gap: Gap, density: Density) -> u32 {
        self.quanta(gap.step_at(density).ratio())
    }
}

/// Which input the layout is being sized for.
///
/// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides
/// which step a relationship picks, the surface decides how that step lands.
/// Which density applies is the app's call — GoingsOn and Balanced Breakfast
/// already decide it once and hang a `ui-mode-*` class off the result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Density {
    /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
    #[default]
    Pointer,
    /// Finger. Targets separate, shells tighten.
    Touch,
}

/// A named separation between two things.
///
/// Pick by relationship. The size is a consequence of the name, not the other
/// way round, and callers should never care what it is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Gap {
    /// A control and the thing it belongs to: an edit field and its pop-up, a
    /// checkbox and its label, an icon and the text it labels. Reads as one
    /// object.
    Bound,
    /// Items of the same kind in a list: stacked checkboxes, radio buttons,
    /// rows, chips in a row. Reads as a set.
    Peer,
    /// A container's inner margin, and the distance between sibling groups
    /// side by side. Reads as "inside this box".
    Group,
    /// Separated groups, and rows of actions. The first gap that reads as a
    /// deliberate break rather than as breathing room.
    Section,
    /// Panel padding and content shells. Layout, not controls.
    Pane,
    /// The outermost shell margin. One per screen, usually.
    Page,
}

/// A raw step on the underlying scale.
///
/// Present because not every distance is a relationship between two controls —
/// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
/// fits: a step name says how big, a gap name says why.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Step {
    /// An eighth of the base. Optical nudges inside small inline elements.
    Hair,
    /// A quarter of the base.
    Tight,
    /// Three eighths of the base.
    Snug,
    /// Half the base.
    Base,
    /// Five eighths of the base.
    Roomy,
    /// Three quarters of the base.
    Wide,
    /// The base itself.
    Loose,
    /// One and a half times the base.
    Broad,
    /// Twice the base.
    Vast,
    /// Three times the base.
    Colossal,
}

impl Step {
    /// This step as a fraction of the base unit.
    #[must_use]
    pub const fn ratio(self) -> Ratio {
        let (numerator, denominator) = match self {
            Self::Hair => (1, 8),
            Self::Tight => (1, 4),
            Self::Snug => (3, 8),
            Self::Base => (1, 2),
            Self::Roomy => (5, 8),
            Self::Wide => (3, 4),
            Self::Loose => (1, 1),
            Self::Broad => (3, 2),
            Self::Vast => (2, 1),
            Self::Colossal => (3, 1),
        };
        Ratio {
            numerator,
            denominator,
        }
    }

    /// Size in CSS pixels at the default base.
    #[must_use]
    pub const fn px(self) -> u16 {
        self.ratio().px_at(DEFAULT_BASE_PX)
    }

    /// The CSS custom-property name, without the leading `--`.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Hair => "step-hair",
            Self::Tight => "step-tight",
            Self::Snug => "step-snug",
            Self::Base => "step-base",
            Self::Roomy => "step-roomy",
            Self::Wide => "step-wide",
            Self::Loose => "step-loose",
            Self::Broad => "step-broad",
            Self::Vast => "step-vast",
            Self::Colossal => "step-colossal",
        }
    }

    /// Every step, smallest first.
    #[must_use]
    pub const fn all() -> [Self; 10] {
        [
            Self::Hair,
            Self::Tight,
            Self::Snug,
            Self::Base,
            Self::Roomy,
            Self::Wide,
            Self::Loose,
            Self::Broad,
            Self::Vast,
            Self::Colossal,
        ]
    }
}

impl Gap {
    /// The step this relationship resolves to at a given density.
    #[must_use]
    pub const fn step_at(self, density: Density) -> Step {
        match (self, density) {
            // Binding is not a separation, so it does not open up on touch.
            (Self::Bound, _) => Step::Tight,

            (Self::Peer, Density::Pointer) => Step::Snug,
            (Self::Peer, Density::Touch) => Step::Roomy,

            (Self::Group, Density::Pointer) => Step::Roomy,
            (Self::Group, Density::Touch) => Step::Wide,

            (Self::Section, Density::Pointer) => Step::Wide,
            (Self::Section, Density::Touch) => Step::Loose,

            // Shells tighten on touch: outer margin is screen you don't get.
            (Self::Pane, Density::Pointer) => Step::Broad,
            (Self::Pane, Density::Touch) => Step::Loose,

            (Self::Page, Density::Pointer) => Step::Vast,
            (Self::Page, Density::Touch) => Step::Broad,
        }
    }

    /// The step this relationship resolves to at the default density.
    #[must_use]
    pub const fn step(self) -> Step {
        self.step_at(Density::Pointer)
    }

    /// Size in CSS pixels at the default base, at a given density.
    #[must_use]
    pub const fn px_at(self, density: Density) -> u16 {
        self.step_at(density).px()
    }

    /// Size in CSS pixels at the default base and density.
    #[must_use]
    pub const fn px(self) -> u16 {
        self.step().px()
    }

    /// The CSS custom-property name, without the leading `--`.
    #[must_use]
    pub const fn token(self) -> &'static str {
        match self {
            Self::Bound => "gap-bound",
            Self::Peer => "gap-peer",
            Self::Group => "gap-group",
            Self::Section => "gap-section",
            Self::Pane => "gap-pane",
            Self::Page => "gap-page",
        }
    }

    /// Every relationship, tightest first.
    #[must_use]
    pub const fn all() -> [Self; 6] {
        [
            Self::Bound,
            Self::Peer,
            Self::Group,
            Self::Section,
            Self::Pane,
            Self::Page,
        ]
    }
}

/// Emit the base unit and the raw scale as CSS declarations, no selector.
///
/// Density-invariant: the steps are the vocabulary, and only which step a
/// relationship picks changes between presets.
#[must_use]
pub fn scale_css_declarations() -> String {
    let mut out = String::new();
    let _ = writeln!(
        out,
        "  /* Every size below is a ratio of this. Scale it and the whole\n     \
         layout scales with it, including for a user who has asked for\n     \
         larger text. */\n  --{BASE_TOKEN}: 1rem;\n"
    );
    out.push_str("  /* Raw scale. Prefer a --gap-* below; reach here only when\n");
    out.push_str("     no relationship describes the distance. */\n");
    for step in Step::all() {
        let _ = writeln!(out, "  --{}: {};", step.token(), step.ratio().css());
    }
    out
}

/// Emit the relational layer for one density as CSS declarations, no selector.
///
/// Gaps reference their step rather than repeating a value, so the scale has
/// exactly one definition and a reader can see which relationship maps where.
#[must_use]
pub fn gap_css_declarations(density: Density) -> String {
    let mut out = String::new();
    for gap in Gap::all() {
        let _ = writeln!(
            out,
            "  --{}: var(--{});",
            gap.token(),
            gap.step_at(density).token()
        );
    }
    out
}

/// Emit the whole geometry layer as a `:root { … }` block at one density.
///
/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
/// constant, so a web consumer should bake it in at build time rather than
/// apply it from JS on every load.
#[must_use]
pub fn geometry_css_vars(density: Density) -> String {
    format!(
        ":root {{\n{}\n{}}}\n",
        scale_css_declarations(),
        gap_css_declarations(density)
    )
}

/// The whole spacing layer with the canonical density selection, as CSS.
///
/// **Density is a capability, not a device and not a width.** A narrow window
/// on a desktop still has a pointer in it and a tablet at full width still has
/// a finger, so the touch preset hangs off `(hover: none), (pointer: coarse)`
/// rather than off a breakpoint or a user-agent string. That is the question
/// the platform actually answers, and it is the one [`Density`] is asking.
///
/// `explicit_touch` names a selector an app sets when the *user* has chosen.
/// It is emitted last and therefore wins at equal specificity, because
/// detection is a default rather than a verdict: a touchscreen laptop and
/// someone who simply wants roomier targets are both real, and neither is
/// visible to a media query.
///
/// Settles a policy three consumers previously answered three ways. GoingsOn
/// sniffed the user agent behind a mode class, Balanced Breakfast used
/// `(hover: none)` alone, and audiofiles had no switch at all; the first of
/// those asked what device this is as a proxy for a capability already
/// reported.
#[must_use]
pub fn density_css(explicit_touch: Option<&str>) -> String {
    let mut css = geometry_css_vars(Density::Pointer);
    css.push_str("\n@media (hover: none), (pointer: coarse) {\n");
    for line in gap_css_overrides(":root", Density::Touch).lines() {
        css.push_str("    ");
        css.push_str(line);
        css.push('\n');
    }
    css.push_str("}\n");
    if let Some(selector) = explicit_touch {
        css.push_str("\n/* An explicit user choice, last so it wins over detection. */\n");
        css.push_str(&gap_css_overrides(selector, Density::Touch));
    }
    css
}

/// Emit a density preset as a scoped override block.
///
/// Only the relational layer is emitted: the scale and the base do not change
/// between presets, so an app ships [`geometry_css_vars`] at its default
/// density and one of these per mode class it supports.
///
/// ```
/// # use makeover_geometry::{Density, gap_css_overrides};
/// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
/// assert!(css.starts_with(".ui-mode-mobile {\n"));
/// ```
#[must_use]
pub fn gap_css_overrides(selector: &str, density: Density) -> String {
    format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn density_is_selected_by_capability_not_by_width_or_agent() {
        let css = density_css(None);
        assert!(css.contains("@media (hover: none), (pointer: coarse)"));
        // The three things density must never be selected by.
        assert!(!css.contains("max-width"), "a breakpoint crept in");
        assert!(!css.contains("min-width"), "a breakpoint crept in");
        assert!(!css.contains("ui-mode"), "a device mode crept in");
    }

    #[test]
    fn an_explicit_choice_is_emitted_after_the_detection() {
        let css = density_css(Some(".ui-mode-mobile"));
        let media = css.find("@media").expect("media query");
        let explicit = css.find(".ui-mode-mobile").expect("explicit selector");
        // Equal specificity, so order is the whole mechanism: the user's
        // choice has to come last or detection quietly overrides it.
        assert!(explicit > media, "the explicit selector must come last");
    }

    #[test]
    fn without_an_explicit_selector_there_are_exactly_two_presets() {
        assert_eq!(density_css(None).matches("--gap-peer").count(), 2);
    }

    #[test]
    fn the_hig_relationships_land_on_the_hig_values() {
        // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
        // the default base they must resolve to the numbers the HIG specifies,
        // or the departure has cost us the thing it was translating.
        assert_eq!(Gap::Bound.px(), 4);
        assert_eq!(Gap::Peer.px(), 6);
        assert_eq!(Gap::Group.px(), 10);
        assert_eq!(Gap::Section.px(), 12);
    }

    #[test]
    fn every_ratio_divides_the_default_base_exactly() {
        for step in Step::all() {
            let r = step.ratio();
            assert_eq!(
                u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
                0,
                "{step:?} is fractional at the default base"
            );
        }
    }

    #[test]
    fn ratios_scale_linearly() {
        for step in Step::all() {
            assert_eq!(
                step.ratio().px_at(DEFAULT_BASE_PX * 2),
                step.px() * 2,
                "{step:?} does not double with the base"
            );
        }
    }

    #[test]
    fn steps_ascend_and_never_repeat() {
        let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
        let mut sorted = px.clone();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(px, sorted, "steps must be strictly ascending");
    }

    #[test]
    fn gaps_ascend_with_their_relationships_at_every_density() {
        for density in [Density::Pointer, Density::Touch] {
            let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
            let mut sorted = px.clone();
            sorted.sort_unstable();
            assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
        }
    }

    #[test]
    fn touch_separates_targets_and_tightens_shells() {
        // The asymmetry is the whole reason a base scalar would not do.
        for gap in [Gap::Peer, Gap::Section] {
            assert!(
                gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
                "{gap:?} must open up for a fingertip"
            );
        }
        for gap in [Gap::Pane, Gap::Page] {
            assert!(
                gap.px_at(Density::Touch) < gap.px_at(Density::Pointer),
                "{gap:?} must tighten on a small screen"
            );
        }
        assert_eq!(
            Gap::Bound.px_at(Density::Touch),
            Gap::Bound.px_at(Density::Pointer),
            "bound things stay bound"
        );
    }

    #[test]
    fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
        let t = Surface::terminal();
        let cells: Vec<u32> = Gap::all()
            .iter()
            .map(|g| t.gap(*g, Density::Pointer))
            .collect();
        // bound, peer | group, section | pane, page
        assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
    }

    #[test]
    fn collapsing_is_allowed_but_inverting_is_not() {
        // A coarse surface has fewer distinctions, so neighbouring gaps may
        // land on the same quantum. What must never happen is a looser
        // relationship coming out tighter than a closer one.
        for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
            for density in [Density::Pointer, Density::Touch] {
                let s = Surface {
                    base: 16.0,
                    quantum,
                };
                let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
                let mut sorted = v.clone();
                sorted.sort_unstable();
                assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
            }
        }
    }

    #[test]
    fn the_web_surface_agrees_with_the_pixel_helper() {
        let w = Surface::web();
        for step in Step::all() {
            assert_eq!(
                w.resolve(step.ratio()) as u16,
                step.px(),
                "{step:?} disagrees between surface and px_at"
            );
        }
    }

    #[test]
    fn quantising_is_monotonic_in_the_ratio() {
        let (base, quantum) = (16.0, 1.0);
        let mut previous = 0;
        for step in Step::all() {
            let q = step.ratio().quanta(base, quantum);
            assert!(q >= previous, "{step:?} went backwards");
            previous = q;
        }
    }

    #[test]
    fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
        let r = Step::Loose.ratio();
        for bad in [0.0_f32, -1.0, f32::NAN] {
            assert_eq!(r.quanta(16.0, bad), 0);
            assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
        }
        assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
    }

    #[test]
    fn px_at_rounds_rather_than_truncating() {
        // Eighths divide 16 exactly, so the rounding only shows on a base
        // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
        assert_eq!(Step::Snug.ratio().px_at(15), 6);
        assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
    }

    #[test]
    fn tokens_are_unique() {
        let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
        names.extend(Gap::all().iter().map(|g| g.token()));
        let count = names.len();
        names.sort_unstable();
        names.dedup();
        assert_eq!(names.len(), count, "token names collide");
    }

    #[test]
    fn css_is_expressed_over_the_base_never_in_pixels() {
        let css = geometry_css_vars(Density::Pointer);
        assert!(css.starts_with(":root {\n"));
        assert!(css.trim_end().ends_with('}'));
        assert!(css.contains("--geometry-base: 1rem;"));
        for step in Step::all() {
            let line = format!("--{}: {}", step.token(), step.ratio().css());
            assert!(css.contains(&line), "missing or wrong: {line}");
        }
        // A hard pixel count anywhere in the scale defeats the point.
        let scale = scale_css_declarations();
        assert!(
            !scale.contains("px;"),
            "the scale must not emit pixel literals:\n{scale}"
        );
    }

    #[test]
    fn ratio_css_drops_redundant_arithmetic() {
        assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
        assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
        assert_eq!(
            Step::Snug.ratio().css(),
            "calc(var(--geometry-base) * 3 / 8)"
        );
    }

    #[test]
    fn gaps_reference_steps_rather_than_repeating_values() {
        let css = geometry_css_vars(Density::Pointer);
        assert!(css.contains("--gap-peer: var(--step-snug);"));
        assert!(!css.contains("--gap-peer: calc"));
    }

    #[test]
    fn a_density_override_emits_only_the_relational_layer() {
        let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
        assert!(css.contains("--gap-peer: var(--step-roomy);"));
        // Referencing a step is the point; re-declaring one would fork the
        // scale, so the check is on declarations, not on mentions.
        let declared: Vec<&str> = css
            .lines()
            .filter_map(|l| l.trim().strip_prefix("--"))
            .filter_map(|l| l.split(':').next())
            .collect();
        assert!(
            declared.iter().all(|t| t.starts_with("gap-")),
            "only the relational layer may be overridden, got {declared:?}"
        );
    }
}