Skip to main content

makeover_geometry/
lib.rs

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