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//! The two presets are not one scaled copy of the other, and the asymmetry is
51//! the point — it is also why a base scalar alone cannot express this. Touch
52//! needs *more* room between things you tap, because a fingertip is coarser
53//! than a pointer, and *less* room around the edges, because the screen is
54//! small and outer margin is screen you do not get to use. So `Peer` and
55//! `Section` open up under [`Density::Touch`] while `Pane` and `Page` tighten.
56//! `Bound` never moves: it is the one relationship that says "these are one
57//! object", and separating them on touch would say the opposite.
58//!
59//! # Surfaces, and why a TUI is not a third density
60//!
61//! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
62//! terminal is not a density preset; it is a surface whose smallest
63//! representable step is one cell rather than one pixel. Give
64//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
65//! relational vocabulary crosses to a character grid with nothing added.
66//!
67//! Quantising is not a terminal special case either — a display quantises to
68//! the pixel. It is only that rounding 6.0 to the nearest pixel is
69//! uninteresting, while rounding three eighths of a cell to the nearest cell
70//! decides the layout.
71//!
72//! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2
73//! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy:
74//! in a grid that dense, both relationships are expressed by adjacency. A
75//! coarse surface genuinely has fewer distinctions available, and the model
76//! should say so instead of inventing a gap to keep six names distinct.
77//!
78//! So the three axes are: [`Gap`] is what is being separated, [`Density`] is
79//! who is operating it, [`Surface`] is what it is drawn on.
80
81#![forbid(unsafe_code)]
82
83use std::fmt::Write as _;
84
85/// The default base unit in CSS pixels, at a 16px root font size.
86pub const DEFAULT_BASE_PX: u16 = 16;
87
88/// The CSS custom property every ratio scales from.
89pub const BASE_TOKEN: &str = "geometry-base";
90
91/// A fraction of the base unit.
92///
93/// Rational rather than floating point so the scale is exact, comparable and
94/// usable in a `const`. At the default base every ratio below divides evenly.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub struct Ratio {
97    /// Top of the fraction.
98    pub numerator: u16,
99    /// Bottom of the fraction. Never zero for any ratio this crate defines.
100    pub denominator: u16,
101}
102
103impl Ratio {
104    /// Resolve against a base measured in whole pixels, rounding to nearest.
105    ///
106    /// Integer maths throughout, and exact for every [`Step`] at
107    /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is
108    /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the
109    /// common case stays `const`.
110    #[must_use]
111    pub const fn px_at(self, base_px: u16) -> u16 {
112        let (n, d) = (self.numerator as u32, self.denominator as u32);
113        let scaled = base_px as u32 * n;
114        // Round half away from zero without leaving integer arithmetic.
115        ((scaled * 2 + d) / (d * 2)) as u16
116    }
117
118    /// Resolve against an arbitrary base, keeping the fraction.
119    ///
120    /// The exact value, before any surface gets a say. Prefer
121    /// [`Surface::resolve`] unless you specifically want the unsnapped number.
122    #[must_use]
123    pub fn scale(self, base: f32) -> f32 {
124        base * f32::from(self.numerator) / f32::from(self.denominator)
125    }
126
127    /// How many whole quanta this ratio is worth on a surface whose smallest
128    /// representable step is `quantum`.
129    ///
130    /// The generalisation of "round to a pixel". A display quantises to one
131    /// pixel and the answer is usually uninteresting; a terminal quantises to
132    /// one cell and the answer is the whole design. Rounds to nearest, and
133    /// does not floor at one: a gap that lands below half a quantum should
134    /// collapse to nothing, because on that surface it *is* nothing.
135    ///
136    /// A `quantum` that is zero, negative or not finite yields `0` rather than
137    /// panicking or returning infinity — a surface with no smallest step is a
138    /// caller error, not a layout to guess at.
139    #[must_use]
140    pub fn quanta(self, base: f32, quantum: f32) -> u32 {
141        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
142            return 0;
143        }
144        let exact = self.scale(base) / quantum;
145        if exact <= 0.0 {
146            0
147        } else {
148            // `as` saturates at the integer bound, so a wild base cannot wrap.
149            exact.round() as u32
150        }
151    }
152
153    /// Resolve against a base and snap to a whole number of `quantum`.
154    ///
155    /// The value [`Self::quanta`] counts, back in the surface's own units.
156    /// Guards the degenerate quantum in its own right rather than leaning on
157    /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN,
158    /// not zero.
159    #[must_use]
160    pub fn quantize(self, base: f32, quantum: f32) -> f32 {
161        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
162            return 0.0;
163        }
164        self.quanta(base, quantum) as f32 * quantum
165    }
166
167    /// The CSS value, as an expression over [`BASE_TOKEN`].
168    ///
169    /// A whole multiple of the base emits without a division, and 1:1 emits
170    /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
171    #[must_use]
172    pub fn css(self) -> String {
173        match (self.numerator, self.denominator) {
174            (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
175            (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
176            (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
177        }
178    }
179}
180
181/// What the layout is being drawn on: a base unit, and the smallest step the
182/// surface can actually represent.
183///
184/// Both are in the surface's own units, and the crate never assumes those are
185/// pixels. A display measures in pixels and can represent one of them; a
186/// terminal measures in cells and cannot represent less than one. That single
187/// difference is the whole of the terminal story — a TUI is not a density, it
188/// is a surface with a coarse quantum, and the relational vocabulary above
189/// crosses over untouched.
190///
191/// Quantising is not a terminal special case. A display does it too; it is
192/// just that rounding 6.0 to the nearest pixel is uninteresting, whereas
193/// rounding three eighths of a cell to the nearest cell is a design decision
194/// the surface makes for you.
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct Surface {
197    /// The base unit, in this surface's units.
198    pub base: f32,
199    /// The smallest step this surface can represent, in the same units.
200    pub quantum: f32,
201}
202
203impl Surface {
204    /// A display measuring in CSS pixels: a 16px base, one-pixel quantum.
205    #[must_use]
206    pub fn web() -> Self {
207        Self {
208            base: f32::from(DEFAULT_BASE_PX),
209            quantum: 1.0,
210        }
211    }
212
213    /// A terminal measuring in cells: a one-cell base, one-cell quantum.
214    ///
215    /// The coarsest surface in the family, and the one that proves the
216    /// vocabulary. `bound` and `peer` collapse to no cells at all, which is
217    /// correct — in a grid this dense, "belongs to" and "is a peer of" are
218    /// both expressed by adjacency, not by a gap.
219    #[must_use]
220    pub fn terminal() -> Self {
221        Self {
222            base: 1.0,
223            quantum: 1.0,
224        }
225    }
226
227    /// Resolve a ratio on this surface, snapped to its quantum.
228    #[must_use]
229    pub fn resolve(self, ratio: Ratio) -> f32 {
230        ratio.quantize(self.base, self.quantum)
231    }
232
233    /// How many whole quanta a ratio is worth here.
234    ///
235    /// What a cell-addressed layout actually wants: the count, not the size.
236    #[must_use]
237    pub fn quanta(self, ratio: Ratio) -> u32 {
238        ratio.quanta(self.base, self.quantum)
239    }
240
241    /// Resolve a relationship on this surface at a given density, in quanta.
242    ///
243    /// The whole model in one call: *what* is being separated, *who* is
244    /// operating it, *what* it is drawn on.
245    #[must_use]
246    pub fn gap(self, gap: Gap, density: Density) -> u32 {
247        self.quanta(gap.step_at(density).ratio())
248    }
249}
250
251/// Which input the layout is being sized for.
252///
253/// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides
254/// which step a relationship picks, the surface decides how that step lands.
255/// Which density applies is the app's call — GoingsOn and Balanced Breakfast
256/// already decide it once and hang a `ui-mode-*` class off the result.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
258pub enum Density {
259    /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
260    #[default]
261    Pointer,
262    /// Finger. Targets separate, shells tighten.
263    Touch,
264}
265
266/// A named separation between two things.
267///
268/// Pick by relationship. The size is a consequence of the name, not the other
269/// way round, and callers should never care what it is.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
271pub enum Gap {
272    /// A control and the thing it belongs to: an edit field and its pop-up, a
273    /// checkbox and its label, an icon and the text it labels. Reads as one
274    /// object.
275    Bound,
276    /// Items of the same kind in a list: stacked checkboxes, radio buttons,
277    /// rows, chips in a row. Reads as a set.
278    Peer,
279    /// A container's inner margin, and the distance between sibling groups
280    /// side by side. Reads as "inside this box".
281    Group,
282    /// Separated groups, and rows of actions. The first gap that reads as a
283    /// deliberate break rather than as breathing room.
284    Section,
285    /// Panel padding and content shells. Layout, not controls.
286    Pane,
287    /// The outermost shell margin. One per screen, usually.
288    Page,
289}
290
291/// A raw step on the underlying scale.
292///
293/// Present because not every distance is a relationship between two controls —
294/// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
295/// fits: a step name says how big, a gap name says why.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
297pub enum Step {
298    /// An eighth of the base. Optical nudges inside small inline elements.
299    Hair,
300    /// A quarter of the base.
301    Tight,
302    /// Three eighths of the base.
303    Snug,
304    /// Half the base.
305    Base,
306    /// Five eighths of the base.
307    Roomy,
308    /// Three quarters of the base.
309    Wide,
310    /// The base itself.
311    Loose,
312    /// One and a half times the base.
313    Broad,
314    /// Twice the base.
315    Vast,
316    /// Three times the base.
317    Colossal,
318}
319
320impl Step {
321    /// This step as a fraction of the base unit.
322    #[must_use]
323    pub const fn ratio(self) -> Ratio {
324        let (numerator, denominator) = match self {
325            Self::Hair => (1, 8),
326            Self::Tight => (1, 4),
327            Self::Snug => (3, 8),
328            Self::Base => (1, 2),
329            Self::Roomy => (5, 8),
330            Self::Wide => (3, 4),
331            Self::Loose => (1, 1),
332            Self::Broad => (3, 2),
333            Self::Vast => (2, 1),
334            Self::Colossal => (3, 1),
335        };
336        Ratio {
337            numerator,
338            denominator,
339        }
340    }
341
342    /// Size in CSS pixels at the default base.
343    #[must_use]
344    pub const fn px(self) -> u16 {
345        self.ratio().px_at(DEFAULT_BASE_PX)
346    }
347
348    /// The CSS custom-property name, without the leading `--`.
349    #[must_use]
350    pub const fn token(self) -> &'static str {
351        match self {
352            Self::Hair => "step-hair",
353            Self::Tight => "step-tight",
354            Self::Snug => "step-snug",
355            Self::Base => "step-base",
356            Self::Roomy => "step-roomy",
357            Self::Wide => "step-wide",
358            Self::Loose => "step-loose",
359            Self::Broad => "step-broad",
360            Self::Vast => "step-vast",
361            Self::Colossal => "step-colossal",
362        }
363    }
364
365    /// Every step, smallest first.
366    #[must_use]
367    pub const fn all() -> [Self; 10] {
368        [
369            Self::Hair,
370            Self::Tight,
371            Self::Snug,
372            Self::Base,
373            Self::Roomy,
374            Self::Wide,
375            Self::Loose,
376            Self::Broad,
377            Self::Vast,
378            Self::Colossal,
379        ]
380    }
381}
382
383impl Gap {
384    /// The step this relationship resolves to at a given density.
385    #[must_use]
386    pub const fn step_at(self, density: Density) -> Step {
387        match (self, density) {
388            // Binding is not a separation, so it does not open up on touch.
389            (Self::Bound, _) => Step::Tight,
390
391            (Self::Peer, Density::Pointer) => Step::Snug,
392            (Self::Peer, Density::Touch) => Step::Roomy,
393
394            (Self::Group, Density::Pointer) => Step::Roomy,
395            (Self::Group, Density::Touch) => Step::Wide,
396
397            (Self::Section, Density::Pointer) => Step::Wide,
398            (Self::Section, Density::Touch) => Step::Loose,
399
400            // Shells tighten on touch: outer margin is screen you don't get.
401            (Self::Pane, Density::Pointer) => Step::Broad,
402            (Self::Pane, Density::Touch) => Step::Loose,
403
404            (Self::Page, Density::Pointer) => Step::Vast,
405            (Self::Page, Density::Touch) => Step::Broad,
406        }
407    }
408
409    /// The step this relationship resolves to at the default density.
410    #[must_use]
411    pub const fn step(self) -> Step {
412        self.step_at(Density::Pointer)
413    }
414
415    /// Size in CSS pixels at the default base, at a given density.
416    #[must_use]
417    pub const fn px_at(self, density: Density) -> u16 {
418        self.step_at(density).px()
419    }
420
421    /// Size in CSS pixels at the default base and density.
422    #[must_use]
423    pub const fn px(self) -> u16 {
424        self.step().px()
425    }
426
427    /// The CSS custom-property name, without the leading `--`.
428    #[must_use]
429    pub const fn token(self) -> &'static str {
430        match self {
431            Self::Bound => "gap-bound",
432            Self::Peer => "gap-peer",
433            Self::Group => "gap-group",
434            Self::Section => "gap-section",
435            Self::Pane => "gap-pane",
436            Self::Page => "gap-page",
437        }
438    }
439
440    /// Every relationship, tightest first.
441    #[must_use]
442    pub const fn all() -> [Self; 6] {
443        [
444            Self::Bound,
445            Self::Peer,
446            Self::Group,
447            Self::Section,
448            Self::Pane,
449            Self::Page,
450        ]
451    }
452}
453
454/// Emit the base unit and the raw scale as CSS declarations, no selector.
455///
456/// Density-invariant: the steps are the vocabulary, and only which step a
457/// relationship picks changes between presets.
458#[must_use]
459pub fn scale_css_declarations() -> String {
460    let mut out = String::new();
461    let _ = writeln!(
462        out,
463        "  /* Every size below is a ratio of this. Scale it and the whole\n     \
464         layout scales with it, including for a user who has asked for\n     \
465         larger text. */\n  --{BASE_TOKEN}: 1rem;\n"
466    );
467    out.push_str("  /* Raw scale. Prefer a --gap-* below; reach here only when\n");
468    out.push_str("     no relationship describes the distance. */\n");
469    for step in Step::all() {
470        let _ = writeln!(out, "  --{}: {};", step.token(), step.ratio().css());
471    }
472    out
473}
474
475/// Emit the relational layer for one density as CSS declarations, no selector.
476///
477/// Gaps reference their step rather than repeating a value, so the scale has
478/// exactly one definition and a reader can see which relationship maps where.
479#[must_use]
480pub fn gap_css_declarations(density: Density) -> String {
481    let mut out = String::new();
482    for gap in Gap::all() {
483        let _ = writeln!(
484            out,
485            "  --{}: var(--{});",
486            gap.token(),
487            gap.step_at(density).token()
488        );
489    }
490    out
491}
492
493/// Emit the whole geometry layer as a `:root { … }` block at one density.
494///
495/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
496/// constant, so a web consumer should bake it in at build time rather than
497/// apply it from JS on every load.
498#[must_use]
499pub fn geometry_css_vars(density: Density) -> String {
500    format!(
501        ":root {{\n{}\n{}}}\n",
502        scale_css_declarations(),
503        gap_css_declarations(density)
504    )
505}
506
507/// Emit a density preset as a scoped override block.
508///
509/// Only the relational layer is emitted: the scale and the base do not change
510/// between presets, so an app ships [`geometry_css_vars`] at its default
511/// density and one of these per mode class it supports.
512///
513/// ```
514/// # use makeover_geometry::{Density, gap_css_overrides};
515/// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
516/// assert!(css.starts_with(".ui-mode-mobile {\n"));
517/// ```
518#[must_use]
519pub fn gap_css_overrides(selector: &str, density: Density) -> String {
520    format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn the_hig_relationships_land_on_the_hig_values() {
529        // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
530        // the default base they must resolve to the numbers the HIG specifies,
531        // or the departure has cost us the thing it was translating.
532        assert_eq!(Gap::Bound.px(), 4);
533        assert_eq!(Gap::Peer.px(), 6);
534        assert_eq!(Gap::Group.px(), 10);
535        assert_eq!(Gap::Section.px(), 12);
536    }
537
538    #[test]
539    fn every_ratio_divides_the_default_base_exactly() {
540        for step in Step::all() {
541            let r = step.ratio();
542            assert_eq!(
543                u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
544                0,
545                "{step:?} is fractional at the default base"
546            );
547        }
548    }
549
550    #[test]
551    fn ratios_scale_linearly() {
552        for step in Step::all() {
553            assert_eq!(
554                step.ratio().px_at(DEFAULT_BASE_PX * 2),
555                step.px() * 2,
556                "{step:?} does not double with the base"
557            );
558        }
559    }
560
561    #[test]
562    fn steps_ascend_and_never_repeat() {
563        let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
564        let mut sorted = px.clone();
565        sorted.sort_unstable();
566        sorted.dedup();
567        assert_eq!(px, sorted, "steps must be strictly ascending");
568    }
569
570    #[test]
571    fn gaps_ascend_with_their_relationships_at_every_density() {
572        for density in [Density::Pointer, Density::Touch] {
573            let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
574            let mut sorted = px.clone();
575            sorted.sort_unstable();
576            assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
577        }
578    }
579
580    #[test]
581    fn touch_separates_targets_and_tightens_shells() {
582        // The asymmetry is the whole reason a base scalar would not do.
583        for gap in [Gap::Peer, Gap::Section] {
584            assert!(
585                gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
586                "{gap:?} must open up for a fingertip"
587            );
588        }
589        for gap in [Gap::Pane, Gap::Page] {
590            assert!(
591                gap.px_at(Density::Touch) < gap.px_at(Density::Pointer),
592                "{gap:?} must tighten on a small screen"
593            );
594        }
595        assert_eq!(
596            Gap::Bound.px_at(Density::Touch),
597            Gap::Bound.px_at(Density::Pointer),
598            "bound things stay bound"
599        );
600    }
601
602    #[test]
603    fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
604        let t = Surface::terminal();
605        let cells: Vec<u32> = Gap::all()
606            .iter()
607            .map(|g| t.gap(*g, Density::Pointer))
608            .collect();
609        // bound, peer | group, section | pane, page
610        assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
611    }
612
613    #[test]
614    fn collapsing_is_allowed_but_inverting_is_not() {
615        // A coarse surface has fewer distinctions, so neighbouring gaps may
616        // land on the same quantum. What must never happen is a looser
617        // relationship coming out tighter than a closer one.
618        for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
619            for density in [Density::Pointer, Density::Touch] {
620                let s = Surface {
621                    base: 16.0,
622                    quantum,
623                };
624                let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
625                let mut sorted = v.clone();
626                sorted.sort_unstable();
627                assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
628            }
629        }
630    }
631
632    #[test]
633    fn the_web_surface_agrees_with_the_pixel_helper() {
634        let w = Surface::web();
635        for step in Step::all() {
636            assert_eq!(
637                w.resolve(step.ratio()) as u16,
638                step.px(),
639                "{step:?} disagrees between surface and px_at"
640            );
641        }
642    }
643
644    #[test]
645    fn quantising_is_monotonic_in_the_ratio() {
646        let (base, quantum) = (16.0, 1.0);
647        let mut previous = 0;
648        for step in Step::all() {
649            let q = step.ratio().quanta(base, quantum);
650            assert!(q >= previous, "{step:?} went backwards");
651            previous = q;
652        }
653    }
654
655    #[test]
656    fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
657        let r = Step::Loose.ratio();
658        for bad in [0.0_f32, -1.0, f32::NAN] {
659            assert_eq!(r.quanta(16.0, bad), 0);
660            assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
661        }
662        assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
663    }
664
665    #[test]
666    fn px_at_rounds_rather_than_truncating() {
667        // Eighths divide 16 exactly, so the rounding only shows on a base
668        // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
669        assert_eq!(Step::Snug.ratio().px_at(15), 6);
670        assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
671    }
672
673    #[test]
674    fn tokens_are_unique() {
675        let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
676        names.extend(Gap::all().iter().map(|g| g.token()));
677        let count = names.len();
678        names.sort_unstable();
679        names.dedup();
680        assert_eq!(names.len(), count, "token names collide");
681    }
682
683    #[test]
684    fn css_is_expressed_over_the_base_never_in_pixels() {
685        let css = geometry_css_vars(Density::Pointer);
686        assert!(css.starts_with(":root {\n"));
687        assert!(css.trim_end().ends_with('}'));
688        assert!(css.contains("--geometry-base: 1rem;"));
689        for step in Step::all() {
690            let line = format!("--{}: {}", step.token(), step.ratio().css());
691            assert!(css.contains(&line), "missing or wrong: {line}");
692        }
693        // A hard pixel count anywhere in the scale defeats the point.
694        let scale = scale_css_declarations();
695        assert!(
696            !scale.contains("px;"),
697            "the scale must not emit pixel literals:\n{scale}"
698        );
699    }
700
701    #[test]
702    fn ratio_css_drops_redundant_arithmetic() {
703        assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
704        assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
705        assert_eq!(
706            Step::Snug.ratio().css(),
707            "calc(var(--geometry-base) * 3 / 8)"
708        );
709    }
710
711    #[test]
712    fn gaps_reference_steps_rather_than_repeating_values() {
713        let css = geometry_css_vars(Density::Pointer);
714        assert!(css.contains("--gap-peer: var(--step-snug);"));
715        assert!(!css.contains("--gap-peer: calc"));
716    }
717
718    #[test]
719    fn a_density_override_emits_only_the_relational_layer() {
720        let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
721        assert!(css.contains("--gap-peer: var(--step-roomy);"));
722        // Referencing a step is the point; re-declaring one would fork the
723        // scale, so the check is on declarations, not on mentions.
724        let declared: Vec<&str> = css
725            .lines()
726            .filter_map(|l| l.trim().strip_prefix("--"))
727            .filter_map(|l| l.split(':').next())
728            .collect();
729        assert!(
730            declared.iter().all(|t| t.starts_with("gap-")),
731            "only the relational layer may be overridden, got {declared:?}"
732        );
733    }
734}