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//! # Density presets
43//!
44//! Naming relationships instead of sizes is what makes a density preset
45//! possible at all. [`Density`] changes what each relationship resolves to
46//! without touching a single call site, because no call site names a size.
47//! The mobile and desktop builds of a Tauri app should differ mostly by which
48//! preset they emit, not by a parallel set of hand-written mode-scoped rules.
49//!
50//! The two presets are not one scaled copy of the other, and the asymmetry is
51//! the point — it is also why a base scalar alone cannot express this. Touch
52//! needs *more* room between things you tap, because a fingertip is coarser
53//! than a pointer, and *less* room around the edges, because the screen is
54//! small and outer margin is screen you do not get to use. So `Peer` and
55//! `Section` open up under [`Density::Touch`] while `Pane` and `Page` tighten.
56//! `Bound` never moves: it is the one relationship that says "these are one
57//! object", and separating them on touch would say the opposite.
58//!
59//! # Surfaces, and why a TUI is not a third density
60//!
61//! [`Surface`] is the third axis and the one that carries this to alloy_tui. A
62//! terminal is not a density preset; it is a surface whose smallest
63//! representable step is one cell rather than one pixel. Give
64//! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the
65//! relational vocabulary crosses to a character grid with nothing added.
66//!
67//! Quantising is not a terminal special case either — a display quantises to
68//! the pixel. It is only that rounding 6.0 to the nearest pixel is
69//! uninteresting, while rounding three eighths of a cell to the nearest cell
70//! decides the layout.
71//!
72//! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2
73//! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy:
74//! in a grid that dense, both relationships are expressed by adjacency. A
75//! coarse surface genuinely has fewer distinctions available, and the model
76//! should say so instead of inventing a gap to keep six names distinct.
77//!
78//! So the three axes are: [`Gap`] is what is being separated, [`Density`] is
79//! who is operating it, [`Surface`] is what it is drawn on.
80
81#![forbid(unsafe_code)]
82
83use std::fmt::Write as _;
84
85/// The default base unit in CSS pixels, at a 16px root font size.
86pub const DEFAULT_BASE_PX: u16 = 16;
87
88/// The CSS custom property every ratio scales from.
89pub const BASE_TOKEN: &str = "geometry-base";
90
91/// A fraction of the base unit.
92///
93/// Rational rather than floating point so the scale is exact, comparable and
94/// usable in a `const`. At the default base every ratio below divides evenly.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96pub struct Ratio {
97 /// Top of the fraction.
98 pub numerator: u16,
99 /// Bottom of the fraction. Never zero for any ratio this crate defines.
100 pub denominator: u16,
101}
102
103impl Ratio {
104 /// Resolve against a base measured in whole pixels, rounding to nearest.
105 ///
106 /// Integer maths throughout, and exact for every [`Step`] at
107 /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is
108 /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the
109 /// common case stays `const`.
110 #[must_use]
111 pub const fn px_at(self, base_px: u16) -> u16 {
112 let (n, d) = (self.numerator as u32, self.denominator as u32);
113 let scaled = base_px as u32 * n;
114 // Round half away from zero without leaving integer arithmetic.
115 ((scaled * 2 + d) / (d * 2)) as u16
116 }
117
118 /// Resolve against an arbitrary base, keeping the fraction.
119 ///
120 /// The exact value, before any surface gets a say. Prefer
121 /// [`Surface::resolve`] unless you specifically want the unsnapped number.
122 #[must_use]
123 pub fn scale(self, base: f32) -> f32 {
124 base * f32::from(self.numerator) / f32::from(self.denominator)
125 }
126
127 /// How many whole quanta this ratio is worth on a surface whose smallest
128 /// representable step is `quantum`.
129 ///
130 /// The generalisation of "round to a pixel". A display quantises to one
131 /// pixel and the answer is usually uninteresting; a terminal quantises to
132 /// one cell and the answer is the whole design. Rounds to nearest, and
133 /// does not floor at one: a gap that lands below half a quantum should
134 /// collapse to nothing, because on that surface it *is* nothing.
135 ///
136 /// A `quantum` that is zero, negative or not finite yields `0` rather than
137 /// panicking or returning infinity — a surface with no smallest step is a
138 /// caller error, not a layout to guess at.
139 #[must_use]
140 pub fn quanta(self, base: f32, quantum: f32) -> u32 {
141 if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
142 return 0;
143 }
144 let exact = self.scale(base) / quantum;
145 if exact <= 0.0 {
146 0
147 } else {
148 // `as` saturates at the integer bound, so a wild base cannot wrap.
149 exact.round() as u32
150 }
151 }
152
153 /// Resolve against a base and snap to a whole number of `quantum`.
154 ///
155 /// The value [`Self::quanta`] counts, back in the surface's own units.
156 /// Guards the degenerate quantum in its own right rather than leaning on
157 /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN,
158 /// not zero.
159 #[must_use]
160 pub fn quantize(self, base: f32, quantum: f32) -> f32 {
161 if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() {
162 return 0.0;
163 }
164 self.quanta(base, quantum) as f32 * quantum
165 }
166
167 /// The CSS value, as an expression over [`BASE_TOKEN`].
168 ///
169 /// A whole multiple of the base emits without a division, and 1:1 emits
170 /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
171 #[must_use]
172 pub fn css(self) -> String {
173 match (self.numerator, self.denominator) {
174 (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
175 (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
176 (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
177 }
178 }
179}
180
181/// What the layout is being drawn on: a base unit, and the smallest step the
182/// surface can actually represent.
183///
184/// Both are in the surface's own units, and the crate never assumes those are
185/// pixels. A display measures in pixels and can represent one of them; a
186/// terminal measures in cells and cannot represent less than one. That single
187/// difference is the whole of the terminal story — a TUI is not a density, it
188/// is a surface with a coarse quantum, and the relational vocabulary above
189/// crosses over untouched.
190///
191/// Quantising is not a terminal special case. A display does it too; it is
192/// just that rounding 6.0 to the nearest pixel is uninteresting, whereas
193/// rounding three eighths of a cell to the nearest cell is a design decision
194/// the surface makes for you.
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct Surface {
197 /// The base unit, in this surface's units.
198 pub base: f32,
199 /// The smallest step this surface can represent, in the same units.
200 pub quantum: f32,
201}
202
203impl Surface {
204 /// A display measuring in CSS pixels: a 16px base, one-pixel quantum.
205 #[must_use]
206 pub fn web() -> Self {
207 Self {
208 base: f32::from(DEFAULT_BASE_PX),
209 quantum: 1.0,
210 }
211 }
212
213 /// A terminal measuring in cells: a one-cell base, one-cell quantum.
214 ///
215 /// The coarsest surface in the family, and the one that proves the
216 /// vocabulary. `bound` and `peer` collapse to no cells at all, which is
217 /// correct — in a grid this dense, "belongs to" and "is a peer of" are
218 /// both expressed by adjacency, not by a gap.
219 #[must_use]
220 pub fn terminal() -> Self {
221 Self {
222 base: 1.0,
223 quantum: 1.0,
224 }
225 }
226
227 /// Resolve a ratio on this surface, snapped to its quantum.
228 #[must_use]
229 pub fn resolve(self, ratio: Ratio) -> f32 {
230 ratio.quantize(self.base, self.quantum)
231 }
232
233 /// How many whole quanta a ratio is worth here.
234 ///
235 /// What a cell-addressed layout actually wants: the count, not the size.
236 #[must_use]
237 pub fn quanta(self, ratio: Ratio) -> u32 {
238 ratio.quanta(self.base, self.quantum)
239 }
240
241 /// Resolve a relationship on this surface at a given density, in quanta.
242 ///
243 /// The whole model in one call: *what* is being separated, *who* is
244 /// operating it, *what* it is drawn on.
245 #[must_use]
246 pub fn gap(self, gap: Gap, density: Density) -> u32 {
247 self.quanta(gap.step_at(density).ratio())
248 }
249}
250
251/// Which input the layout is being sized for.
252///
253/// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides
254/// which step a relationship picks, the surface decides how that step lands.
255/// Which density applies is the app's call — GoingsOn and Balanced Breakfast
256/// already decide it once and hang a `ui-mode-*` class off the result.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
258pub enum Density {
259 /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
260 #[default]
261 Pointer,
262 /// Finger. Targets separate, shells tighten.
263 Touch,
264}
265
266/// A named separation between two things.
267///
268/// Pick by relationship. The size is a consequence of the name, not the other
269/// way round, and callers should never care what it is.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
271pub enum Gap {
272 /// A control and the thing it belongs to: an edit field and its pop-up, a
273 /// checkbox and its label, an icon and the text it labels. Reads as one
274 /// object.
275 Bound,
276 /// Items of the same kind in a list: stacked checkboxes, radio buttons,
277 /// rows, chips in a row. Reads as a set.
278 Peer,
279 /// A container's inner margin, and the distance between sibling groups
280 /// side by side. Reads as "inside this box".
281 Group,
282 /// Separated groups, and rows of actions. The first gap that reads as a
283 /// deliberate break rather than as breathing room.
284 Section,
285 /// Panel padding and content shells. Layout, not controls.
286 Pane,
287 /// The outermost shell margin. One per screen, usually.
288 Page,
289}
290
291/// A raw step on the underlying scale.
292///
293/// Present because not every distance is a relationship between two controls —
294/// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
295/// fits: a step name says how big, a gap name says why.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
297pub enum Step {
298 /// An eighth of the base. Optical nudges inside small inline elements.
299 Hair,
300 /// A quarter of the base.
301 Tight,
302 /// Three eighths of the base.
303 Snug,
304 /// Half the base.
305 Base,
306 /// Five eighths of the base.
307 Roomy,
308 /// Three quarters of the base.
309 Wide,
310 /// The base itself.
311 Loose,
312 /// One and a half times the base.
313 Broad,
314 /// Twice the base.
315 Vast,
316 /// Three times the base.
317 Colossal,
318}
319
320impl Step {
321 /// This step as a fraction of the base unit.
322 #[must_use]
323 pub const fn ratio(self) -> Ratio {
324 let (numerator, denominator) = match self {
325 Self::Hair => (1, 8),
326 Self::Tight => (1, 4),
327 Self::Snug => (3, 8),
328 Self::Base => (1, 2),
329 Self::Roomy => (5, 8),
330 Self::Wide => (3, 4),
331 Self::Loose => (1, 1),
332 Self::Broad => (3, 2),
333 Self::Vast => (2, 1),
334 Self::Colossal => (3, 1),
335 };
336 Ratio {
337 numerator,
338 denominator,
339 }
340 }
341
342 /// Size in CSS pixels at the default base.
343 #[must_use]
344 pub const fn px(self) -> u16 {
345 self.ratio().px_at(DEFAULT_BASE_PX)
346 }
347
348 /// The CSS custom-property name, without the leading `--`.
349 #[must_use]
350 pub const fn token(self) -> &'static str {
351 match self {
352 Self::Hair => "step-hair",
353 Self::Tight => "step-tight",
354 Self::Snug => "step-snug",
355 Self::Base => "step-base",
356 Self::Roomy => "step-roomy",
357 Self::Wide => "step-wide",
358 Self::Loose => "step-loose",
359 Self::Broad => "step-broad",
360 Self::Vast => "step-vast",
361 Self::Colossal => "step-colossal",
362 }
363 }
364
365 /// Every step, smallest first.
366 #[must_use]
367 pub const fn all() -> [Self; 10] {
368 [
369 Self::Hair,
370 Self::Tight,
371 Self::Snug,
372 Self::Base,
373 Self::Roomy,
374 Self::Wide,
375 Self::Loose,
376 Self::Broad,
377 Self::Vast,
378 Self::Colossal,
379 ]
380 }
381}
382
383impl Gap {
384 /// The step this relationship resolves to at a given density.
385 #[must_use]
386 pub const fn step_at(self, density: Density) -> Step {
387 match (self, density) {
388 // Binding is not a separation, so it does not open up on touch.
389 (Self::Bound, _) => Step::Tight,
390
391 (Self::Peer, Density::Pointer) => Step::Snug,
392 (Self::Peer, Density::Touch) => Step::Roomy,
393
394 (Self::Group, Density::Pointer) => Step::Roomy,
395 (Self::Group, Density::Touch) => Step::Wide,
396
397 (Self::Section, Density::Pointer) => Step::Wide,
398 (Self::Section, Density::Touch) => Step::Loose,
399
400 // Shells tighten on touch: outer margin is screen you don't get.
401 (Self::Pane, Density::Pointer) => Step::Broad,
402 (Self::Pane, Density::Touch) => Step::Loose,
403
404 (Self::Page, Density::Pointer) => Step::Vast,
405 (Self::Page, Density::Touch) => Step::Broad,
406 }
407 }
408
409 /// The step this relationship resolves to at the default density.
410 #[must_use]
411 pub const fn step(self) -> Step {
412 self.step_at(Density::Pointer)
413 }
414
415 /// Size in CSS pixels at the default base, at a given density.
416 #[must_use]
417 pub const fn px_at(self, density: Density) -> u16 {
418 self.step_at(density).px()
419 }
420
421 /// Size in CSS pixels at the default base and density.
422 #[must_use]
423 pub const fn px(self) -> u16 {
424 self.step().px()
425 }
426
427 /// The CSS custom-property name, without the leading `--`.
428 #[must_use]
429 pub const fn token(self) -> &'static str {
430 match self {
431 Self::Bound => "gap-bound",
432 Self::Peer => "gap-peer",
433 Self::Group => "gap-group",
434 Self::Section => "gap-section",
435 Self::Pane => "gap-pane",
436 Self::Page => "gap-page",
437 }
438 }
439
440 /// Every relationship, tightest first.
441 #[must_use]
442 pub const fn all() -> [Self; 6] {
443 [
444 Self::Bound,
445 Self::Peer,
446 Self::Group,
447 Self::Section,
448 Self::Pane,
449 Self::Page,
450 ]
451 }
452}
453
454/// Emit the base unit and the raw scale as CSS declarations, no selector.
455///
456/// Density-invariant: the steps are the vocabulary, and only which step a
457/// relationship picks changes between presets.
458#[must_use]
459pub fn scale_css_declarations() -> String {
460 let mut out = String::new();
461 let _ = writeln!(
462 out,
463 " /* Every size below is a ratio of this. Scale it and the whole\n \
464 layout scales with it, including for a user who has asked for\n \
465 larger text. */\n --{BASE_TOKEN}: 1rem;\n"
466 );
467 out.push_str(" /* Raw scale. Prefer a --gap-* below; reach here only when\n");
468 out.push_str(" no relationship describes the distance. */\n");
469 for step in Step::all() {
470 let _ = writeln!(out, " --{}: {};", step.token(), step.ratio().css());
471 }
472 out
473}
474
475/// Emit the relational layer for one density as CSS declarations, no selector.
476///
477/// Gaps reference their step rather than repeating a value, so the scale has
478/// exactly one definition and a reader can see which relationship maps where.
479#[must_use]
480pub fn gap_css_declarations(density: Density) -> String {
481 let mut out = String::new();
482 for gap in Gap::all() {
483 let _ = writeln!(
484 out,
485 " --{}: var(--{});",
486 gap.token(),
487 gap.step_at(density).token()
488 );
489 }
490 out
491}
492
493/// Emit the whole geometry layer as a `:root { … }` block at one density.
494///
495/// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
496/// constant, so a web consumer should bake it in at build time rather than
497/// apply it from JS on every load.
498#[must_use]
499pub fn geometry_css_vars(density: Density) -> String {
500 format!(
501 ":root {{\n{}\n{}}}\n",
502 scale_css_declarations(),
503 gap_css_declarations(density)
504 )
505}
506
507/// Emit a density preset as a scoped override block.
508///
509/// Only the relational layer is emitted: the scale and the base do not change
510/// between presets, so an app ships [`geometry_css_vars`] at its default
511/// density and one of these per mode class it supports.
512///
513/// ```
514/// # use makeover_geometry::{Density, gap_css_overrides};
515/// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
516/// assert!(css.starts_with(".ui-mode-mobile {\n"));
517/// ```
518#[must_use]
519pub fn gap_css_overrides(selector: &str, density: Density) -> String {
520 format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn the_hig_relationships_land_on_the_hig_values() {
529 // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
530 // the default base they must resolve to the numbers the HIG specifies,
531 // or the departure has cost us the thing it was translating.
532 assert_eq!(Gap::Bound.px(), 4);
533 assert_eq!(Gap::Peer.px(), 6);
534 assert_eq!(Gap::Group.px(), 10);
535 assert_eq!(Gap::Section.px(), 12);
536 }
537
538 #[test]
539 fn every_ratio_divides_the_default_base_exactly() {
540 for step in Step::all() {
541 let r = step.ratio();
542 assert_eq!(
543 u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
544 0,
545 "{step:?} is fractional at the default base"
546 );
547 }
548 }
549
550 #[test]
551 fn ratios_scale_linearly() {
552 for step in Step::all() {
553 assert_eq!(
554 step.ratio().px_at(DEFAULT_BASE_PX * 2),
555 step.px() * 2,
556 "{step:?} does not double with the base"
557 );
558 }
559 }
560
561 #[test]
562 fn steps_ascend_and_never_repeat() {
563 let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
564 let mut sorted = px.clone();
565 sorted.sort_unstable();
566 sorted.dedup();
567 assert_eq!(px, sorted, "steps must be strictly ascending");
568 }
569
570 #[test]
571 fn gaps_ascend_with_their_relationships_at_every_density() {
572 for density in [Density::Pointer, Density::Touch] {
573 let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
574 let mut sorted = px.clone();
575 sorted.sort_unstable();
576 assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
577 }
578 }
579
580 #[test]
581 fn touch_separates_targets_and_tightens_shells() {
582 // The asymmetry is the whole reason a base scalar would not do.
583 for gap in [Gap::Peer, Gap::Section] {
584 assert!(
585 gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
586 "{gap:?} must open up for a fingertip"
587 );
588 }
589 for gap in [Gap::Pane, Gap::Page] {
590 assert!(
591 gap.px_at(Density::Touch) < gap.px_at(Density::Pointer),
592 "{gap:?} must tighten on a small screen"
593 );
594 }
595 assert_eq!(
596 Gap::Bound.px_at(Density::Touch),
597 Gap::Bound.px_at(Density::Pointer),
598 "bound things stay bound"
599 );
600 }
601
602 #[test]
603 fn a_terminal_resolves_the_vocabulary_to_whole_cells() {
604 let t = Surface::terminal();
605 let cells: Vec<u32> = Gap::all()
606 .iter()
607 .map(|g| t.gap(*g, Density::Pointer))
608 .collect();
609 // bound, peer | group, section | pane, page
610 assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]);
611 }
612
613 #[test]
614 fn collapsing_is_allowed_but_inverting_is_not() {
615 // A coarse surface has fewer distinctions, so neighbouring gaps may
616 // land on the same quantum. What must never happen is a looser
617 // relationship coming out tighter than a closer one.
618 for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] {
619 for density in [Density::Pointer, Density::Touch] {
620 let s = Surface {
621 base: 16.0,
622 quantum,
623 };
624 let v: Vec<u32> = Gap::all().iter().map(|g| s.gap(*g, density)).collect();
625 let mut sorted = v.clone();
626 sorted.sort_unstable();
627 assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}");
628 }
629 }
630 }
631
632 #[test]
633 fn the_web_surface_agrees_with_the_pixel_helper() {
634 let w = Surface::web();
635 for step in Step::all() {
636 assert_eq!(
637 w.resolve(step.ratio()) as u16,
638 step.px(),
639 "{step:?} disagrees between surface and px_at"
640 );
641 }
642 }
643
644 #[test]
645 fn quantising_is_monotonic_in_the_ratio() {
646 let (base, quantum) = (16.0, 1.0);
647 let mut previous = 0;
648 for step in Step::all() {
649 let q = step.ratio().quanta(base, quantum);
650 assert!(q >= previous, "{step:?} went backwards");
651 previous = q;
652 }
653 }
654
655 #[test]
656 fn a_degenerate_quantum_yields_nothing_rather_than_panicking() {
657 let r = Step::Loose.ratio();
658 for bad in [0.0_f32, -1.0, f32::NAN] {
659 assert_eq!(r.quanta(16.0, bad), 0);
660 assert!(r.quantize(16.0, bad).abs() < f32::EPSILON);
661 }
662 assert_eq!(r.quanta(f32::INFINITY, 1.0), 0);
663 }
664
665 #[test]
666 fn px_at_rounds_rather_than_truncating() {
667 // Eighths divide 16 exactly, so the rounding only shows on a base
668 // that does not: 3/8 of 15 is 5.625, which is 6px, not 5.
669 assert_eq!(Step::Snug.ratio().px_at(15), 6);
670 assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6);
671 }
672
673 #[test]
674 fn tokens_are_unique() {
675 let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
676 names.extend(Gap::all().iter().map(|g| g.token()));
677 let count = names.len();
678 names.sort_unstable();
679 names.dedup();
680 assert_eq!(names.len(), count, "token names collide");
681 }
682
683 #[test]
684 fn css_is_expressed_over_the_base_never_in_pixels() {
685 let css = geometry_css_vars(Density::Pointer);
686 assert!(css.starts_with(":root {\n"));
687 assert!(css.trim_end().ends_with('}'));
688 assert!(css.contains("--geometry-base: 1rem;"));
689 for step in Step::all() {
690 let line = format!("--{}: {}", step.token(), step.ratio().css());
691 assert!(css.contains(&line), "missing or wrong: {line}");
692 }
693 // A hard pixel count anywhere in the scale defeats the point.
694 let scale = scale_css_declarations();
695 assert!(
696 !scale.contains("px;"),
697 "the scale must not emit pixel literals:\n{scale}"
698 );
699 }
700
701 #[test]
702 fn ratio_css_drops_redundant_arithmetic() {
703 assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
704 assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
705 assert_eq!(
706 Step::Snug.ratio().css(),
707 "calc(var(--geometry-base) * 3 / 8)"
708 );
709 }
710
711 #[test]
712 fn gaps_reference_steps_rather_than_repeating_values() {
713 let css = geometry_css_vars(Density::Pointer);
714 assert!(css.contains("--gap-peer: var(--step-snug);"));
715 assert!(!css.contains("--gap-peer: calc"));
716 }
717
718 #[test]
719 fn a_density_override_emits_only_the_relational_layer() {
720 let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
721 assert!(css.contains("--gap-peer: var(--step-roomy);"));
722 // Referencing a step is the point; re-declaring one would fork the
723 // scale, so the check is on declarations, not on mentions.
724 let declared: Vec<&str> = css
725 .lines()
726 .filter_map(|l| l.trim().strip_prefix("--"))
727 .filter_map(|l| l.split(':').next())
728 .collect();
729 assert!(
730 declared.iter().all(|t| t.starts_with("gap-")),
731 "only the relational layer may be overridden, got {declared:?}"
732 );
733 }
734}