Skip to main content

max / makeover-geometry

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