//! The invariant half of the make-family design system. //! //! //! //! [`makeover`] resolves colour, which varies by theme. This crate carries //! everything that does not: spacing, radius, border width and the type scale. //! The split is the same one Balanced Breakfast's theme contract has always //! drawn — *a theme overrides colour tokens only* — moved out of two app //! stylesheets so the three consumers stop maintaining three copies of it. //! //! # Spacing is relational, not numeric //! //! The Mac OS 8 Human Interface Guidelines specify white space by *what two //! things are being separated*, never by a size name, and define no base grid //! unit. A control and its satellite pop-up are set 4 pixels apart; peers //! stacked in a list get 6; a group box's inner margin is 10; separated groups //! and rows of push buttons get 12. //! //! That vocabulary is the primary interface here. [`Gap`] names the //! relationship and the size follows from it, exactly as `surface-raised` //! names an intent and the hex follows from it. The raw [`Step`] scale exists //! underneath for distances a relationship does not describe, but reaching for //! it is a smell worth a second look. //! //! Naming the relationship is what makes the rule reviewable. Whether a gap //! should be 6px or 8px is unanswerable in isolation; whether two things are //! peers is not. //! //! # Ratios, not pixel counts //! //! This is the deliberate departure from the HIG, which is specified in hard //! device pixels because in 1997 there was one pixel density and one text //! size. Every [`Step`] here is a [`Ratio`] of a single base unit, so the //! whole system scales from one knob: `--geometry-base`, `1rem` by default. //! //! At the default base the ratios land exactly on the HIG's numbers — `Snug` //! is three eighths of 16px, which is 6px — so nothing is lost in the //! translation. What is gained is that the layout tracks the user's text size //! instead of fighting it, an accessibility setting becomes one value rather //! than a sweep, and the scale means the same thing at any display density. //! //! # Type is relational too //! //! [`Text`] names what a piece of text is — body, note, head — and the size //! follows, exactly as [`Gap`] names what is being separated. It is the same //! argument: whether a caption should be 13px or 14px cannot be reviewed, //! whether a piece of text is a caption can. //! //! Type has its own rungs rather than reusing [`Step`], because the spacing //! scale is eighths of the base to match the HIG's distances and a type ramp //! wants different fractions. What the two axes share is the base, so the //! reader's root font size moves the text and the space around it together. //! //! Unlike spacing, type does not move with [`Density`]. The reason is in //! [`Text`], and it is the same one that keeps shells out of the touch preset. //! //! # Corners, same move again //! //! [`Radius`] names what the corner belongs to. The scale is deliberately the //! shortest of the three, because rounding carries one bit of meaning — //! whether the thing is meant to be pressed — and a long radius scale is one //! nobody can choose from. `Square` is a rung rather than the absence of one, //! so a container can state that it is square and a reader can tell that from //! a rule nobody wrote. //! //! # Density presets //! //! Naming relationships instead of sizes is what makes a density preset //! possible at all. [`Density`] changes what each relationship resolves to //! without touching a single call site, because no call site names a size. //! The mobile and desktop builds of a Tauri app should differ mostly by which //! preset they emit, not by a parallel set of hand-written mode-scoped rules. //! //! ## What Touch is a claim about //! //! Touch is a claim about the **contact patch and nothing else**. A fingertip //! is coarse where a cursor hotspot is a point, and the only consequence of //! that is mis-tap cost: when two adjacent things do different things, an //! imprecise contact needs more room between them to land on the intended one. //! //! Shells are not tap targets. Panel padding and the outer page margin separate //! a region from the edge of the screen, and no amount of coarseness in the //! pointing device makes that separation riskier. So **Touch opens the gaps //! that separate targets and leaves the shells exactly where Pointer put //! them**: //! //! | Gap | Pointer | Touch | why | //! |---|---|---|---| //! | bound | 4 | 4 | not a separation at all | //! | peer | 6 | 10 | adjacent distinct targets, the whole point | //! | group | 10 | 12 | holds the peer/group distinction open | //! | section | 12 | 16 | a deliberate break stays legible as one | //! | pane | 24 | 24 | a shell is not a target | //! | page | 32 | 32 | a shell is not a target | //! //! A Touch preset must never *tighten* `Pane` or `Page` on the argument that //! outer margin is screen you do not get. **That is a claim about screen //! budget, not about the input device.** Opening `Section` to 16 while //! tightening `Pane` below it makes any Pointer `Pane` at or under 16 an //! inversion, so a derived preset silently sets a floor under the one quoted //! from the HIG. A phone is small *and* touch; a tablet and a touchscreen //! laptop are big and touch. Screen budget is a separate axis and does not //! belong here. //! //! ## The one cross-density rule //! //! **Touch never resolves tighter than Pointer**, at any gap. Stated as a //! deliberate claim rather than inherited, and chosen for its direction: it //! constrains the *derived* preset by the *quoted* one, never the reverse. A //! Pointer retune downward moves freely and cannot be blocked by Touch. Only a //! Pointer move upward can push //! Touch, and that is the correct direction of authority. //! //! # Size class: the axis Density kept being asked to carry //! //! [`SizeClass`] answers **how much screen there is**, which is not the same //! question as what is pointing at it. A phone is small and touch; a tablet and //! a touchscreen laptop are big and touch; a half-width desktop window is small //! and pointer. Four real combinations, and one axis cannot name them. //! //! This is the home for the claim *outer margin is screen you do not get*. On //! the input device it lets a derived preset set a floor under a quoted one; //! here it is correct. //! //! Boundaries are **quoted** (Material 3 window size classes: 600 and 840) //! rather than derived, for the same reason the [`Gap`] values are. This crate //! carries the boundaries only; what appears or disappears at each is a product //! decision and belongs to `makeover-touch`. //! //! Size class feeds [`Gap::step_at_size`], and only the two shells listen to //! it: `pane` and `page` come down one step on a compact window. That is where //! "outer margin is screen you don't get" belongs. It does not belong in the //! Pointer preset, which would be the same mistake one axis over. //! //! # Surfaces, and why a TUI is not a third density //! //! [`Surface`] is the fourth axis and the one that carries this to alloy_tui. A //! terminal is not a density preset; it is a surface whose smallest //! representable step is one cell rather than one pixel. Give //! [`Ratio::quanta`] a quantum and it answers in whole units of it, so the //! relational vocabulary crosses to a character grid with nothing added. //! //! Quantising is not a terminal special case either — a display quantises to //! the pixel. It is only that rounding 6.0 to the nearest pixel is //! uninteresting, while rounding three eighths of a cell to the nearest cell //! decides the layout. //! //! On [`Surface::terminal`] the pointer preset resolves to 0, 0, 1, 1, 2, 2 //! cells. `Bound` and `Peer` collapsing to nothing is correct rather than lossy: //! in a grid that dense, both relationships are expressed by adjacency. A //! coarse surface genuinely has fewer distinctions available, and the model //! should say so instead of inventing a gap to keep six names distinct. //! //! So the three axes are: [`Gap`] is what is being separated, [`Density`] is //! who is operating it, [`Surface`] is what it is drawn on. #![forbid(unsafe_code)] use std::fmt::Write as _; /// The default base unit in CSS pixels, at a 16px root font size. pub const DEFAULT_BASE_PX: u16 = 16; /// The CSS custom property every ratio scales from. pub const BASE_TOKEN: &str = "geometry-base"; /// A fraction of the base unit. /// /// Rational rather than floating point so the scale is exact, comparable and /// usable in a `const`. At the default base every ratio below divides evenly. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Ratio { /// Top of the fraction. pub numerator: u16, /// Bottom of the fraction. Never zero for any ratio this crate defines. pub denominator: u16, } impl Ratio { /// Resolve against a base measured in whole pixels, rounding to nearest. /// /// Integer maths throughout, and exact for every [`Step`] at /// [`DEFAULT_BASE_PX`] because the scale is eighths. This is /// [`Self::quanta`] with a quantum of one pixel, kept separate only so the /// common case stays `const`. #[must_use] pub const fn px_at(self, base_px: u16) -> u16 { let (n, d) = (self.numerator as u32, self.denominator as u32); let scaled = base_px as u32 * n; // Round half away from zero without leaving integer arithmetic. ((scaled * 2 + d) / (d * 2)) as u16 } /// Resolve against an arbitrary base, keeping the fraction. /// /// The exact value, before any surface gets a say. Prefer /// [`Surface::resolve`] unless you specifically want the unsnapped number. #[must_use] pub fn scale(self, base: f32) -> f32 { base * f32::from(self.numerator) / f32::from(self.denominator) } /// How many whole quanta this ratio is worth on a surface whose smallest /// representable step is `quantum`. /// /// The generalisation of "round to a pixel". A display quantises to one /// pixel and the answer is usually uninteresting; a terminal quantises to /// one cell and the answer is the whole design. Rounds to nearest, and /// does not floor at one: a gap that lands below half a quantum should /// collapse to nothing, because on that surface it *is* nothing. /// /// A `quantum` that is zero, negative or not finite yields `0` rather than /// panicking or returning infinity — a surface with no smallest step is a /// caller error, not a layout to guess at. #[must_use] pub fn quanta(self, base: f32, quantum: f32) -> u32 { if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() { return 0; } let exact = self.scale(base) / quantum; if exact <= 0.0 { 0 } else { // `as` saturates at the integer bound, so a wild base cannot wrap. exact.round() as u32 } } /// Resolve against a base and snap to a whole number of `quantum`. /// /// The value [`Self::quanta`] counts, back in the surface's own units. /// Guards the degenerate quantum in its own right rather than leaning on /// [`Self::quanta`]: a count of zero times a non-finite quantum is NaN, /// not zero. #[must_use] pub fn quantize(self, base: f32, quantum: f32) -> f32 { if !quantum.is_finite() || quantum <= 0.0 || !base.is_finite() { return 0.0; } self.quanta(base, quantum) as f32 * quantum } /// The CSS value, as an expression over [`BASE_TOKEN`]. /// /// A whole multiple of the base emits without a division, and 1:1 emits /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise. #[must_use] pub fn css(self) -> String { match (self.numerator, self.denominator) { (n, d) if n == d => format!("var(--{BASE_TOKEN})"), (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"), (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"), } } } /// What the layout is being drawn on: a base unit, and the smallest step the /// surface can actually represent. /// /// Both are in the surface's own units, and the crate never assumes those are /// pixels. A display measures in pixels and can represent one of them; a /// terminal measures in cells and cannot represent less than one. That single /// difference is the whole of the terminal story — a TUI is not a density, it /// is a surface with a coarse quantum, and the relational vocabulary above /// crosses over untouched. /// /// Quantising is not a terminal special case. A display does it too; it is /// just that rounding 6.0 to the nearest pixel is uninteresting, whereas /// rounding three eighths of a cell to the nearest cell is a design decision /// the surface makes for you. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Surface { /// The base unit, in this surface's units. pub base: f32, /// The smallest step this surface can represent, in the same units. pub quantum: f32, } impl Surface { /// A display measuring in CSS pixels: a 16px base, one-pixel quantum. #[must_use] pub fn web() -> Self { Self { base: f32::from(DEFAULT_BASE_PX), quantum: 1.0, } } /// A terminal measuring in cells: a one-cell base, one-cell quantum. /// /// The coarsest surface in the family, and the one that proves the /// vocabulary. `bound` and `peer` collapse to no cells at all, which is /// correct — in a grid this dense, "belongs to" and "is a peer of" are /// both expressed by adjacency, not by a gap. #[must_use] pub fn terminal() -> Self { Self { base: 1.0, quantum: 1.0, } } /// Resolve a ratio on this surface, snapped to its quantum. #[must_use] pub fn resolve(self, ratio: Ratio) -> f32 { ratio.quantize(self.base, self.quantum) } /// How many whole quanta a ratio is worth here. /// /// What a cell-addressed layout actually wants: the count, not the size. #[must_use] pub fn quanta(self, ratio: Ratio) -> u32 { ratio.quanta(self.base, self.quantum) } /// Resolve a relationship on this surface at a given density, in quanta. /// /// The whole model in one call: *what* is being separated, *who* is /// operating it, *what* it is drawn on. #[must_use] pub fn gap(self, gap: Gap, density: Density) -> u32 { self.quanta(gap.step_at(density).ratio()) } } /// Which input the layout is being sized for. /// /// A preset, not a breakpoint, and orthogonal to [`Surface`]: density decides /// which step a relationship picks, the surface decides how that step lands. /// Which density applies is the app's call — GoingsOn and Balanced Breakfast /// already decide it once and hang a `ui-mode-*` class off the result. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Density { /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions. #[default] Pointer, /// Finger. Opens the gaps that separate distinct tap targets and leaves /// the shells where Pointer put them, because a coarse contact patch /// raises mis-tap cost and a panel margin is not something you tap. See /// the crate-level "Density presets" section for the derivation. Touch, } impl Density { /// The media condition selecting exactly this density, without the /// `@media`. /// /// A capability question rather than a width or a device, which is the /// policy this crate settles for [`density_css`]. /// /// The two are **not** each other's textual negation, and that is the /// reason they live in one place. Touch is comma-joined, so it is an OR, /// and negating an OR gives an AND with both halves inverted. Deriving one /// from the other by eye is how the pair drifts apart, and it drifts /// silently: a wrong negation still parses, still minifies, and only shows /// up as hover states surviving on a phone. /// /// Pointer's condition is what a renderer wraps a hover rule in. /// `makeover-touch` decides *whether* a hover rule should be gated; /// this decides what the gate is spelled as. #[must_use] pub const fn media_condition(self) -> &'static str { match self { Self::Pointer => "(hover: hover) and (pointer: fine)", Self::Touch => "(hover: none), (pointer: coarse)", } } } /// How much screen there is, independent of what is pointing at it. /// /// The second axis, and the one [`Density`] kept being asked to carry. A phone /// is small *and* touch; a tablet and a touchscreen laptop are big and touch; a /// half-width window on a desktop is small and pointer. Those are four real /// combinations and one axis cannot name them, which is what made the old Touch /// preset tighten shells it had no business tightening. /// /// **Boundaries are quoted, not derived**, for the same reason the [`Gap`] /// values are: a derived boundary is one nobody can check. They are Material 3's /// window size classes, the best-known three-tier split with published numbers. /// Apple's size classes are two-tier and expressed as regular/compact per axis, /// which does not give a middle to aim at. /// /// Source: /// /// This enum carries the **boundaries only**. What appears, disappears or /// reflows at each is a product decision and belongs to `makeover-touch`, not /// here, with one exception: shells tighten on a compact window, through /// [`Gap::step_at_size`]. That is a look call, so it is eyeballed rather than /// derived. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] pub enum SizeClass { /// Under 600px. Phones in either orientation, and any window narrowed to /// phone width regardless of what is pointing at it. Compact, /// 600px to 839px. Small tablets, split-screen panes, half-width windows. #[default] Medium, /// 840px and up. Laptops, desktops, tablets in landscape. Expanded, } impl SizeClass { /// Lower bound in CSS pixels, inclusive. [`Self::Compact`] starts at zero. #[must_use] pub const fn min_px(self) -> u16 { match self { Self::Compact => 0, Self::Medium => 600, Self::Expanded => 840, } } /// The media condition selecting exactly this class, without the `@media`. /// /// Bounded on both sides for the middle class, so the three are mutually /// exclusive and a rule cannot land in two of them. `max-width` is one below /// the next class's `min_px`, because CSS width ranges are inclusive. #[must_use] pub fn media_condition(self) -> String { match self { Self::Compact => format!("(max-width: {}px)", Self::Medium.min_px() - 1), Self::Medium => format!( "(min-width: {}px) and (max-width: {}px)", Self::Medium.min_px(), Self::Expanded.min_px() - 1 ), Self::Expanded => format!("(min-width: {}px)", Self::Expanded.min_px()), } } /// The CSS class name an app may hang off this, without the leading dot. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Compact => "size-compact", Self::Medium => "size-medium", Self::Expanded => "size-expanded", } } /// Every class, narrowest first. #[must_use] pub const fn all() -> [Self; 3] { [Self::Compact, Self::Medium, Self::Expanded] } /// The class a given viewport width falls in. #[must_use] pub const fn at_width(px: u16) -> Self { if px >= Self::Expanded.min_px() { Self::Expanded } else if px >= Self::Medium.min_px() { Self::Medium } else { Self::Compact } } } /// A named separation between two things. /// /// Pick by relationship. The size is a consequence of the name, not the other /// way round, and callers should never care what it is. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Gap { /// A control and the thing it belongs to: an edit field and its pop-up, a /// checkbox and its label, an icon and the text it labels. Reads as one /// object. Bound, /// Items of the same kind in a list: stacked checkboxes, radio buttons, /// rows, chips in a row. Reads as a set. Peer, /// A container's inner margin, and the distance between sibling groups /// side by side. Reads as "inside this box". Group, /// Separated groups, and rows of actions. The first gap that reads as a /// deliberate break rather than as breathing room. Section, /// Panel padding and content shells. Layout, not controls. Pane, /// The outermost shell margin. One per screen, usually. Page, } /// A raw step on the underlying scale. /// /// Present because not every distance is a relationship between two controls — /// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one /// fits: a step name says how big, a gap name says why. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Step { /// An eighth of the base. Optical nudges inside small inline elements. Hair, /// A quarter of the base. Tight, /// Three eighths of the base. Snug, /// Half the base. Base, /// Five eighths of the base. Roomy, /// Three quarters of the base. Wide, /// The base itself. Loose, /// One and a half times the base. Broad, /// Twice the base. Vast, /// Three times the base. Colossal, } impl Step { /// This step as a fraction of the base unit. #[must_use] pub const fn ratio(self) -> Ratio { let (numerator, denominator) = match self { Self::Hair => (1, 8), Self::Tight => (1, 4), Self::Snug => (3, 8), Self::Base => (1, 2), Self::Roomy => (5, 8), Self::Wide => (3, 4), Self::Loose => (1, 1), Self::Broad => (3, 2), Self::Vast => (2, 1), Self::Colossal => (3, 1), }; Ratio { numerator, denominator, } } /// Size in CSS pixels at the default base. #[must_use] pub const fn px(self) -> u16 { self.ratio().px_at(DEFAULT_BASE_PX) } /// The CSS custom-property name, without the leading `--`. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Hair => "step-hair", Self::Tight => "step-tight", Self::Snug => "step-snug", Self::Base => "step-base", Self::Roomy => "step-roomy", Self::Wide => "step-wide", Self::Loose => "step-loose", Self::Broad => "step-broad", Self::Vast => "step-vast", Self::Colossal => "step-colossal", } } /// Every step, smallest first. #[must_use] pub const fn all() -> [Self; 10] { [ Self::Hair, Self::Tight, Self::Snug, Self::Base, Self::Roomy, Self::Wide, Self::Loose, Self::Broad, Self::Vast, Self::Colossal, ] } } impl Gap { /// The step this relationship resolves to at a given density, on a /// [`SizeClass::Medium`] or wider window. /// /// [`Density::Pointer`]'s values are the HIG's own. [`Density::Touch`] /// opens the three gaps that separate distinct tap targets and holds the /// rest, per the crate-level "Density presets" section. /// /// Shells tighten on a compact window rather than at touch density. Use /// [`Self::step_at_size`] where the window width is known; this is the /// wider-window answer and the one every existing caller already meant. #[must_use] pub const fn step_at(self, density: Density) -> Step { self.step_at_size(density, SizeClass::Medium) } /// The step this relationship resolves to at a given density and window /// size class. /// /// Only the two shells move, and only on [`SizeClass::Compact`]: `pane` /// 24 to 16, `page` 32 to 24, one step down each. The four gaps between /// controls do not, because how much room a window has says nothing about /// how far apart two tap targets should be. /// /// **Which axis owns this** (Max). A tighter window edge is a claim about /// screen budget, and smuggling screen budget into the density axis is the /// bug [`SizeClass`] exists to prevent. So the claim goes here and the /// quoted Pointer values hold. Both compact values sit on the eighths /// scale, so no `Step` at 7/8 = 14 is needed. #[must_use] pub const fn step_at_size(self, density: Density, size: SizeClass) -> Step { match self { // Binding is not a separation, so it does not open up on touch // either: separating these would say they are two objects. Self::Bound => Step::Tight, // The three that carry mis-tap cost. Peer is the one that matters // most (stacked rows, adjacent chips) and moves furthest; Group // and Section follow only far enough to stay distinct from it. Self::Peer => match density { Density::Pointer => Step::Snug, Density::Touch => Step::Roomy, }, Self::Group => match density { Density::Pointer => Step::Roomy, Density::Touch => Step::Wide, }, Self::Section => match density { Density::Pointer => Step::Wide, Density::Touch => Step::Loose, }, // Shells. Not tap targets, so the contact patch has no opinion. // Screen budget is the axis that does, and it is this one. Self::Pane => match size { SizeClass::Compact => Step::Loose, SizeClass::Medium | SizeClass::Expanded => Step::Broad, }, Self::Page => match size { SizeClass::Compact => Step::Broad, SizeClass::Medium | SizeClass::Expanded => Step::Vast, }, } } /// The step this relationship resolves to at the default density. #[must_use] pub const fn step(self) -> Step { self.step_at(Density::Pointer) } /// Size in CSS pixels at the default base, at a given density. #[must_use] pub const fn px_at(self, density: Density) -> u16 { self.step_at(density).px() } /// Size in CSS pixels at the default base, at a given density and window /// size class. #[must_use] pub const fn px_at_size(self, density: Density, size: SizeClass) -> u16 { self.step_at_size(density, size).px() } /// Size in CSS pixels at the default base and density. #[must_use] pub const fn px(self) -> u16 { self.step().px() } /// The CSS custom-property name, without the leading `--`. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Bound => "gap-bound", Self::Peer => "gap-peer", Self::Group => "gap-group", Self::Section => "gap-section", Self::Pane => "gap-pane", Self::Page => "gap-page", } } /// Every relationship, tightest first. #[must_use] pub const fn all() -> [Self; 6] { [ Self::Bound, Self::Peer, Self::Group, Self::Section, Self::Pane, Self::Page, ] } } /// What a piece of text is, from which its size follows. /// /// The type axis, and the same move [`Gap`] makes on the spacing axis: name /// the role and let the size follow, so the choice is reviewable. Whether a /// caption should be 13px or 14px is unanswerable in isolation; whether a /// piece of text is a caption is not. /// /// # Why the ratios are their own ramp /// /// Type does not reuse [`Step`]. The spacing scale is built in eighths of the /// base because that is what the HIG's distances land on, and a type ramp /// needs different rungs — 7/8 and 9/8 sit either side of body copy and have /// no spacing meaning at all, while `Hair` and `Tight` are far below any /// legible size. Sharing the enum would have meant widening it for rungs /// spacing never asks for. /// /// What is shared is the thing that matters: every rung here is a [`Ratio`] /// of `--geometry-base`, so text tracks the user's chosen root size exactly /// as spacing does, and one knob still moves the whole design. /// /// # Why the floor is 3/4 /// /// Twelve pixels at the default base, and nothing below it. Sizes under that /// are a legibility problem rather than a tier, and a scale that offers one /// is a scale that invites it. Text that needs to recede should recede by /// colour or weight, which cost no legibility. /// /// # Why type does not shift on touch /// /// [`Density`] is a claim about the contact patch and nothing else, and text /// is not a tap target. The reader's own root font size is already the knob /// for how large text should be, and it already moves this whole ramp. So the /// type axis is density-invariant, and a phone gets the same tiers a desktop /// does. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Text { /// Three quarters of the base. Timestamps, badges, legal lines. Fine, /// Seven eighths of the base. Secondary text: metadata, table cells, /// captions, form help. Note, /// The base itself. Running copy, and the size everything else is read /// against. Body, /// Nine eighths of the base. Emphasised copy: intros, card titles. Lead, /// Five quarters of the base. The third heading level. Subhead, /// One and a half times the base. Section headings, the second level. Head, /// Twice the base. The page's own title, the first level. Title, /// Two and a half times the base. Display copy, above the document /// hierarchy rather than at the top of it. Display, /// Three times the base. One per page at most: a landing hero. Hero, } impl Text { /// This role's size as a fraction of the base unit. #[must_use] pub const fn ratio(self) -> Ratio { let (numerator, denominator) = match self { Self::Fine => (3, 4), Self::Note => (7, 8), Self::Body => (1, 1), Self::Lead => (9, 8), Self::Subhead => (5, 4), Self::Head => (3, 2), Self::Title => (2, 1), Self::Display => (5, 2), Self::Hero => (3, 1), }; Ratio { numerator, denominator, } } /// Size in CSS pixels at the default base. #[must_use] pub const fn px(self) -> u16 { self.ratio().px_at(DEFAULT_BASE_PX) } /// The CSS custom-property name, without the leading `--`. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Fine => "text-fine", Self::Note => "text-note", Self::Body => "text-body", Self::Lead => "text-lead", Self::Subhead => "text-subhead", Self::Head => "text-head", Self::Title => "text-title", Self::Display => "text-display", Self::Hero => "text-hero", } } /// Every role, smallest first. #[must_use] pub const fn all() -> [Self; 9] { [ Self::Fine, Self::Note, Self::Body, Self::Lead, Self::Subhead, Self::Head, Self::Title, Self::Display, Self::Hero, ] } } /// How rounded a corner is, named for what the corner belongs to. /// /// The third axis to make the same move as [`Gap`] and [`Text`]: name the /// thing and let the value follow. Whether a corner should be 3px or 4px is /// unanswerable in isolation, and answering it once per component is how a /// stylesheet ends up with 2, 3, 4, 6, 8 and 12 all meaning "slightly /// rounded". /// /// # Rounding is an affordance /// /// The scale is deliberately short, because a corner radius carries one bit /// of meaning: whether the thing is meant to be pressed. [`Self::Square`] /// exists as a named rung rather than as the absence of a radius so that a /// container states that it is square, and a reader can tell a deliberate /// zero from a rule nobody wrote. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Radius { /// No rounding. Containers: cards, panels, dropdowns, page shells. Square, /// An eighth of the base. The tightest corner still visible: inline code, /// small badges, status chips. Fine, /// A quarter of the base. Controls: buttons, inputs, selects. Control, /// Half the base. Surfaces that round rather than sit square: media /// covers, callout boxes, feature cards. Panel, /// A circle, whatever the element's size. Round, } impl Radius { /// This corner as a fraction of the base unit. /// /// [`Self::Round`] has none, and that is not an oversight: 50% is a /// proportion of the element's own box rather than of the base, so it /// does not scale with `--geometry-base` and cannot be written as a /// [`Ratio`]. Use [`Self::css`], which spells every rung. #[must_use] pub const fn ratio(self) -> Option { let (numerator, denominator) = match self { Self::Square => (0, 1), Self::Fine => (1, 8), Self::Control => (1, 4), Self::Panel => (1, 2), Self::Round => return None, }; Some(Ratio { numerator, denominator, }) } /// Size in CSS pixels at the default base, or `None` for [`Self::Round`]. #[must_use] pub const fn px(self) -> Option { match self.ratio() { Some(r) => Some(r.px_at(DEFAULT_BASE_PX)), None => None, } } /// The CSS value for this rung. /// /// `Square` emits a bare `0` rather than a `calc()` that multiplies the /// base by nothing, and `Round` emits the percentage. #[must_use] pub fn css(self) -> String { match self { Self::Square => "0".to_owned(), Self::Round => "50%".to_owned(), other => other .ratio() .expect("every rung but Round has a ratio") .css(), } } /// The CSS custom-property name, without the leading `--`. #[must_use] pub const fn token(self) -> &'static str { match self { Self::Square => "radius-square", Self::Fine => "radius-fine", Self::Control => "radius-control", Self::Panel => "radius-panel", Self::Round => "radius-round", } } /// Every rung, squarest first. #[must_use] pub const fn all() -> [Self; 5] { [ Self::Square, Self::Fine, Self::Control, Self::Panel, Self::Round, ] } } /// Emit the base unit and the raw scale as CSS declarations, no selector. /// /// Density-invariant: the steps are the vocabulary, and only which step a /// relationship picks changes between presets. #[must_use] pub fn scale_css_declarations() -> String { let mut out = String::new(); let _ = writeln!( out, " /* Every size below is a ratio of this. Scale it and the whole\n \ layout scales with it, including for a user who has asked for\n \ larger text. */\n --{BASE_TOKEN}: 1rem;\n" ); out.push_str(" /* Raw scale. Prefer a --gap-* below; reach here only when\n"); out.push_str(" no relationship describes the distance. */\n"); for step in Step::all() { let _ = writeln!(out, " --{}: {};", step.token(), step.ratio().css()); } out } /// Emit the type axis as CSS declarations, no selector. /// /// Takes no [`Density`]: text is not a tap target, so the contact patch has no /// opinion on it. See [`Text`] for the derivation. #[must_use] pub fn text_css_declarations() -> String { let mut out = String::new(); out.push_str(" /* Type. Named for what the text is; the size follows.\n"); out.push_str(" Ratios of the base, so text tracks the reader's own\n"); out.push_str(" root size. Density-invariant: text is not a target. */\n"); for text in Text::all() { let _ = writeln!(out, " --{}: {};", text.token(), text.ratio().css()); } out } /// Emit the corner scale as CSS declarations, no selector. /// /// Takes no [`Density`] for the same reason [`text_css_declarations`] does /// not: a corner is not a tap target. #[must_use] pub fn radius_css_declarations() -> String { let mut out = String::new(); out.push_str(" /* Corners. Rounding says a thing is meant to be pressed,\n"); out.push_str(" so the scale is short on purpose and square is a rung\n"); out.push_str(" rather than the absence of one. */\n"); for radius in Radius::all() { let _ = writeln!(out, " --{}: {};", radius.token(), radius.css()); } out } /// Emit the relational layer for one density as CSS declarations, no selector. /// /// Gaps reference their step rather than repeating a value, so the scale has /// exactly one definition and a reader can see which relationship maps where. #[must_use] pub fn gap_css_declarations(density: Density) -> String { let mut out = String::new(); for gap in Gap::all() { let _ = writeln!( out, " --{}: var(--{});", gap.token(), gap.step_at(density).token() ); } out } /// The cascade layer every stylesheet the make-family generates is wrapped in. /// /// One name shared by every emitter in the family, so an app writes it once and /// the design system's output lands in one place it can order against: /// /// ```css /// @layer makeover, base, components, responsive; /// ``` /// /// # Why a layer at all /// /// The cascade resolves origin and importance, then layer, then specificity, /// then source order, and **unlayered normal declarations outrank every named /// layer**. So the moment an app declares any layer of its own, every rule it /// owns loses to unlayered generated CSS regardless of specificity or of /// loading last. Emitting into a layer is what stops that, and putting the name /// here rather than in each app is what stops three apps picking three names. /// /// # Why this constant lives in the geometry crate /// /// Not because spacing owns it. This crate is the only one every CSS-emitting /// crate in the family already depends on, and it is already the crate that /// spells CSS for the family (`media_condition`, `Step::token`, `Ratio::css`). /// A second copy in `makeover-webview` is exactly the drift /// [`Density::media_condition`] exists to prevent, one layer up. pub const CSS_LAYER: &str = "makeover"; /// Wrap generated CSS in [`CSS_LAYER`]. /// /// Every whole-stylesheet emitter in the family ends with this call. Exposed /// rather than kept private because an app that assembles its own stylesheet /// out of this family's pieces has to put it in the same layer: goingson builds /// `tables.css` in its own `build.rs` from `makeover_webview::list`, and those /// rules are as generated as the ones in `layout.css`. #[must_use] pub fn in_css_layer(css: &str) -> String { let mut out = format!("@layer {CSS_LAYER} {{\n"); for line in css.lines() { // Blank lines stay blank; indenting one leaves trailing whitespace. if line.is_empty() { out.push('\n'); } else { let _ = writeln!(out, " {line}"); } } out.push_str("}\n"); out } /// Emit the whole geometry layer as a `:root { … }` block at one density. /// /// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is /// constant, so a web consumer should bake it in at build time rather than /// apply it from JS on every load. /// /// The density argument reaches the gaps only. The scale and the type ramp are /// the same at every density, which is why neither takes one. #[must_use] pub fn geometry_css_vars(density: Density) -> String { format!( ":root {{\n{}\n{}\n{}\n{}}}\n", scale_css_declarations(), gap_css_declarations(density), text_css_declarations(), radius_css_declarations() ) } /// The whole spacing layer with the canonical density selection, as CSS. /// /// **Density is a capability, not a device and not a width.** A narrow window /// on a desktop still has a pointer in it and a tablet at full width still has /// a finger, so the touch preset hangs off `(hover: none), (pointer: coarse)` /// rather than off a breakpoint or a user-agent string. That is the question /// the platform actually answers, and it is the one [`Density`] is asking. /// /// `explicit_touch` names a selector an app sets when the *user* has chosen. /// It is emitted last and therefore wins at equal specificity, because /// detection is a default rather than a verdict: a touchscreen laptop and /// someone who simply wants roomier targets are both real, and neither is /// visible to a media query. /// /// Never sniff the user agent for this: that asks what device this is as a /// proxy for a capability the browser already reports. /// /// Emitted inside [`CSS_LAYER`]. Custom properties follow the /// ordinary cascade, so unlayered ones outrank layered ones: an app that puts /// its own `:root` overrides in a named layer while this file stayed unlayered /// would find the generated tokens beating the overrides meant to replace them. /// That is the same trap the component sheet had, and it is not visible until /// the app adopts layers. #[must_use] pub fn density_css(explicit_touch: Option<&str>) -> String { in_css_layer(&density_declarations(explicit_touch)) } /// [`density_css`] without the layer wrapper. fn density_declarations(explicit_touch: Option<&str>) -> String { let mut css = geometry_css_vars(Density::Pointer); css.push_str("\n/* Touch: targets separate, shells hold. */\n"); css.push_str("@media "); css.push_str(Density::Touch.media_condition()); css.push_str(" {\n"); for line in gap_css_overrides(":root", Density::Touch).lines() { css.push_str(" "); css.push_str(line); css.push('\n'); } css.push_str("}\n"); if let Some(selector) = explicit_touch { css.push_str("\n/* An explicit user choice, last so it wins over detection. */\n"); css.push_str(&gap_css_overrides(selector, Density::Touch)); } css } /// Emit the compact-window shell override. /// /// The one place this crate is allowed to ask how wide the window is. Density /// must never be selected by width — [`density_css`] has a test forbidding a /// breakpoint from appearing in it at all — because a capability query answers /// "what is pointing at this" and a breakpoint does not. Size class is the /// opposite: width is exactly what it means, so it gets its own emitter and /// its own media query rather than being folded into that file. /// /// Only the two shells move, and only below [`SizeClass::Medium`]'s boundary: /// `pane` 24 to 16, `page` 32 to 24. The four gaps between controls are absent /// from the block, so a narrow window never reasons about tap targets. /// /// Emitted inside [`CSS_LAYER`] for the same reason [`density_css`] is: an /// unlayered custom property outranks a layered one, so an app that layers its /// own overrides would otherwise lose to this. /// /// ``` /// # use makeover_geometry::size_class_css; /// let css = size_class_css(); /// assert!(css.contains("--gap-pane")); /// assert!(!css.contains("--gap-peer"), "a control gap crept into a width query"); /// ``` #[must_use] pub fn size_class_css() -> String { in_css_layer(&size_class_declarations()) } /// [`size_class_css`] without the layer wrapper. fn size_class_declarations() -> String { // Compact is everything below Medium's lower bound, so the query ends one // step under it. Fractional, because a 599.5px viewport is reachable on a // fractional-scaling display and an integer bound would drop it into // neither class. let ceiling = f32::from(SizeClass::Medium.min_px()) - 0.02; let mut css = String::new(); css.push_str("/* Compact window: shells tighten. Outer margin is screen you\n"); css.push_str(" don't get, which is a claim about the window and not about\n"); css.push_str(" what is pointing at it, so it lives here and not in the\n"); css.push_str(" density presets. Targets are untouched. */\n"); let _ = writeln!(css, "@media (max-width: {ceiling}px) {{"); css.push_str(" :root {\n"); for gap in [Gap::Pane, Gap::Page] { let _ = writeln!( css, " --{}: var(--{});", gap.token(), gap.step_at_size(Density::Pointer, SizeClass::Compact) .token() ); } css.push_str(" }\n}\n"); css } /// Emit a density preset as a scoped override block. /// /// Only the relational layer is emitted: the scale and the base do not change /// between presets, so an app ships [`geometry_css_vars`] at its default /// density and one of these per mode class it supports. /// /// ``` /// # use makeover_geometry::{Density, gap_css_overrides}; /// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch); /// assert!(css.starts_with(".ui-mode-mobile {\n")); /// ``` #[must_use] pub fn gap_css_overrides(selector: &str, density: Density) -> String { format!("{selector} {{\n{}}}\n", gap_css_declarations(density)) } #[cfg(test)] mod tests { use super::*; #[test] fn density_is_selected_by_capability_not_by_width_or_agent() { let css = density_css(None); assert!(css.contains("@media (hover: none), (pointer: coarse)")); // The three things density must never be selected by. assert!(!css.contains("max-width"), "a breakpoint crept in"); assert!(!css.contains("min-width"), "a breakpoint crept in"); assert!(!css.contains("ui-mode"), "a device mode crept in"); } #[test] fn the_spacing_layer_is_emitted_inside_the_family_layer() { // Unlayered declarations outrank layered ones, so an app that layers // its own :root overrides would lose to an unlayered geometry.css. let css = density_css(None); assert!(css.starts_with(&format!("@layer {CSS_LAYER} {{\n"))); assert!(css.trim_end().ends_with('}')); // Everything still there, one level in. assert!(css.contains(" :root {")); assert!(css.contains("--gap-peer")); } #[test] fn the_type_ramp_ascends_and_never_repeats_a_size() { // A tier that resolves to the same size as its neighbour is a name // with no distinction behind it, which is how a scale grows rungs // nobody can choose between. let sizes: Vec = Text::all().iter().map(|t| t.px()).collect(); assert!(sizes.windows(2).all(|w| w[0] < w[1]), "{sizes:?}"); assert_eq!(sizes, vec![12, 14, 16, 18, 20, 24, 32, 40, 48]); } #[test] fn the_type_ramp_has_a_legibility_floor() { // Nothing under 12px at the default base. Text that should recede // recedes by colour or weight, not by shrinking out of legibility. assert_eq!(Text::Fine.px(), 12); assert!(Text::all().iter().all(|t| t.px() >= 12)); } #[test] fn the_corner_scale_is_short_and_ordered() { // A radius carries one bit of meaning, whether the thing is meant to // be pressed, so a long scale is a scale nobody can choose from. let px: Vec> = Radius::all().iter().map(|r| r.px()).collect(); assert_eq!(px, vec![Some(0), Some(2), Some(4), Some(8), None]); } #[test] fn square_and_round_are_spelled_not_calculated() { // `calc(var(--geometry-base) * 0 / 1)` is a zero nobody can read, and // 50% is a proportion of the element rather than of the base. assert_eq!(Radius::Square.css(), "0"); assert_eq!(Radius::Round.css(), "50%"); assert_eq!(Radius::Round.ratio(), None); assert!(Radius::Control.css().contains(BASE_TOKEN)); } #[test] fn type_does_not_move_with_density() { // Density is a claim about the contact patch, and text is not a // target. A --text-* inside the touch override means that argument // was lost somewhere. let css = density_css(Some(".ui-mode-mobile")); let root_end = css.find("@media").expect("a touch block"); assert!(css[..root_end].contains("--text-body")); assert!(!css[root_end..].contains("--text-"), "{}", &css[root_end..]); } #[test] fn every_type_token_scales_from_the_one_base() { // A literal rem here would be a size that stops tracking the reader's // root font size, which is the whole point of the base. for text in Text::all() { let css = text.ratio().css(); assert!(css.contains(BASE_TOKEN), "{}: {css}", text.token()); } } #[test] fn wrapping_leaves_no_trailing_whitespace_on_blank_lines() { // A formatter strips these later and calls it a diff. let css = in_css_layer("a {\n\nb\n}\n"); assert!(!css.lines().any(|l| l != l.trim_end()), "{css:?}"); } #[test] fn the_two_density_conditions_are_complements_and_not_negations() { let pointer = Density::Pointer.media_condition(); let touch = Density::Touch.media_condition(); // Both halves are inverted, feature for feature. assert!(pointer.contains("hover: hover") && touch.contains("hover: none")); assert!(pointer.contains("pointer: fine") && touch.contains("pointer: coarse")); // And the joins are inverted too, which is the part that gets written // wrong by hand: touch is an OR, so not-touch is an AND. A pointer // condition joined with a comma would match every touchscreen. assert!(touch.contains(", "), "touch must be an OR"); assert!(pointer.contains(" and "), "pointer must be an AND"); assert!(!pointer.contains(','), "pointer must not be an OR"); } #[test] fn the_emitted_touch_block_is_the_condition_and_not_a_second_copy_of_it() { // The literal used to be inline here. Nothing may re-spell it. let css = density_css(None); assert!(css.contains(&format!("@media {}", Density::Touch.media_condition()))); } #[test] fn an_explicit_choice_is_emitted_after_the_detection() { let css = density_css(Some(".ui-mode-mobile")); let media = css.find("@media").expect("media query"); let explicit = css.find(".ui-mode-mobile").expect("explicit selector"); // Equal specificity, so order is the whole mechanism: the user's // choice has to come last or detection quietly overrides it. assert!(explicit > media, "the explicit selector must come last"); } #[test] fn without_an_explicit_selector_there_are_exactly_two_presets() { assert_eq!(density_css(None).matches("--gap-peer").count(), 2); } #[test] fn the_hig_relationships_land_on_the_hig_values() { // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at // the default base they must resolve to the numbers the HIG specifies, // or the departure has cost us the thing it was translating. assert_eq!(Gap::Bound.px(), 4); assert_eq!(Gap::Peer.px(), 6); assert_eq!(Gap::Group.px(), 10); assert_eq!(Gap::Section.px(), 12); } #[test] fn every_ratio_divides_the_default_base_exactly() { for step in Step::all() { let r = step.ratio(); assert_eq!( u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator), 0, "{step:?} is fractional at the default base" ); } } #[test] fn ratios_scale_linearly() { for step in Step::all() { assert_eq!( step.ratio().px_at(DEFAULT_BASE_PX * 2), step.px() * 2, "{step:?} does not double with the base" ); } } #[test] fn steps_ascend_and_never_repeat() { let px: Vec = Step::all().iter().map(|s| s.px()).collect(); let mut sorted = px.clone(); sorted.sort_unstable(); sorted.dedup(); assert_eq!(px, sorted, "steps must be strictly ascending"); } #[test] fn gaps_ascend_with_their_relationships_at_every_density() { for density in [Density::Pointer, Density::Touch] { let px: Vec = Gap::all().iter().map(|g| g.px_at(density)).collect(); let mut sorted = px.clone(); sorted.sort_unstable(); assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter"); } } #[test] fn touch_separates_targets_and_holds_the_shells() { // The derivation, asserted so that changing it has to come here and say // so. Touch is a claim about the contact patch: the gaps between // distinct tap targets open, and the gaps that are not tap targets do // not move. for gap in [Gap::Peer, Gap::Group, Gap::Section] { assert!( gap.px_at(Density::Touch) > gap.px_at(Density::Pointer), "{gap:?} separates tap targets and must open on touch" ); } for gap in [Gap::Bound, Gap::Pane, Gap::Page] { assert_eq!( gap.px_at(Density::Touch), gap.px_at(Density::Pointer), "{gap:?} is not a tap target and must not move with the input device" ); } } #[test] fn touch_never_resolves_tighter_than_pointer() { // The one cross-density rule, and its direction is the point. The // preset thrown out on 2026-07-29 tightened Pane and Page on touch, // which combined with an opened Section to make any Pointer Pane at or // below 16 an inversion: a derived preset set a floor under the one // quoted from the HIG, blocking the retune to pane 14 / page 16. // // Constraining Touch by Pointer instead cannot do that. A Pointer // retune downward moves freely; only a Pointer move upward pushes // Touch, which is the correct direction of authority. for gap in Gap::all() { assert!( gap.px_at(Density::Touch) >= gap.px_at(Density::Pointer), "{gap:?}: Touch resolved tighter than Pointer" ); } } #[test] fn the_pointer_retune_is_not_blocked_by_touch() { // Guards the specific regression above rather than trusting the general // rule to imply it. Touch's own ordering must hold using Touch values // only, so that a Pointer Pane at or below Touch's Section is legal. assert!( Gap::Section.px_at(Density::Touch) <= Gap::Pane.px_at(Density::Touch), "Touch inverted internally, which is what set the old floor" ); // The retune this guarded is WITHDRAWN (Max, 2026-08-09): shells // tighten by size class, not by moving the quoted Pointer values, so // Pointer pane holds at 24 and never goes under Touch's Section. // // The assertion stays anyway. It is not about the retune; it is about // the direction of authority, and the day a derived preset can set a // floor under a quoted one is the day this crate has the 2026-07-29 // bug back regardless of what anybody wanted to retune. assert!(Gap::Section.px_at(Density::Touch) >= 16); } #[test] fn shells_tighten_on_a_compact_window_and_nothing_else_does() { // The whole of the 2026-08-09 ruling, in one test. Shells come down // one step on a compact window; the four gaps that separate controls // do not move, because how much room a window has says nothing about // how far apart two tap targets belong. for density in [Density::Pointer, Density::Touch] { assert_eq!(Gap::Pane.px_at_size(density, SizeClass::Compact), 16); assert_eq!(Gap::Page.px_at_size(density, SizeClass::Compact), 24); for class in [SizeClass::Medium, SizeClass::Expanded] { assert_eq!(Gap::Pane.px_at_size(density, class), 24); assert_eq!(Gap::Page.px_at_size(density, class), 32); } for gap in [Gap::Bound, Gap::Peer, Gap::Group, Gap::Section] { for class in SizeClass::all() { assert_eq!( gap.px_at_size(density, class), gap.px_at(density), "{gap:?} moved on {class:?}, and only shells may" ); } } } } #[test] fn the_widths_never_needed_a_step_at_seven_eighths() { // The retune wanted pane 14, which is 7/8 of the base and off an // eighths scale that runs 2/4/6/8/10/12/16/24/32/48. It would have // needed a new public Step variant, and naming one is a cost paid // forever. Putting the claim on the size-class axis lands both compact // values on steps that already exist. for gap in [Gap::Pane, Gap::Page] { for density in [Density::Pointer, Density::Touch] { let step = gap.step_at_size(density, SizeClass::Compact); assert!( Step::all().contains(&step), "{gap:?} compact resolved off the scale" ); } } } #[test] fn no_size_class_inverts_the_ordering() { // Collapse is allowed, inversion is not — the same rule the surface // quantum test applies, now across the third axis. Touch on a compact // window is the tight one: Section opens to 16 and Pane comes down to // 16, so they meet. Meeting is fine. Crossing is not. for density in [Density::Pointer, Density::Touch] { for class in SizeClass::all() { let v: Vec = Gap::all() .iter() .map(|g| g.px_at_size(density, class)) .collect(); let mut sorted = v.clone(); sorted.sort_unstable(); assert_eq!(v, sorted, "{density:?} {class:?} inverted: {v:?}"); } } } #[test] fn step_at_is_the_wider_window_answer() { // Every caller that predates the size-class axis meant the wide // window, so the old entry point has to keep resolving to it or a // consumer tightens silently on a bump it did not read about. for gap in Gap::all() { for density in [Density::Pointer, Density::Touch] { assert_eq!( gap.step_at(density), gap.step_at_size(density, SizeClass::Medium) ); assert_eq!( gap.step_at(density), gap.step_at_size(density, SizeClass::Expanded) ); } } } #[test] fn the_width_query_lives_outside_the_density_file() { // density_is_selected_by_capability_not_by_width_or_agent forbids a // breakpoint in density_css. This is the other half of that rule: the // width query has to exist somewhere, and somewhere is here. let css = size_class_css(); assert!(css.contains("max-width")); assert!(css.contains("--gap-pane")); assert!(css.contains("--gap-page")); // Targets never appear in a width query. for token in ["--gap-bound", "--gap-peer", "--gap-group", "--gap-section"] { assert!(!css.contains(token), "{token} crept into a width query"); } // And it stays out of the density file. assert!(!density_css(None).contains("max-width")); } #[test] fn size_classes_partition_every_width_exactly_once() { // Mutually exclusive and exhaustive, or a rule lands in two classes and // whichever is emitted last silently wins. Checked at every width up to // well past the top boundary rather than at the boundaries alone. for px in 0..=4000u16 { let hits: Vec = SizeClass::all() .into_iter() .filter(|c| { let lo = c.min_px(); let hi = match c { SizeClass::Compact => SizeClass::Medium.min_px() - 1, SizeClass::Medium => SizeClass::Expanded.min_px() - 1, SizeClass::Expanded => u16::MAX, }; px >= lo && px <= hi }) .collect(); assert_eq!(hits.len(), 1, "{px}px matched {hits:?}"); assert_eq!(hits[0], SizeClass::at_width(px), "{px}px disagrees"); } } #[test] fn the_quoted_boundaries_are_the_ones_material_publishes() { // Quoted, not derived. Changing these means departing from the source, // which is a decision to record rather than a value to nudge. assert_eq!(SizeClass::Compact.min_px(), 0); assert_eq!(SizeClass::Medium.min_px(), 600); assert_eq!(SizeClass::Expanded.min_px(), 840); } #[test] fn the_media_conditions_do_not_overlap_at_the_boundary() { // The off-by-one that makes CSS width ranges overlap: max-width is // inclusive, so it must be one below the next class's min-width. assert_eq!( SizeClass::Compact.media_condition(), "(max-width: 599px)", "Compact must stop one pixel below Medium" ); assert_eq!( SizeClass::Medium.media_condition(), "(min-width: 600px) and (max-width: 839px)" ); assert_eq!(SizeClass::Expanded.media_condition(), "(min-width: 840px)"); } #[test] fn the_gap_scale_is_reachable_without_naming_a_size_class() { // This was size_class_does_not_reach_the_gap_scale, a tripwire holding // the axis unwired: "asserted so that wiring it in has to come here and // say so". Saying so, 2026-08-09 — it is wired, Max ruled it, and the // reasoning is in the docs on Gap::step_at_size. // // What survives is the half that was always the real assertion: the // whole spacing layer stays reachable without naming a size class. A // caller that has no idea how wide the window is still gets an answer, // and it is the wide-window one. Only step_at_size asks. let _ = geometry_css_vars(Density::Pointer); let _ = Gap::Page.px_at(Density::Touch); assert_eq!(SizeClass::all().len(), 3); // The tripwire's other half, kept as a real check now that there is // something to check: no size class may reach the four control gaps. // That is the line whose crossing would be the 2026-07-29 bug again. for gap in [Gap::Bound, Gap::Peer, Gap::Group, Gap::Section] { for class in SizeClass::all() { for density in [Density::Pointer, Density::Touch] { assert_eq!( gap.step_at_size(density, class), gap.step_at(density), "{gap:?} moved on {class:?}: screen budget reached a target gap" ); } } } } #[test] fn a_terminal_resolves_the_vocabulary_to_whole_cells() { let t = Surface::terminal(); let cells: Vec = Gap::all() .iter() .map(|g| t.gap(*g, Density::Pointer)) .collect(); // bound, peer | group, section | pane, page assert_eq!(cells, vec![0, 0, 1, 1, 2, 2]); } #[test] fn collapsing_is_allowed_but_inverting_is_not() { // A coarse surface has fewer distinctions, so neighbouring gaps may // land on the same quantum. What must never happen is a looser // relationship coming out tighter than a closer one. for quantum in [0.5_f32, 1.0, 2.0, 3.0, 7.0] { for density in [Density::Pointer, Density::Touch] { let s = Surface { base: 16.0, quantum, }; let v: Vec = Gap::all().iter().map(|g| s.gap(*g, density)).collect(); let mut sorted = v.clone(); sorted.sort_unstable(); assert_eq!(v, sorted, "quantum {quantum} {density:?} inverted: {v:?}"); } } } #[test] fn the_web_surface_agrees_with_the_pixel_helper() { let w = Surface::web(); for step in Step::all() { assert_eq!( w.resolve(step.ratio()) as u16, step.px(), "{step:?} disagrees between surface and px_at" ); } } #[test] fn quantising_is_monotonic_in_the_ratio() { let (base, quantum) = (16.0, 1.0); let mut previous = 0; for step in Step::all() { let q = step.ratio().quanta(base, quantum); assert!(q >= previous, "{step:?} went backwards"); previous = q; } } #[test] fn a_degenerate_quantum_yields_nothing_rather_than_panicking() { let r = Step::Loose.ratio(); for bad in [0.0_f32, -1.0, f32::NAN] { assert_eq!(r.quanta(16.0, bad), 0); assert!(r.quantize(16.0, bad).abs() < f32::EPSILON); } assert_eq!(r.quanta(f32::INFINITY, 1.0), 0); } #[test] fn px_at_rounds_rather_than_truncating() { // Eighths divide 16 exactly, so the rounding only shows on a base // that does not: 3/8 of 15 is 5.625, which is 6px, not 5. assert_eq!(Step::Snug.ratio().px_at(15), 6); assert_eq!(Step::Snug.ratio().px_at(DEFAULT_BASE_PX), 6); } #[test] fn tokens_are_unique() { let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect(); names.extend(Gap::all().iter().map(|g| g.token())); names.extend(Text::all().iter().map(|t| t.token())); names.extend(Radius::all().iter().map(|r| r.token())); let count = names.len(); names.sort_unstable(); names.dedup(); assert_eq!(names.len(), count, "token names collide"); } #[test] fn css_is_expressed_over_the_base_never_in_pixels() { let css = geometry_css_vars(Density::Pointer); assert!(css.starts_with(":root {\n")); assert!(css.trim_end().ends_with('}')); assert!(css.contains("--geometry-base: 1rem;")); for step in Step::all() { let line = format!("--{}: {}", step.token(), step.ratio().css()); assert!(css.contains(&line), "missing or wrong: {line}"); } // A hard pixel count anywhere in the scale defeats the point. let scale = scale_css_declarations(); assert!( !scale.contains("px;"), "the scale must not emit pixel literals:\n{scale}" ); } #[test] fn ratio_css_drops_redundant_arithmetic() { assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)"); assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)"); assert_eq!( Step::Snug.ratio().css(), "calc(var(--geometry-base) * 3 / 8)" ); } #[test] fn gaps_reference_steps_rather_than_repeating_values() { let css = geometry_css_vars(Density::Pointer); assert!(css.contains("--gap-peer: var(--step-snug);")); assert!(!css.contains("--gap-peer: calc")); } #[test] fn a_density_override_emits_only_the_relational_layer() { let css = gap_css_overrides(".ui-mode-mobile", Density::Touch); // Which step Peer lands on is the preset's business, asserted in // touch_separates_targets_and_holds_the_shells. This says only that the // gap is emitted and references a step. assert!(css.contains("--gap-peer: var(--step-")); // Referencing a step is the point; re-declaring one would fork the // scale, so the check is on declarations, not on mentions. let declared: Vec<&str> = css .lines() .filter_map(|l| l.trim().strip_prefix("--")) .filter_map(|l| l.split(':').next()) .collect(); assert!( declared.iter().all(|t| t.starts_with("gap-")), "only the relational layer may be overridden, got {declared:?}" ); } }