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