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