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