Skip to main content

makeover_geometry/
lib.rs

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