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