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