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