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//! # Type is relational too
43//!
44//! [`Text`] names what a piece of text is — body, note, head — and the size
45//! follows, exactly as [`Gap`] names what is being separated. It is the same
46//! argument: whether a caption should be 13px or 14px cannot be reviewed,
47//! whether a piece of text is a caption can.
48//!
49//! Type has its own rungs rather than reusing [`Step`], because the spacing
50//! scale is eighths of the base to match the HIG's distances and a type ramp
51//! wants different fractions. What the two axes share is the base, so the
52//! reader's root font size moves the text and the space around it together.
53//!
54//! Unlike spacing, type does not move with [`Density`]. The reason is in
55//! [`Text`], and it is the same one that keeps shells out of the touch preset.
56//!
57//! # Density presets
58//!
59//! Naming relationships instead of sizes is what makes a density preset
60//! possible at all. [`Density`] changes what each relationship resolves to
61//! without touching a single call site, because no call site names a size.
62//! The mobile and desktop builds of a Tauri app should differ mostly by which
63//! preset they emit, not by a parallel set of hand-written mode-scoped rules.
64//!
65//! ## What Touch is a claim about
66//!
67//! Touch is a claim about the **contact patch and nothing else**. A fingertip
68//! is coarse where a cursor hotspot is a point, and the only consequence of
69//! that is mis-tap cost: when two adjacent things do different things, an
70//! imprecise contact needs more room between them to land on the intended one.
71//!
72//! Shells are not tap targets. Panel padding and the outer page margin separate
73//! a region from the edge of the screen, and no amount of coarseness in the
74//! pointing device makes that separation riskier. So **Touch opens the gaps
75//! that separate targets and leaves the shells exactly where Pointer put
76//! them**:
77//!
78//! | Gap | Pointer | Touch | why |
79//! |---|---|---|---|
80//! | bound | 4 | 4 | not a separation at all |
81//! | peer | 6 | 10 | adjacent distinct targets, the whole point |
82//! | group | 10 | 12 | holds the peer/group distinction open |
83//! | section | 12 | 16 | a deliberate break stays legible as one |
84//! | pane | 24 | 24 | a shell is not a target |
85//! | page | 32 | 32 | a shell is not a target |
86//!
87//! The previous Touch preset was thrown out on 2026-07-29 because it also
88//! *tightened* `Pane` and `Page`, on the argument that outer margin is screen
89//! you do not get. **That is a claim about screen budget, not about the input
90//! device**, and smuggling it into this axis is what broke: opening `Section`
91//! to 16 while tightening `Pane` below it made any Pointer `Pane` at or under
92//! 16 an inversion, so a derived preset silently set a floor under the one
93//! quoted from the HIG. A phone is small *and* touch; a tablet and a
94//! touchscreen laptop are big and touch. Screen budget is a separate axis and
95//! does not belong here.
96//!
97//! ## The one cross-density rule
98//!
99//! **Touch never resolves tighter than Pointer**, at any gap. Stated as a
100//! deliberate claim rather than inherited, and chosen for its direction: it
101//! constrains the *derived* preset by the *quoted* one, never the reverse. A
102//! Pointer retune downward moves freely and cannot be blocked by Touch, which
103//! is the exact failure this replaces. Only a Pointer move upward can push
104//! Touch, and that is the correct direction of authority.
105//!
106//! # Size class: the axis Density kept being asked to carry
107//!
108//! [`SizeClass`] answers **how much screen there is**, which is not the same
109//! question as what is pointing at it. A phone is small and touch; a tablet and
110//! a touchscreen laptop are big and touch; a half-width desktop window is small
111//! and pointer. Four real combinations, and one axis cannot name them.
112//!
113//! This is the home for the claim the old Touch preset was thrown out for
114//! making: *outer margin is screen you do not get*. That claim was never wrong,
115//! it was on the wrong axis, and putting it on the input device is what let a
116//! derived preset set a floor under a quoted one.
117//!
118//! Boundaries are **quoted** (Material 3 window size classes: 600 and 840)
119//! rather than derived, for the same reason the [`Gap`] values are. This crate
120//! carries the boundaries only; what appears or disappears at each is a product
121//! decision and belongs to `makeover-touch`.
122//!
123//! Deliberately absent: size class does not feed [`Gap::step_at`] yet. Whether
124//! shells tighten on a compact window is a look call, and deriving it a second
125//! time is how the last one went wrong.
126//!
127//! # Surfaces, and why a TUI is not a third density
128//!
129//! [`Surface`] is the fourth axis and the one that carries this to alloy_tui. A
130//! terminal is not a density preset; it is a surface whose smallest
131//! representable step is one cell rather than one pixel. Give
132//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
133//! relational vocabulary crosses to a character grid with nothing added.
134//!
135//! Quantising is not a terminal special case either — a display quantises to
136//! the pixel. It is only that rounding 6.0 to the nearest pixel is
137//! uninteresting, while rounding three eighths of a cell to the nearest cell
138//! decides the layout.
139//!
140//! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2
141//! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy:
142//! in a grid that dense, both relationships are expressed by adjacency. A
143//! coarse surface genuinely has fewer distinctions available, and the model
144//! should say so instead of inventing a gap to keep six names distinct.
145//!
146//! So the three axes are: [`Gap`] is what is being separated, [`Density`] is
147//! who is operating it, [`Surface`] is what it is drawn on.
148
149#![forbid(unsafe_code)]
150
151use std::fmt::Write as _;
152
153/// The default base unit in CSS pixels, at a 16px root font size.
154pub const DEFAULT_BASE_PX: u16 = 16;
155
156/// The CSS custom property every ratio scales from.
157pub const BASE_TOKEN: &str = "geometry-base";
158
159/// A fraction of the base unit.
160///
161/// Rational rather than floating point so the scale is exact, comparable and
162/// usable in a `const`. At the default base every ratio below divides evenly.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164pub struct Ratio {
165    /// Top of the fraction.
166    pub numerator: u16,
167    /// Bottom of the fraction. Never zero for any ratio this crate defines.
168    pub denominator: u16,
169}
170
171impl Ratio {
172    /// Resolve against a base measured in whole pixels, rounding to nearest.
173    ///
174    /// Integer maths throughout, and exact for every [`Step`] at
175    /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is
176    /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the
177    /// common case stays `const`.
178    #[must_use]
179    pub const fn px_at(self, base_px: u16) -> u16 {
180        let (n, d) = (self.numerator as u32, self.denominator as u32);
181        let scaled = base_px as u32 * n;
182        // Round half away from zero without leaving integer arithmetic.
183        ((scaled * 2 + d) / (d * 2)) as u16
184    }
185
186    /// Resolve against an arbitrary base, keeping the fraction.
187    ///
188    /// The exact value, before any surface gets a say. Prefer
189    /// [`Surface::resolve`] unless you specifically want the unsnapped number.
190    #[must_use]
191    pub fn scale(self, base: f32) -> f32 {
192        base * f32::from(self.numerator) / f32::from(self.denominator)
193    }
194
195    /// How many whole quanta this ratio is worth on a surface whose smallest
196    /// representable step is `quantum`.
197    ///
198    /// The generalisation of "round to a pixel". A display quantises to one
199    /// pixel and the answer is usually uninteresting; a terminal quantises to
200    /// one cell and the answer is the whole design. Rounds to nearest, and
201    /// does not floor at one: a gap that lands below half a quantum should
202    /// collapse to nothing, because on that surface it *is* nothing.
203    ///
204    /// A `quantum` that is zero, negative or not finite yields `0` rather than
205    /// panicking or returning infinity — a surface with no smallest step is a
206    /// caller error, not a layout to guess at.
207    #[must_use]
208    pub fn quanta(self, base: f32, quantum: f32) -> u32 {
209        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
210            return 0;
211        }
212        let exact = self.scale(base) / quantum;
213        if exact <= 0.0 {
214            0
215        } else {
216            // `as` saturates at the integer bound, so a wild base cannot wrap.
217            exact.round() as u32
218        }
219    }
220
221    /// Resolve against a base and snap to a whole number of `quantum`.
222    ///
223    /// The value [`Self::quanta`] counts, back in the surface's own units.
224    /// Guards the degenerate quantum in its own right rather than leaning on
225    /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN,
226    /// not zero.
227    #[must_use]
228    pub fn quantize(self, base: f32, quantum: f32) -> f32 {
229        if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
230            return 0.0;
231        }
232        self.quanta(base, quantum) as f32 * quantum
233    }
234
235    /// The CSS value, as an expression over [`BASE_TOKEN`].
236    ///
237    /// A whole multiple of the base emits without a division, and 1:1 emits
238    /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
239    #[must_use]
240    pub fn css(self) -> String {
241        match (self.numerator, self.denominator) {
242            (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
243            (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
244            (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
245        }
246    }
247}
248
249/// What the layout is being drawn on: a base unit, and the smallest step the
250/// surface can actually represent.
251///
252/// Both are in the surface's own units, and the crate never assumes those are
253/// pixels. A display measures in pixels and can represent one of them; a
254/// terminal measures in cells and cannot represent less than one. That single
255/// difference is the whole of the terminal story — a TUI is not a density, it
256/// is a surface with a coarse quantum, and the relational vocabulary above
257/// crosses over untouched.
258///
259/// Quantising is not a terminal special case. A display does it too; it is
260/// just that rounding 6.0 to the nearest pixel is uninteresting, whereas
261/// rounding three eighths of a cell to the nearest cell is a design decision
262/// the surface makes for you.
263#[derive(Debug, Clone, Copy, PartialEq)]
264pub struct Surface {
265    /// The base unit, in this surface's units.
266    pub base: f32,
267    /// The smallest step this surface can represent, in the same units.
268    pub quantum: f32,
269}
270
271impl Surface {
272    /// A display measuring in CSS pixels: a 16px base, one-pixel quantum.
273    #[must_use]
274    pub fn web() -> Self {
275        Self {
276            base: f32::from(DEFAULT_BASE_PX),
277            quantum: 1.0,
278        }
279    }
280
281    /// A terminal measuring in cells: a one-cell base, one-cell quantum.
282    ///
283    /// The coarsest surface in the family, and the one that proves the
284    /// vocabulary. `bound` and `peer` collapse to no cells at all, which is
285    /// correct — in a grid this dense, "belongs to" and "is a peer of" are
286    /// both expressed by adjacency, not by a gap.
287    #[must_use]
288    pub fn terminal() -> Self {
289        Self {
290            base: 1.0,
291            quantum: 1.0,
292        }
293    }
294
295    /// Resolve a ratio on this surface, snapped to its quantum.
296    #[must_use]
297    pub fn resolve(self, ratio: Ratio) -> f32 {
298        ratio.quantize(self.base, self.quantum)
299    }
300
301    /// How many whole quanta a ratio is worth here.
302    ///
303    /// What a cell-addressed layout actually wants: the count, not the size.
304    #[must_use]
305    pub fn quanta(self, ratio: Ratio) -> u32 {
306        ratio.quanta(self.base, self.quantum)
307    }
308
309    /// Resolve a relationship on this surface at a given density, in quanta.
310    ///
311    /// The whole model in one call: *what* is being separated, *who* is
312    /// operating it, *what* it is drawn on.
313    #[must_use]
314    pub fn gap(self, gap: Gap, density: Density) -> u32 {
315        self.quanta(gap.step_at(density).ratio())
316    }
317}
318
319/// Which input the layout is being sized for.
320///
321/// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides
322/// which step a relationship picks, the surface decides how that step lands.
323/// Which density applies is the app's call — GoingsOn and Balanced Breakfast
324/// already decide it once and hang a `ui-mode-*` class off the result.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
326pub enum Density {
327    /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
328    #[default]
329    Pointer,
330    /// Finger. Opens the gaps that separate distinct tap targets and leaves
331    /// the shells where Pointer put them, because a coarse contact patch
332    /// raises mis-tap cost and a panel margin is not something you tap. See
333    /// the crate-level "Density presets" section for the derivation.
334    Touch,
335}
336
337impl Density {
338    /// The media condition selecting exactly this density, without the
339    /// `@media`.
340    ///
341    /// A capability question rather than a width or a device, which is the
342    /// policy this crate already settled for [`density_css`] and which three
343    /// consumers previously answered three ways.
344    ///
345    /// The two are **not** each other's textual negation, and that is the
346    /// reason they live in one place. Touch is comma-joined, so it is an OR,
347    /// and negating an OR gives an AND with both halves inverted. Deriving one
348    /// from the other by eye is how the pair drifts apart, and it drifts
349    /// silently: a wrong negation still parses, still minifies, and only shows
350    /// up as hover states surviving on a phone.
351    ///
352    /// Pointer's condition is what a renderer wraps a hover rule in.
353    /// `makeover-touch` decides *whether* a hover rule should be gated;
354    /// this decides what the gate is spelled as.
355    #[must_use]
356    pub const fn media_condition(self) -> &'static str {
357        match self {
358            Self::Pointer => "(hover: hover) and (pointer: fine)",
359            Self::Touch => "(hover: none), (pointer: coarse)",
360        }
361    }
362}
363
364/// How much screen there is, independent of what is pointing at it.
365///
366/// The second axis, and the one [`Density`] kept being asked to carry. A phone
367/// is small *and* touch; a tablet and a touchscreen laptop are big and touch; a
368/// half-width window on a desktop is small and pointer. Those are four real
369/// combinations and one axis cannot name them, which is what made the old Touch
370/// preset tighten shells it had no business tightening.
371///
372/// **Boundaries are quoted, not derived**, for the same reason the [`Gap`]
373/// values are: a derived boundary is one nobody can check. They are Material 3's
374/// window size classes, the best-known three-tier split with published numbers.
375/// Apple's size classes are two-tier and expressed as regular/compact per axis,
376/// which does not give a middle to aim at.
377///
378/// Source: <https://m3.material.io/foundations/layout/applying-layout/window-size-classes>
379///
380/// This enum carries the **boundaries only**. What appears, disappears or
381/// reflows at each is a product decision and belongs to `makeover-touch`, not
382/// here. Deliberately absent for now: size class does not feed [`Gap::step_at`].
383/// Whether shells should tighten on a compact window is a look call of exactly
384/// the kind that produced the 2026-07-29 demolition, so it waits for an eyeball
385/// rather than being derived a second time.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
387pub enum SizeClass {
388    /// Under 600px. Phones in either orientation, and any window narrowed to
389    /// phone width regardless of what is pointing at it.
390    Compact,
391    /// 600px to 839px. Small tablets, split-screen panes, half-width windows.
392    #[default]
393    Medium,
394    /// 840px and up. Laptops, desktops, tablets in landscape.
395    Expanded,
396}
397
398impl SizeClass {
399    /// Lower bound in CSS pixels, inclusive. [`Self::Compact`] starts at zero.
400    #[must_use]
401    pub const fn min_px(self) -> u16 {
402        match self {
403            Self::Compact => 0,
404            Self::Medium => 600,
405            Self::Expanded => 840,
406        }
407    }
408
409    /// The media condition selecting exactly this class, without the `@media`.
410    ///
411    /// Bounded on both sides for the middle class, so the three are mutually
412    /// exclusive and a rule cannot land in two of them. `max-width` is one below
413    /// the next class's `min_px`, because CSS width ranges are inclusive.
414    #[must_use]
415    pub fn media_condition(self) -> String {
416        match self {
417            Self::Compact => format!("(max-width: {}px)", Self::Medium.min_px() - 1),
418            Self::Medium => format!(
419                "(min-width: {}px) and (max-width: {}px)",
420                Self::Medium.min_px(),
421                Self::Expanded.min_px() - 1
422            ),
423            Self::Expanded => format!("(min-width: {}px)", Self::Expanded.min_px()),
424        }
425    }
426
427    /// The CSS class name an app may hang off this, without the leading dot.
428    #[must_use]
429    pub const fn token(self) -> &'static str {
430        match self {
431            Self::Compact => "size-compact",
432            Self::Medium => "size-medium",
433            Self::Expanded => "size-expanded",
434        }
435    }
436
437    /// Every class, narrowest first.
438    #[must_use]
439    pub const fn all() -> [Self; 3] {
440        [Self::Compact, Self::Medium, Self::Expanded]
441    }
442
443    /// The class a given viewport width falls in.
444    #[must_use]
445    pub const fn at_width(px: u16) -> Self {
446        if px >= Self::Expanded.min_px() {
447            Self::Expanded
448        } else if px >= Self::Medium.min_px() {
449            Self::Medium
450        } else {
451            Self::Compact
452        }
453    }
454}
455
456/// A named separation between two things.
457///
458/// Pick by relationship. The size is a consequence of the name, not the other
459/// way round, and callers should never care what it is.
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
461pub enum Gap {
462    /// A control and the thing it belongs to: an edit field and its pop-up, a
463    /// checkbox and its label, an icon and the text it labels. Reads as one
464    /// object.
465    Bound,
466    /// Items of the same kind in a list: stacked checkboxes, radio buttons,
467    /// rows, chips in a row. Reads as a set.
468    Peer,
469    /// A container's inner margin, and the distance between sibling groups
470    /// side by side. Reads as "inside this box".
471    Group,
472    /// Separated groups, and rows of actions. The first gap that reads as a
473    /// deliberate break rather than as breathing room.
474    Section,
475    /// Panel padding and content shells. Layout, not controls.
476    Pane,
477    /// The outermost shell margin. One per screen, usually.
478    Page,
479}
480
481/// A raw step on the underlying scale.
482///
483/// Present because not every distance is a relationship between two controls —
484/// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
485/// fits: a step name says how big, a gap name says why.
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
487pub enum Step {
488    /// An eighth of the base. Optical nudges inside small inline elements.
489    Hair,
490    /// A quarter of the base.
491    Tight,
492    /// Three eighths of the base.
493    Snug,
494    /// Half the base.
495    Base,
496    /// Five eighths of the base.
497    Roomy,
498    /// Three quarters of the base.
499    Wide,
500    /// The base itself.
501    Loose,
502    /// One and a half times the base.
503    Broad,
504    /// Twice the base.
505    Vast,
506    /// Three times the base.
507    Colossal,
508}
509
510impl Step {
511    /// This step as a fraction of the base unit.
512    #[must_use]
513    pub const fn ratio(self) -> Ratio {
514        let (numerator, denominator) = match self {
515            Self::Hair => (1, 8),
516            Self::Tight => (1, 4),
517            Self::Snug => (3, 8),
518            Self::Base => (1, 2),
519            Self::Roomy => (5, 8),
520            Self::Wide => (3, 4),
521            Self::Loose => (1, 1),
522            Self::Broad => (3, 2),
523            Self::Vast => (2, 1),
524            Self::Colossal => (3, 1),
525        };
526        Ratio {
527            numerator,
528            denominator,
529        }
530    }
531
532    /// Size in CSS pixels at the default base.
533    #[must_use]
534    pub const fn px(self) -> u16 {
535        self.ratio().px_at(DEFAULT_BASE_PX)
536    }
537
538    /// The CSS custom-property name, without the leading `--`.
539    #[must_use]
540    pub const fn token(self) -> &'static str {
541        match self {
542            Self::Hair => "step-hair",
543            Self::Tight => "step-tight",
544            Self::Snug => "step-snug",
545            Self::Base => "step-base",
546            Self::Roomy => "step-roomy",
547            Self::Wide => "step-wide",
548            Self::Loose => "step-loose",
549            Self::Broad => "step-broad",
550            Self::Vast => "step-vast",
551            Self::Colossal => "step-colossal",
552        }
553    }
554
555    /// Every step, smallest first.
556    #[must_use]
557    pub const fn all() -> [Self; 10] {
558        [
559            Self::Hair,
560            Self::Tight,
561            Self::Snug,
562            Self::Base,
563            Self::Roomy,
564            Self::Wide,
565            Self::Loose,
566            Self::Broad,
567            Self::Vast,
568            Self::Colossal,
569        ]
570    }
571}
572
573impl Gap {
574    /// The step this relationship resolves to at a given density.
575    ///
576    /// [`Density::Pointer`]'s values are the HIG's own. [`Density::Touch`]
577    /// opens the three gaps that separate distinct tap targets and holds the
578    /// rest, per the crate-level "Density presets" section.
579    #[must_use]
580    pub const fn step_at(self, density: Density) -> Step {
581        match self {
582            // Binding is not a separation, so it does not open up on touch
583            // either: separating these would say they are two objects.
584            Self::Bound => Step::Tight,
585
586            // The three that carry mis-tap cost. Peer is the one that matters
587            // most (stacked rows, adjacent chips) and moves furthest; Group
588            // and Section follow only far enough to stay distinct from it.
589            Self::Peer => match density {
590                Density::Pointer => Step::Snug,
591                Density::Touch => Step::Roomy,
592            },
593            Self::Group => match density {
594                Density::Pointer => Step::Roomy,
595                Density::Touch => Step::Wide,
596            },
597            Self::Section => match density {
598                Density::Pointer => Step::Wide,
599                Density::Touch => Step::Loose,
600            },
601
602            // Shells. Not tap targets, so the contact patch has no opinion,
603            // and screen budget is a different axis than this one.
604            Self::Pane => Step::Broad,
605            Self::Page => Step::Vast,
606        }
607    }
608
609    /// The step this relationship resolves to at the default density.
610    #[must_use]
611    pub const fn step(self) -> Step {
612        self.step_at(Density::Pointer)
613    }
614
615    /// Size in CSS pixels at the default base, at a given density.
616    #[must_use]
617    pub const fn px_at(self, density: Density) -> u16 {
618        self.step_at(density).px()
619    }
620
621    /// Size in CSS pixels at the default base and density.
622    #[must_use]
623    pub const fn px(self) -> u16 {
624        self.step().px()
625    }
626
627    /// The CSS custom-property name, without the leading `--`.
628    #[must_use]
629    pub const fn token(self) -> &'static str {
630        match self {
631            Self::Bound => "gap-bound",
632            Self::Peer => "gap-peer",
633            Self::Group => "gap-group",
634            Self::Section => "gap-section",
635            Self::Pane => "gap-pane",
636            Self::Page => "gap-page",
637        }
638    }
639
640    /// Every relationship, tightest first.
641    #[must_use]
642    pub const fn all() -> [Self; 6] {
643        [
644            Self::Bound,
645            Self::Peer,
646            Self::Group,
647            Self::Section,
648            Self::Pane,
649            Self::Page,
650        ]
651    }
652}
653
654/// What a piece of text is, from which its size follows.
655///
656/// The type axis, and the same move [`Gap`] makes on the spacing axis: name
657/// the role and let the size follow, so the choice is reviewable. Whether a
658/// caption should be 13px or 14px is unanswerable in isolation; whether a
659/// piece of text is a caption is not.
660///
661/// # Why the ratios are their own ramp
662///
663/// Type does not reuse [`Step`]. The spacing scale is built in eighths of the
664/// base because that is what the HIG's distances land on, and a type ramp
665/// needs different rungs — 7/8 and 9/8 sit either side of body copy and have
666/// no spacing meaning at all, while `Hair` and `Tight` are far below any
667/// legible size. Sharing the enum would have meant widening it for rungs
668/// spacing never asks for.
669///
670/// What is shared is the thing that matters: every rung here is a [`Ratio`]
671/// of `--geometry-base`, so text tracks the user's chosen root size exactly
672/// as spacing does, and one knob still moves the whole design.
673///
674/// # Why the floor is 3/4
675///
676/// Twelve pixels at the default base, and nothing below it. Sizes under that
677/// are a legibility problem rather than a tier, and a scale that offers one
678/// is a scale that invites it. Text that needs to recede should recede by
679/// colour or weight, which cost no legibility.
680///
681/// # Why type does not shift on touch
682///
683/// [`Density`] is a claim about the contact patch and nothing else, and text
684/// is not a tap target. The reader's own root font size is already the knob
685/// for how large text should be, and it already moves this whole ramp. So the
686/// type axis is density-invariant, and a phone gets the same tiers a desktop
687/// does.
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
689pub enum Text {
690    /// Three quarters of the base. Timestamps, badges, legal lines.
691    Fine,
692    /// Seven eighths of the base. Secondary text: metadata, table cells,
693    /// captions, form help.
694    Note,
695    /// The base itself. Running copy, and the size everything else is read
696    /// against.
697    Body,
698    /// Nine eighths of the base. Emphasised copy: intros, card titles.
699    Lead,
700    /// Five quarters of the base. The third heading level.
701    Subhead,
702    /// One and a half times the base. Section headings, the second level.
703    Head,
704    /// Twice the base. The page's own title, the first level.
705    Title,
706    /// Two and a half times the base. Display copy, above the document
707    /// hierarchy rather than at the top of it.
708    Display,
709    /// Three times the base. One per page at most: a landing hero.
710    Hero,
711}
712
713impl Text {
714    /// This role's size as a fraction of the base unit.
715    #[must_use]
716    pub const fn ratio(self) -> Ratio {
717        let (numerator, denominator) = match self {
718            Self::Fine => (3, 4),
719            Self::Note => (7, 8),
720            Self::Body => (1, 1),
721            Self::Lead => (9, 8),
722            Self::Subhead => (5, 4),
723            Self::Head => (3, 2),
724            Self::Title => (2, 1),
725            Self::Display => (5, 2),
726            Self::Hero => (3, 1),
727        };
728        Ratio {
729            numerator,
730            denominator,
731        }
732    }
733
734    /// Size in CSS pixels at the default base.
735    #[must_use]
736    pub const fn px(self) -> u16 {
737        self.ratio().px_at(DEFAULT_BASE_PX)
738    }
739
740    /// The CSS custom-property name, without the leading `--`.
741    #[must_use]
742    pub const fn token(self) -> &'static str {
743        match self {
744            Self::Fine => "text-fine",
745            Self::Note => "text-note",
746            Self::Body => "text-body",
747            Self::Lead => "text-lead",
748            Self::Subhead => "text-subhead",
749            Self::Head => "text-head",
750            Self::Title => "text-title",
751            Self::Display => "text-display",
752            Self::Hero => "text-hero",
753        }
754    }
755
756    /// Every role, smallest first.
757    #[must_use]
758    pub const fn all() -> [Self; 9] {
759        [
760            Self::Fine,
761            Self::Note,
762            Self::Body,
763            Self::Lead,
764            Self::Subhead,
765            Self::Head,
766            Self::Title,
767            Self::Display,
768            Self::Hero,
769        ]
770    }
771}
772
773/// Emit the base unit and the raw scale as CSS declarations, no selector.
774///
775/// Density-invariant: the steps are the vocabulary, and only which step a
776/// relationship picks changes between presets.
777#[must_use]
778pub fn scale_css_declarations() -> String {
779    let mut out = String::new();
780    let _ = writeln!(
781        out,
782        "  /* Every size below is a ratio of this. Scale it and the whole\n     \
783         layout scales with it, including for a user who has asked for\n     \
784         larger text. */\n  --{BASE_TOKEN}: 1rem;\n"
785    );
786    out.push_str("  /* Raw scale. Prefer a --gap-* below; reach here only when\n");
787    out.push_str("     no relationship describes the distance. */\n");
788    for step in Step::all() {
789        let _ = writeln!(out, "  --{}: {};", step.token(), step.ratio().css());
790    }
791    out
792}
793
794/// Emit the type axis as CSS declarations, no selector.
795///
796/// Takes no [`Density`]: text is not a tap target, so the contact patch has no
797/// opinion on it. See [`Text`] for the derivation.
798#[must_use]
799pub fn text_css_declarations() -> String {
800    let mut out = String::new();
801    out.push_str("  /* Type. Named for what the text is; the size follows.\n");
802    out.push_str("     Ratios of the base, so text tracks the reader's own\n");
803    out.push_str("     root size. Density-invariant: text is not a target. */\n");
804    for text in Text::all() {
805        let _ = writeln!(out, "  --{}: {};", text.token(), text.ratio().css());
806    }
807    out
808}
809
810/// Emit the relational layer for one density as CSS declarations, no selector.
811///
812/// Gaps reference their step rather than repeating a value, so the scale has
813/// exactly one definition and a reader can see which relationship maps where.
814#[must_use]
815pub fn gap_css_declarations(density: Density) -> String {
816    let mut out = String::new();
817    for gap in Gap::all() {
818        let _ = writeln!(
819            out,
820            "  --{}: var(--{});",
821            gap.token(),
822            gap.step_at(density).token()
823        );
824    }
825    out
826}
827
828/// The cascade layer every stylesheet the make-family generates is wrapped in.
829///
830/// One name shared by every emitter in the family, so an app writes it once and
831/// the design system's output lands in one place it can order against:
832///
833/// ```css
834/// @layer makeover, base, components, responsive;
835/// ```
836///
837/// # Why a layer at all
838///
839/// The cascade resolves origin and importance, then layer, then specificity,
840/// then source order, and **unlayered normal declarations outrank every named
841/// layer**. So the moment an app declares any layer of its own, every rule it
842/// owns loses to unlayered generated CSS regardless of specificity or of
843/// loading last. Emitting into a layer is what stops that, and putting the name
844/// here rather than in each app is what stops three apps picking three names.
845///
846/// # Why this constant lives in the geometry crate
847///
848/// Not because spacing owns it. This crate is the only one every CSS-emitting
849/// crate in the family already depends on, and it is already the crate that
850/// spells CSS for the family (`media_condition`, `Step::token`, `Ratio::css`).
851/// A second copy in `makeover-webview` is exactly the drift
852/// [`Density::media_condition`] exists to prevent, one layer up.
853pub const CSS_LAYER: &str = "makeover";
854
855/// Wrap generated CSS in [`CSS_LAYER`].
856///
857/// Every whole-stylesheet emitter in the family ends with this call. Exposed
858/// rather than kept private because an app that assembles its own stylesheet
859/// out of this family's pieces has to put it in the same layer: goingson builds
860/// `tables.css` in its own `build.rs` from `makeover_webview::list`, and those
861/// rules are as generated as the ones in `layout.css`.
862#[must_use]
863pub fn in_css_layer(css: &str) -> String {
864    let mut out = format!("@layer {CSS_LAYER} {{\n");
865    for line in css.lines() {
866        // Blank lines stay blank; indenting one leaves trailing whitespace.
867        if line.is_empty() {
868            out.push('\n');
869        } else {
870            let _ = writeln!(out, "    {line}");
871        }
872    }
873    out.push_str("}\n");
874    out
875}
876
877/// Emit the whole geometry layer as a `:root { … }` block at one density.
878///
879/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
880/// constant, so a web consumer should bake it in at build time rather than
881/// apply it from JS on every load.
882///
883/// The density argument reaches the gaps only. The scale and the type ramp are
884/// the same at every density, which is why neither takes one.
885#[must_use]
886pub fn geometry_css_vars(density: Density) -> String {
887    format!(
888        ":root {{\n{}\n{}\n{}}}\n",
889        scale_css_declarations(),
890        gap_css_declarations(density),
891        text_css_declarations()
892    )
893}
894
895/// The whole spacing layer with the canonical density selection, as CSS.
896///
897/// **Density is a capability, not a device and not a width.** A narrow window
898/// on a desktop still has a pointer in it and a tablet at full width still has
899/// a finger, so the touch preset hangs off `(hover: none), (pointer: coarse)`
900/// rather than off a breakpoint or a user-agent string. That is the question
901/// the platform actually answers, and it is the one [`Density`] is asking.
902///
903/// `explicit_touch` names a selector an app sets when the *user* has chosen.
904/// It is emitted last and therefore wins at equal specificity, because
905/// detection is a default rather than a verdict: a touchscreen laptop and
906/// someone who simply wants roomier targets are both real, and neither is
907/// visible to a media query.
908///
909/// Settles a policy three consumers previously answered three ways. GoingsOn
910/// sniffed the user agent behind a mode class, Balanced Breakfast used
911/// `(hover: none)` alone, and audiofiles had no switch at all; the first of
912/// those asked what device this is as a proxy for a capability already
913/// reported.
914///
915/// Emitted inside [`CSS_LAYER`] since 0.6.0. Custom properties follow the
916/// ordinary cascade, so unlayered ones outrank layered ones: an app that puts
917/// its own `:root` overrides in a named layer while this file stayed unlayered
918/// would find the generated tokens beating the overrides meant to replace them.
919/// That is the same trap the component sheet had, and it is not visible until
920/// the app adopts layers.
921#[must_use]
922pub fn density_css(explicit_touch: Option<&str>) -> String {
923    in_css_layer(&density_declarations(explicit_touch))
924}
925
926/// [`density_css`] without the layer wrapper.
927fn density_declarations(explicit_touch: Option<&str>) -> String {
928    let mut css = geometry_css_vars(Density::Pointer);
929    css.push_str("\n/* Touch: targets separate, shells hold. */\n");
930    css.push_str("@media ");
931    css.push_str(Density::Touch.media_condition());
932    css.push_str(" {\n");
933    for line in gap_css_overrides(":root", Density::Touch).lines() {
934        css.push_str("    ");
935        css.push_str(line);
936        css.push('\n');
937    }
938    css.push_str("}\n");
939    if let Some(selector) = explicit_touch {
940        css.push_str("\n/* An explicit user choice, last so it wins over detection. */\n");
941        css.push_str(&gap_css_overrides(selector, Density::Touch));
942    }
943    css
944}
945
946/// Emit a density preset as a scoped override block.
947///
948/// Only the relational layer is emitted: the scale and the base do not change
949/// between presets, so an app ships [`geometry_css_vars`] at its default
950/// density and one of these per mode class it supports.
951///
952/// ```
953/// # use makeover_geometry::{Density, gap_css_overrides};
954/// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
955/// assert!(css.starts_with(".ui-mode-mobile {\n"));
956/// ```
957#[must_use]
958pub fn gap_css_overrides(selector: &str, density: Density) -> String {
959    format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    #[test]
967    fn density_is_selected_by_capability_not_by_width_or_agent() {
968        let css = density_css(None);
969        assert!(css.contains("@media (hover: none), (pointer: coarse)"));
970        // The three things density must never be selected by.
971        assert!(!css.contains("max-width"), "a breakpoint crept in");
972        assert!(!css.contains("min-width"), "a breakpoint crept in");
973        assert!(!css.contains("ui-mode"), "a device mode crept in");
974    }
975
976    #[test]
977    fn the_spacing_layer_is_emitted_inside_the_family_layer() {
978        // Unlayered declarations outrank layered ones, so an app that layers
979        // its own :root overrides would lose to an unlayered geometry.css.
980        let css = density_css(None);
981        assert!(css.starts_with(&format!("@layer {CSS_LAYER} {{\n")));
982        assert!(css.trim_end().ends_with('}'));
983        // Everything still there, one level in.
984        assert!(css.contains("    :root {"));
985        assert!(css.contains("--gap-peer"));
986    }
987
988    #[test]
989    fn the_type_ramp_ascends_and_never_repeats_a_size() {
990        // A tier that resolves to the same size as its neighbour is a name
991        // with no distinction behind it, which is how a scale grows rungs
992        // nobody can choose between.
993        let sizes: Vec<u16> = Text::all().iter().map(|t| t.px()).collect();
994        assert!(sizes.windows(2).all(|w| w[0] < w[1]), "{sizes:?}");
995        assert_eq!(sizes, vec![12, 14, 16, 18, 20, 24, 32, 40, 48]);
996    }
997
998    #[test]
999    fn the_type_ramp_has_a_legibility_floor() {
1000        // Nothing under 12px at the default base. Text that should recede
1001        // recedes by colour or weight, not by shrinking out of legibility.
1002        assert_eq!(Text::Fine.px(), 12);
1003        assert!(Text::all().iter().all(|t| t.px() >= 12));
1004    }
1005
1006    #[test]
1007    fn type_does_not_move_with_density() {
1008        // Density is a claim about the contact patch, and text is not a
1009        // target. A --text-* inside the touch override means that argument
1010        // was lost somewhere.
1011        let css = density_css(Some(".ui-mode-mobile"));
1012        let root_end = css.find("@media").expect("a touch block");
1013        assert!(css[..root_end].contains("--text-body"));
1014        assert!(!css[root_end..].contains("--text-"), "{}", &css[root_end..]);
1015    }
1016
1017    #[test]
1018    fn every_type_token_scales_from_the_one_base() {
1019        // A literal rem here would be a size that stops tracking the reader's
1020        // root font size, which is the whole point of the base.
1021        for text in Text::all() {
1022            let css = text.ratio().css();
1023            assert!(css.contains(BASE_TOKEN), "{}: {css}", text.token());
1024        }
1025    }
1026
1027    #[test]
1028    fn wrapping_leaves_no_trailing_whitespace_on_blank_lines() {
1029        // A formatter strips these later and calls it a diff.
1030        let css = in_css_layer("a {\n\nb\n}\n");
1031        assert!(!css.lines().any(|l| l != l.trim_end()), "{css:?}");
1032    }
1033
1034    #[test]
1035    fn the_two_density_conditions_are_complements_and_not_negations() {
1036        let pointer = Density::Pointer.media_condition();
1037        let touch = Density::Touch.media_condition();
1038
1039        // Both halves are inverted, feature for feature.
1040        assert!(pointer.contains("hover: hover") && touch.contains("hover: none"));
1041        assert!(pointer.contains("pointer: fine") && touch.contains("pointer: coarse"));
1042
1043        // And the joins are inverted too, which is the part that gets written
1044        // wrong by hand: touch is an OR, so not-touch is an AND. A pointer
1045        // condition joined with a comma would match every touchscreen.
1046        assert!(touch.contains(", "), "touch must be an OR");
1047        assert!(pointer.contains(" and "), "pointer must be an AND");
1048        assert!(!pointer.contains(','), "pointer must not be an OR");
1049    }
1050
1051    #[test]
1052    fn the_emitted_touch_block_is_the_condition_and_not_a_second_copy_of_it() {
1053        // The literal used to be inline here. Nothing may re-spell it.
1054        let css = density_css(None);
1055        assert!(css.contains(&format!("@media {}", Density::Touch.media_condition())));
1056    }
1057
1058    #[test]
1059    fn an_explicit_choice_is_emitted_after_the_detection() {
1060        let css = density_css(Some(".ui-mode-mobile"));
1061        let media = css.find("@media").expect("media query");
1062        let explicit = css.find(".ui-mode-mobile").expect("explicit selector");
1063        // Equal specificity, so order is the whole mechanism: the user's
1064        // choice has to come last or detection quietly overrides it.
1065        assert!(explicit > media, "the explicit selector must come last");
1066    }
1067
1068    #[test]
1069    fn without_an_explicit_selector_there_are_exactly_two_presets() {
1070        assert_eq!(density_css(None).matches("--gap-peer").count(), 2);
1071    }
1072
1073    #[test]
1074    fn the_hig_relationships_land_on_the_hig_values() {
1075        // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
1076        // the default base they must resolve to the numbers the HIG specifies,
1077        // or the departure has cost us the thing it was translating.
1078        assert_eq!(Gap::Bound.px(), 4);
1079        assert_eq!(Gap::Peer.px(), 6);
1080        assert_eq!(Gap::Group.px(), 10);
1081        assert_eq!(Gap::Section.px(), 12);
1082    }
1083
1084    #[test]
1085    fn every_ratio_divides_the_default_base_exactly() {
1086        for step in Step::all() {
1087            let r = step.ratio();
1088            assert_eq!(
1089                u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
1090                0,
1091                "{step:?} is fractional at the default base"
1092            );
1093        }
1094    }
1095
1096    #[test]
1097    fn ratios_scale_linearly() {
1098        for step in Step::all() {
1099            assert_eq!(
1100                step.ratio().px_at(DEFAULT_BASE_PX * 2),
1101                step.px() * 2,
1102                "{step:?} does not double with the base"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn steps_ascend_and_never_repeat() {
1109        let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
1110        let mut sorted = px.clone();
1111        sorted.sort_unstable();
1112        sorted.dedup();
1113        assert_eq!(px, sorted, "steps must be strictly ascending");
1114    }
1115
1116    #[test]
1117    fn gaps_ascend_with_their_relationships_at_every_density() {
1118        for density in [Density::Pointer, Density::Touch] {
1119            let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
1120            let mut sorted = px.clone();
1121            sorted.sort_unstable();
1122            assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
1123        }
1124    }
1125
1126    #[test]
1127    fn touch_separates_targets_and_holds_the_shells() {
1128        // The derivation, asserted so that changing it has to come here and say
1129        // so. Touch is a claim about the contact patch: the gaps between
1130        // distinct tap targets open, and the gaps that are not tap targets do
1131        // not move.
1132        for gap in [Gap::Peer, Gap::Group, Gap::Section] {
1133            assert!(
1134                gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
1135                "{gap:?} separates tap targets and must open on touch"
1136            );
1137        }
1138        for gap in [Gap::Bound, Gap::Pane, Gap::Page] {
1139            assert_eq!(
1140                gap.px_at(Density::Touch),
1141                gap.px_at(Density::Pointer),
1142                "{gap:?} is not a tap target and must not move with the input device"
1143            );
1144        }
1145    }
1146
1147    #[test]
1148    fn touch_never_resolves_tighter_than_pointer() {
1149        // The one cross-density rule, and its direction is the point. The
1150        // preset thrown out on 2026-07-29 tightened Pane and Page on touch,
1151        // which combined with an opened Section to make any Pointer Pane at or
1152        // below 16 an inversion: a derived preset set a floor under the one
1153        // quoted from the HIG, blocking the retune to pane 14 / page 16.
1154        //
1155        // Constraining Touch by Pointer instead cannot do that. A Pointer
1156        // retune downward moves freely; only a Pointer move upward pushes
1157        // Touch, which is the correct direction of authority.
1158        for gap in Gap::all() {
1159            assert!(
1160                gap.px_at(Density::Touch) >= gap.px_at(Density::Pointer),
1161                "{gap:?}: Touch resolved tighter than Pointer"
1162            );
1163        }
1164    }
1165
1166    #[test]
1167    fn the_pointer_retune_is_not_blocked_by_touch() {
1168        // Guards the specific regression above rather than trusting the general
1169        // rule to imply it. Touch's own ordering must hold using Touch values
1170        // only, so that a Pointer Pane at or below Touch's Section is legal.
1171        assert!(
1172            Gap::Section.px_at(Density::Touch) <= Gap::Pane.px_at(Density::Touch),
1173            "Touch inverted internally, which is what set the old floor"
1174        );
1175        // The retune wants Pointer pane 14 / page 16, both under Touch's
1176        // Section of 16. Nothing in this crate may object to that.
1177        assert!(Gap::Section.px_at(Density::Touch) >= 16);
1178    }
1179
1180    #[test]
1181    fn size_classes_partition_every_width_exactly_once() {
1182        // Mutually exclusive and exhaustive, or a rule lands in two classes and
1183        // whichever is emitted last silently wins. Checked at every width up to
1184        // well past the top boundary rather than at the boundaries alone.
1185        for px in 0..=4000u16 {
1186            let hits: Vec<SizeClass> = SizeClass::all()
1187                .into_iter()
1188                .filter(|c| {
1189                    let lo = c.min_px();
1190                    let hi = match c {
1191                        SizeClass::Compact => SizeClass::Medium.min_px() - 1,
1192                        SizeClass::Medium => SizeClass::Expanded.min_px() - 1,
1193                        SizeClass::Expanded => u16::MAX,
1194                    };
1195                    px >= lo && px <= hi
1196                })
1197                .collect();
1198            assert_eq!(hits.len(), 1, "{px}px matched {hits:?}");
1199            assert_eq!(hits[0], SizeClass::at_width(px), "{px}px disagrees");
1200        }
1201    }
1202
1203    #[test]
1204    fn the_quoted_boundaries_are_the_ones_material_publishes() {
1205        // Quoted, not derived. Changing these means departing from the source,
1206        // which is a decision to record rather than a value to nudge.
1207        assert_eq!(SizeClass::Compact.min_px(), 0);
1208        assert_eq!(SizeClass::Medium.min_px(), 600);
1209        assert_eq!(SizeClass::Expanded.min_px(), 840);
1210    }
1211
1212    #[test]
1213    fn the_media_conditions_do_not_overlap_at_the_boundary() {
1214        // The off-by-one that makes CSS width ranges overlap: max-width is
1215        // inclusive, so it must be one below the next class's min-width.
1216        assert_eq!(
1217            SizeClass::Compact.media_condition(),
1218            "(max-width: 599px)",
1219            "Compact must stop one pixel below Medium"
1220        );
1221        assert_eq!(
1222            SizeClass::Medium.media_condition(),
1223            "(min-width: 600px) and (max-width: 839px)"
1224        );
1225        assert_eq!(SizeClass::Expanded.media_condition(), "(min-width: 840px)");
1226    }
1227
1228    #[test]
1229    fn size_class_does_not_reach_the_gap_scale() {
1230        // Deliberately absent, asserted so that wiring it in has to come here
1231        // and say so. Whether a compact window tightens its shells is a look
1232        // call; deriving it is what went wrong with Touch on 2026-07-29.
1233        //
1234        // This test does not check a value. It checks that the whole spacing
1235        // layer is reachable without naming a size class at all.
1236        let _ = geometry_css_vars(Density::Pointer);
1237        let _ = Gap::Page.px_at(Density::Touch);
1238        assert_eq!(SizeClass::all().len(), 3);
1239    }
1240
1241    #[test]
1242    fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
1243        let t = Surface::terminal();
1244        let cells: Vec<u32> = Gap::all()
1245            .iter()
1246            .map(|g| t.gap(*g, Density::Pointer))
1247            .collect();
1248        // bound, peer | group, section | pane, page
1249        assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
1250    }
1251
1252    #[test]
1253    fn collapsing_is_allowed_but_inverting_is_not() {
1254        // A coarse surface has fewer distinctions, so neighbouring gaps may
1255        // land on the same quantum. What must never happen is a looser
1256        // relationship coming out tighter than a closer one.
1257        for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
1258            for density in [Density::Pointer, Density::Touch] {
1259                let s = Surface {
1260                    base: 16.0,
1261                    quantum,
1262                };
1263                let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
1264                let mut sorted = v.clone();
1265                sorted.sort_unstable();
1266                assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
1267            }
1268        }
1269    }
1270
1271    #[test]
1272    fn the_web_surface_agrees_with_the_pixel_helper() {
1273        let w = Surface::web();
1274        for step in Step::all() {
1275            assert_eq!(
1276                w.resolve(step.ratio()) as u16,
1277                step.px(),
1278                "{step:?} disagrees between surface and px_at"
1279            );
1280        }
1281    }
1282
1283    #[test]
1284    fn quantising_is_monotonic_in_the_ratio() {
1285        let (base, quantum) = (16.0, 1.0);
1286        let mut previous = 0;
1287        for step in Step::all() {
1288            let q = step.ratio().quanta(base, quantum);
1289            assert!(q >= previous, "{step:?} went backwards");
1290            previous = q;
1291        }
1292    }
1293
1294    #[test]
1295    fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
1296        let r = Step::Loose.ratio();
1297        for bad in [0.0_f32, -1.0, f32::NAN] {
1298            assert_eq!(r.quanta(16.0, bad), 0);
1299            assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
1300        }
1301        assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
1302    }
1303
1304    #[test]
1305    fn px_at_rounds_rather_than_truncating() {
1306        // Eighths divide 16 exactly, so the rounding only shows on a base
1307        // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
1308        assert_eq!(Step::Snug.ratio().px_at(15), 6);
1309        assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
1310    }
1311
1312    #[test]
1313    fn tokens_are_unique() {
1314        let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
1315        names.extend(Gap::all().iter().map(|g| g.token()));
1316        names.extend(Text::all().iter().map(|t| t.token()));
1317        let count = names.len();
1318        names.sort_unstable();
1319        names.dedup();
1320        assert_eq!(names.len(), count, "token names collide");
1321    }
1322
1323    #[test]
1324    fn css_is_expressed_over_the_base_never_in_pixels() {
1325        let css = geometry_css_vars(Density::Pointer);
1326        assert!(css.starts_with(":root {\n"));
1327        assert!(css.trim_end().ends_with('}'));
1328        assert!(css.contains("--geometry-base: 1rem;"));
1329        for step in Step::all() {
1330            let line = format!("--{}: {}", step.token(), step.ratio().css());
1331            assert!(css.contains(&line), "missing or wrong: {line}");
1332        }
1333        // A hard pixel count anywhere in the scale defeats the point.
1334        let scale = scale_css_declarations();
1335        assert!(
1336            !scale.contains("px;"),
1337            "the scale must not emit pixel literals:\n{scale}"
1338        );
1339    }
1340
1341    #[test]
1342    fn ratio_css_drops_redundant_arithmetic() {
1343        assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
1344        assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
1345        assert_eq!(
1346            Step::Snug.ratio().css(),
1347            "calc(var(--geometry-base) * 3 / 8)"
1348        );
1349    }
1350
1351    #[test]
1352    fn gaps_reference_steps_rather_than_repeating_values() {
1353        let css = geometry_css_vars(Density::Pointer);
1354        assert!(css.contains("--gap-peer: var(--step-snug);"));
1355        assert!(!css.contains("--gap-peer: calc"));
1356    }
1357
1358    #[test]
1359    fn a_density_override_emits_only_the_relational_layer() {
1360        let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
1361        // Which step Peer lands on is the preset's business, asserted in
1362        // touch_separates_targets_and_holds_the_shells. This says only that the
1363        // gap is emitted and references a step.
1364        assert!(css.contains("--gap-peer: var(--step-"));
1365        // Referencing a step is the point; re-declaring one would fork the
1366        // scale, so the check is on declarations, not on mentions.
1367        let declared: Vec<&str> = css
1368            .lines()
1369            .filter_map(|l| l.trim().strip_prefix("--"))
1370            .filter_map(|l| l.split(':').next())
1371            .collect();
1372        assert!(
1373            declared.iter().all(|t| t.starts_with("gap-")),
1374            "only the relational layer may be overridden, got {declared:?}"
1375        );
1376    }
1377}