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