Skip to main content

max / makeover-geometry

Seed makeover-geometry: relational spacing on a ratio scale makeover resolves colour, which varies by theme. This crate carries what does not, starting with spacing. It exists because GoingsOn and Balanced Breakfast each maintain the same invariant layer separately, and audiofiles maintains a third in egui with no tokens at all. Spacing is named by relationship, following the Mac OS 8 HIG, which specifies white space by what two things are being separated and defines no grid unit: bound 4px, peer 6, group 10, section 12. Whether a gap should be 6px or 8px is unanswerable alone; whether two things are peers is not, which is what makes the rule reviewable. Departing from the HIG on one axis: every step is a ratio of a single base rather than a pixel count, since 1997 had one pixel density and one text size. At the default 1rem base the ratios land exactly on the HIG's numbers, so the translation costs nothing, and the layout gains the ability to track a user's text size from one knob. Density presets are the payoff of naming relationships: they retarget every gap without touching a call site. Pointer and Touch are not scaled copies, which is why a base scalar would not do. Touch separates what you tap and tightens what wraps it, so peer and section open while pane and page close, and bound holds still because it is the one relationship that means "one object". Not wired into any app yet.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 01:06 UTC
Signed with PGP, not checked
Commit: 8199cfe487b7eea2f81b9bb64db5e64de5a01830
8 files changed, +744 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +41
@@ -1,0 +1,41 @@
1 + [package]
2 + name = "makeover-geometry"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "The invariant half of the make-family design system: relational spacing, radius, border width and type scale. Geometry never varies by theme, which is why it does not live in makeover."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-geometry"
8 +
9 + [dependencies]
10 +
11 + [lints.rust]
12 + unused = "warn"
13 + unreachable_pub = "warn"
14 +
15 + [lints.clippy]
16 + pedantic = { level = "warn", priority = -1 }
17 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
18 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
19 + # else in `pedantic` stays a warning. Keep this block identical across repos.
20 + module_name_repetitions = "allow"
21 + # Doc lints. No docs-completeness push is underway.
22 + missing_errors_doc = "allow"
23 + missing_panics_doc = "allow"
24 + doc_markdown = "allow"
25 + # Numeric casts. Endemic and mostly intentional in size and byte math.
26 + cast_possible_truncation = "allow"
27 + cast_sign_loss = "allow"
28 + cast_precision_loss = "allow"
29 + cast_possible_wrap = "allow"
30 + cast_lossless = "allow"
31 + # Subjective structure and style nags. High churn, low signal.
32 + must_use_candidate = "allow"
33 + too_many_lines = "allow"
34 + struct_excessive_bools = "allow"
35 + similar_names = "allow"
36 + items_after_statements = "allow"
37 + single_match_else = "allow"
38 + # Frequent false-positives in TUI and router-heavy code.
39 + match_same_arms = "allow"
40 + unnecessary_wraps = "allow"
41 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +81
@@ -1,0 +1,81 @@
1 + # makeover-geometry
2 +
3 + The invariant half of the make-family design system. [`makeover`](https://makenot.work/git/max/makeover)
4 + resolves colour, which varies by theme; this crate carries everything that does
5 + not.
6 +
7 + ## Spacing is a relationship, not a size
8 +
9 + The Mac OS 8 Human Interface Guidelines specify white space by what two things
10 + are being separated, and define no grid unit. A control and its satellite
11 + pop-up sit 4 pixels apart; peers stacked in a list get 6; a group box's inner
12 + margin is 10; separated groups and rows of push buttons get 12.
13 +
14 + So the vocabulary here names the relationship and lets the size follow, the same
15 + way `surface-raised` names an intent and lets the hex follow:
16 +
17 + | Relationship | What it separates |
18 + |---|---|
19 + | `gap-bound` | a control and the thing it belongs to |
20 + | `gap-peer` | items of the same kind in a list |
21 + | `gap-group` | a container's inner margin, sibling groups |
22 + | `gap-section` | separated groups, rows of actions |
23 + | `gap-pane` | panel padding, content shells |
24 + | `gap-page` | the outermost shell margin |
25 +
26 + Whether a gap should be 6px or 8px is unanswerable on its own. Whether two
27 + things are peers is not. That is the whole argument for the layer.
28 +
29 + A raw `step-*` scale sits underneath for distances no relationship describes,
30 + such as an optical nudge inside a badge. Reaching for it is a smell worth a
31 + second look.
32 +
33 + ## Ratios, not pixel counts
34 +
35 + The deliberate departure from the HIG, which is written in hard device pixels
36 + because in 1997 there was one pixel density and one text size.
37 +
38 + Every step is a ratio of one base unit, `--geometry-base`, which defaults to
39 + `1rem`. At that base the ratios land exactly on the HIG's numbers, so nothing
40 + is lost in translation. What is gained: the layout tracks the user's text size
41 + instead of fighting it, an accessibility setting is one value rather than a
42 + sweep, and the scale means the same thing at any display density.
43 +
44 + ```css
45 + --step-snug: calc(var(--geometry-base) * 3 / 8); /* 6px at the default base */
46 + --gap-peer: var(--step-snug);
47 + ```
48 +
49 + ## Density presets
50 +
51 + Because no call site names a size, a preset can change what every relationship
52 + resolves to without touching one of them. Mobile and desktop builds should
53 + differ mostly by which preset they emit.
54 +
55 + The presets are not scaled copies of each other, which is also why a single
56 + base scalar could not express this. Touch wants **more** room between things
57 + you tap and **less** around the edges, since a fingertip is coarse and outer
58 + margin is screen you do not get to use. `gap-peer` and `gap-section` open up;
59 + `gap-pane` and `gap-page` tighten. `gap-bound` never moves — it is the one
60 + relationship that says "these are one object".
61 +
62 + ```rust
63 + use makeover_geometry::{Density, geometry_css_vars, gap_css_overrides};
64 +
65 + let mut css = geometry_css_vars(Density::Pointer);
66 + css.push_str(&gap_css_overrides(".ui-mode-mobile", Density::Touch));
67 + ```
68 +
69 + The override block emits only the relational layer. The base and the scale are
70 + declared once.
71 +
72 + ## Consumers
73 +
74 + Web surfaces bake the CSS in at build time; unlike colour, none of this changes
75 + at runtime, so there is nothing for JS to apply on load. egui surfaces read the
76 + same values through `Gap::px_at` and `Ratio::scale`, which is the reason this is
77 + a crate rather than a stylesheet.
78 +
79 + ## Licence
80 +
81 + MIT.
@@ -1,0 +1,9 @@
1 + //! Print the geometry layer as an app would bake it in at build time.
2 +
3 + use makeover_geometry::{Density, gap_css_overrides, geometry_css_vars};
4 +
5 + fn main() {
6 + print!("{}", geometry_css_vars(Density::Pointer));
7 + println!();
8 + print!("{}", gap_css_overrides(".ui-mode-mobile", Density::Touch));
9 + }
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
@@ -1,0 +1,83 @@
1 + #!/bin/bash
2 + # rustfmt gate: blocks a commit whose staged Rust files are not formatted.
3 + #
4 + # Activate in a fresh clone (one-time):
5 + # git config core.hooksPath scripts/githooks
6 + #
7 + # Bypass for a work-in-progress commit: git commit --no-verify
8 + #
9 + # Only crates with staged .rs changes are checked, so the hook stays fast on a
10 + # large repo. Each file maps to the nearest enclosing Cargo.toml, and the check
11 + # runs as `cargo fmt` there, which picks up that crate's edition and any
12 + # rustfmt.toml rather than guessing.
13 + set -euo pipefail
14 +
15 + ROOT="$(git rev-parse --show-toplevel)"
16 + cd "$ROOT"
17 +
18 + # --- secret scan (gitleaks) -------------------------------------------------
19 + # Independent guardrail: blocks a commit whose staged changes contain a secret,
20 + # regardless of whether a human judged the value "safe". Shared ruleset lives at
21 + # ~/Code/.gitleaks.toml. Degrades gracefully if gitleaks is not installed (the
22 + # astra pre-receive hook is the backstop that always runs). Task: infra 97ffeda0.
23 + if command -v gitleaks >/dev/null 2>&1; then
24 + GL_CFG=""
25 + if [ -f "$ROOT/.gitleaks.toml" ]; then
26 + GL_CFG="$ROOT/.gitleaks.toml"
27 + elif [ -f "$HOME/Code/.gitleaks.toml" ]; then
28 + GL_CFG="$HOME/Code/.gitleaks.toml"
29 + fi
30 + gl_args=(git --staged --no-banner --redact)
31 + [ -n "$GL_CFG" ] && gl_args+=(-c "$GL_CFG")
32 + if ! gitleaks "${gl_args[@]}"; then
33 + echo "pre-commit: gitleaks found a secret in the staged changes."
34 + echo " remove it (or allowlist a false positive), then restage."
35 + echo " bypass: git commit --no-verify."
36 + exit 1
37 + fi
38 + echo "pre-commit: gitleaks clean."
39 + else
40 + echo "pre-commit: gitleaks not installed; skipping secret scan (astra gates on push)."
41 + fi
42 +
43 + # Paths the gate ignores (extended regex, matched against repo-relative paths).
44 + # Empty means check everything.
45 + SKIP_PATHS="${SKIP_PATHS:-}"
46 +
47 + staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')"
48 + if [ -n "$SKIP_PATHS" ]; then
49 + staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)"
50 + fi
51 + [ -n "$staged" ] || exit 0
52 +
53 + # Map each staged file to the directory of its nearest Cargo.toml.
54 + crates=""
55 + while IFS= read -r f; do
56 + [ -n "$f" ] || continue
57 + d="$(dirname "$f")"
58 + while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do
59 + d="$(dirname "$d")"
60 + done
61 + [ -f "$d/Cargo.toml" ] || continue
62 + crates="$crates$d"$'\n'
63 + done <<< "$staged"
64 +
65 + crates="$(printf '%s' "$crates" | sort -u)"
66 + [ -n "$crates" ] || exit 0
67 +
68 + failed=0
69 + while IFS= read -r c; do
70 + [ -n "$c" ] || continue
71 + if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then
72 + echo "pre-commit: rustfmt gate failed in $c"
73 + failed=1
74 + fi
75 + done <<< "$crates"
76 +
77 + if [ "$failed" -ne 0 ]; then
78 + echo "pre-commit: run 'cargo fmt' in the crates above, then restage."
79 + echo "pre-commit: commit aborted (use --no-verify to bypass)."
80 + exit 1
81 + fi
82 +
83 + echo "pre-commit: rustfmt gate clean."
A src/lib.rs +500
@@ -1,0 +1,525 @@
1 + //! The invariant half of the make-family design system.
2 + //!
3 + //! <!-- wiki: makeover-geometry -->
4 + //!
5 + //! [`makeover`] resolves colour, which varies by theme. This crate carries
6 + //! everything that does not: spacing, radius, border width and the type scale.
7 + //! The split is the same one Balanced Breakfast's theme contract has always
8 + //! drawn — *a theme overrides colour tokens only* — moved out of two app
9 + //! stylesheets so the three consumers stop maintaining three copies of it.
10 + //!
11 + //! # Spacing is relational, not numeric
12 + //!
13 + //! The Mac OS 8 Human Interface Guidelines specify white space by *what two
14 + //! things are being separated*, never by a size name, and define no base grid
15 + //! unit. A control and its satellite pop-up are set 4 pixels apart; peers
16 + //! stacked in a list get 6; a group box's inner margin is 10; separated groups
17 + //! and rows of push buttons get 12.
18 + //!
19 + //! That vocabulary is the primary interface here. [`Gap`] names the
20 + //! relationship and the size follows from it, exactly as `surface-raised`
21 + //! names an intent and the hex follows from it. The raw [`Step`] scale exists
22 + //! underneath for distances a relationship does not describe, but reaching for
23 + //! it is a smell worth a second look.
24 + //!
25 + //! Naming the relationship is what makes the rule reviewable. Whether a gap
26 + //! should be 6px or 8px is unanswerable in isolation; whether two things are
27 + //! peers is not.
28 + //!
29 + //! # Ratios, not pixel counts
30 + //!
31 + //! This is the deliberate departure from the HIG, which is specified in hard
32 + //! device pixels because in 1997 there was one pixel density and one text
33 + //! size. Every [`Step`] here is a [`Ratio`] of a single base unit, so the
34 + //! whole system scales from one knob: `--geometry-base`, `1rem` by default.
35 + //!
36 + //! At the default base the ratios land exactly on the HIG's numbers — `Snug`
37 + //! is three eighths of 16px, which is 6px — so nothing is lost in the
38 + //! translation. What is gained is that the layout tracks the user's text size
39 + //! instead of fighting it, an accessibility setting becomes one value rather
40 + //! than a sweep, and the scale means the same thing at any display density.
41 + //!
42 + //! # Density presets
43 + //!
44 + //! Naming relationships instead of sizes is what makes a density preset
45 + //! possible at all. [`Density`] changes what each relationship resolves to
46 + //! without touching a single call site, because no call site names a size.
47 + //! The mobile and desktop builds of a Tauri app should differ mostly by which
48 + //! preset they emit, not by a parallel set of hand-written mode-scoped rules.
49 + //!
50 + //! The two presets are not one scaled copy of the other, and the asymmetry is
51 + //! the point — it is also why a base scalar alone cannot express this. Touch
52 + //! needs *more* room between things you tap, because a fingertip is coarser
53 + //! than a pointer, and *less* room around the edges, because the screen is
54 + //! small and outer margin is screen you do not get to use. So `Peer` and
55 + //! `Section` open up under [`Density::Touch`] while `Pane` and `Page` tighten.
56 + //! `Bound` never moves: it is the one relationship that says "these are one
57 + //! object", and separating them on touch would say the opposite.
58 +
59 + #![forbid(unsafe_code)]
60 +
61 + use std::fmt::Write as _;
62 +
63 + /// The default base unit in CSS pixels, at a 16px root font size.
64 + pub const DEFAULT_BASE_PX: u16 = 16;
65 +
66 + /// The CSS custom property every ratio scales from.
67 + pub const BASE_TOKEN: &str = "geometry-base";
68 +
69 + /// A fraction of the base unit.
70 + ///
71 + /// Rational rather than floating point so the scale is exact, comparable and
72 + /// usable in a `const`. At the default base every ratio below divides evenly.
73 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74 + pub struct Ratio {
75 + /// Top of the fraction.
76 + pub numerator: u16,
77 + /// Bottom of the fraction. Never zero for any ratio this crate defines.
78 + pub denominator: u16,
79 + }
80 +
81 + impl Ratio {
82 + /// Resolve against a base measured in pixels.
83 + ///
84 + /// Integer maths, so this is exact for every ratio in [`Step`] at the
85 + /// default base. A base that does not divide evenly truncates, which is
86 + /// the right failure: a fractional CSS pixel is a blurry edge.
87 + #[must_use]
88 + pub const fn px_at(self, base_px: u16) -> u16 {
89 + base_px * self.numerator / self.denominator
90 + }
91 +
92 + /// Resolve against an arbitrary base, keeping the fraction.
93 + ///
94 + /// For consumers that lay out in logical units rather than whole pixels.
95 + #[must_use]
96 + pub fn scale(self, base: f32) -> f32 {
97 + base * f32::from(self.numerator) / f32::from(self.denominator)
98 + }
99 +
100 + /// The CSS value, as an expression over [`BASE_TOKEN`].
101 + ///
102 + /// A whole multiple of the base emits without a division, and 1:1 emits
103 + /// the bare `var()`, because `calc(var(--geometry-base) * 1 / 1)` is noise.
104 + #[must_use]
105 + pub fn css(self) -> String {
106 + match (self.numerator, self.denominator) {
107 + (n, d) if n == d => format!("var(--{BASE_TOKEN})"),
108 + (n, 1) => format!("calc(var(--{BASE_TOKEN}) * {n})"),
109 + (n, d) => format!("calc(var(--{BASE_TOKEN}) * {n} / {d})"),
110 + }
111 + }
112 + }
113 +
114 + /// Which input the layout is being sized for.
115 + ///
116 + /// A preset, not a breakpoint. Which one applies is the app's call — GoingsOn
117 + /// and Balanced Breakfast already decide it once and hang a `ui-mode-*` class
118 + /// off the result.
119 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
120 + pub enum Density {
121 + /// Mouse or trackpad. Resolves to the Mac OS 8 HIG's own proportions.
122 + #[default]
123 + Pointer,
124 + /// Finger. Targets separate, shells tighten.
125 + Touch,
126 + }
127 +
128 + /// A named separation between two things.
129 + ///
130 + /// Pick by relationship. The size is a consequence of the name, not the other
131 + /// way round, and callers should never care what it is.
132 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
133 + pub enum Gap {
134 + /// A control and the thing it belongs to: an edit field and its pop-up, a
135 + /// checkbox and its label, an icon and the text it labels. Reads as one
136 + /// object.
137 + Bound,
138 + /// Items of the same kind in a list: stacked checkboxes, radio buttons,
139 + /// rows, chips in a row. Reads as a set.
140 + Peer,
141 + /// A container's inner margin, and the distance between sibling groups
142 + /// side by side. Reads as "inside this box".
143 + Group,
144 + /// Separated groups, and rows of actions. The first gap that reads as a
145 + /// deliberate break rather than as breathing room.
146 + Section,
147 + /// Panel padding and content shells. Layout, not controls.
148 + Pane,
149 + /// The outermost shell margin. One per screen, usually.
150 + Page,
151 + }
152 +
153 + /// A raw step on the underlying scale.
154 + ///
155 + /// Present because not every distance is a relationship between two controls —
156 + /// an optical nudge inside a badge is not a `Gap`. Prefer [`Gap`] wherever one
157 + /// fits: a step name says how big, a gap name says why.
158 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
159 + pub enum Step {
160 + /// An eighth of the base. Optical nudges inside small inline elements.
161 + Hair,
162 + /// A quarter of the base.
163 + Tight,
164 + /// Three eighths of the base.
165 + Snug,
166 + /// Half the base.
167 + Base,
168 + /// Five eighths of the base.
169 + Roomy,
170 + /// Three quarters of the base.
171 + Wide,
172 + /// The base itself.
173 + Loose,
174 + /// One and a half times the base.
175 + Broad,
176 + /// Twice the base.
177 + Vast,
178 + /// Three times the base.
179 + Colossal,
180 + }
181 +
182 + impl Step {
183 + /// This step as a fraction of the base unit.
184 + #[must_use]
185 + pub const fn ratio(self) -> Ratio {
186 + let (numerator, denominator) = match self {
187 + Self::Hair => (1, 8),
188 + Self::Tight => (1, 4),
189 + Self::Snug => (3, 8),
190 + Self::Base => (1, 2),
191 + Self::Roomy => (5, 8),
192 + Self::Wide => (3, 4),
193 + Self::Loose => (1, 1),
194 + Self::Broad => (3, 2),
195 + Self::Vast => (2, 1),
196 + Self::Colossal => (3, 1),
197 + };
198 + Ratio {
199 + numerator,
200 + denominator,
201 + }
202 + }
203 +
204 + /// Size in CSS pixels at the default base.
205 + #[must_use]
206 + pub const fn px(self) -> u16 {
207 + self.ratio().px_at(DEFAULT_BASE_PX)
208 + }
209 +
210 + /// The CSS custom-property name, without the leading `--`.
211 + #[must_use]
212 + pub const fn token(self) -> &'static str {
213 + match self {
214 + Self::Hair => "step-hair",
215 + Self::Tight => "step-tight",
216 + Self::Snug => "step-snug",
217 + Self::Base => "step-base",
218 + Self::Roomy => "step-roomy",
219 + Self::Wide => "step-wide",
220 + Self::Loose => "step-loose",
221 + Self::Broad => "step-broad",
222 + Self::Vast => "step-vast",
223 + Self::Colossal => "step-colossal",
224 + }
225 + }
226 +
227 + /// Every step, smallest first.
228 + #[must_use]
229 + pub const fn all() -> [Self; 10] {
230 + [
231 + Self::Hair,
232 + Self::Tight,
233 + Self::Snug,
234 + Self::Base,
235 + Self::Roomy,
236 + Self::Wide,
237 + Self::Loose,
238 + Self::Broad,
239 + Self::Vast,
240 + Self::Colossal,
241 + ]
242 + }
243 + }
244 +
245 + impl Gap {
246 + /// The step this relationship resolves to at a given density.
247 + #[must_use]
248 + pub const fn step_at(self, density: Density) -> Step {
249 + match (self, density) {
250 + // Binding is not a separation, so it does not open up on touch.
251 + (Self::Bound, _) => Step::Tight,
252 +
253 + (Self::Peer, Density::Pointer) => Step::Snug,
254 + (Self::Peer, Density::Touch) => Step::Roomy,
255 +
256 + (Self::Group, Density::Pointer) => Step::Roomy,
257 + (Self::Group, Density::Touch) => Step::Wide,
258 +
259 + (Self::Section, Density::Pointer) => Step::Wide,
260 + (Self::Section, Density::Touch) => Step::Loose,
261 +
262 + // Shells tighten on touch: outer margin is screen you don't get.
263 + (Self::Pane, Density::Pointer) => Step::Broad,
264 + (Self::Pane, Density::Touch) => Step::Loose,
265 +
266 + (Self::Page, Density::Pointer) => Step::Vast,
267 + (Self::Page, Density::Touch) => Step::Broad,
268 + }
269 + }
270 +
271 + /// The step this relationship resolves to at the default density.
272 + #[must_use]
273 + pub const fn step(self) -> Step {
274 + self.step_at(Density::Pointer)
275 + }
276 +
277 + /// Size in CSS pixels at the default base, at a given density.
278 + #[must_use]
279 + pub const fn px_at(self, density: Density) -> u16 {
280 + self.step_at(density).px()
281 + }
282 +
283 + /// Size in CSS pixels at the default base and density.
284 + #[must_use]
285 + pub const fn px(self) -> u16 {
286 + self.step().px()
287 + }
288 +
289 + /// The CSS custom-property name, without the leading `--`.
290 + #[must_use]
291 + pub const fn token(self) -> &'static str {
292 + match self {
293 + Self::Bound => "gap-bound",
294 + Self::Peer => "gap-peer",
295 + Self::Group => "gap-group",
296 + Self::Section => "gap-section",
297 + Self::Pane => "gap-pane",
298 + Self::Page => "gap-page",
299 + }
300 + }
301 +
302 + /// Every relationship, tightest first.
303 + #[must_use]
304 + pub const fn all() -> [Self; 6] {
305 + [
306 + Self::Bound,
307 + Self::Peer,
308 + Self::Group,
309 + Self::Section,
310 + Self::Pane,
311 + Self::Page,
312 + ]
313 + }
314 + }
315 +
316 + /// Emit the base unit and the raw scale as CSS declarations, no selector.
317 + ///
318 + /// Density-invariant: the steps are the vocabulary, and only which step a
319 + /// relationship picks changes between presets.
320 + #[must_use]
321 + pub fn scale_css_declarations() -> String {
322 + let mut out = String::new();
323 + let _ = writeln!(
324 + out,
325 + " /* Every size below is a ratio of this. Scale it and the whole\n \
326 + layout scales with it, including for a user who has asked for\n \
327 + larger text. */\n --{BASE_TOKEN}: 1rem;\n"
328 + );
329 + out.push_str(" /* Raw scale. Prefer a --gap-* below; reach here only when\n");
330 + out.push_str(" no relationship describes the distance. */\n");
331 + for step in Step::all() {
332 + let _ = writeln!(out, " --{}: {};", step.token(), step.ratio().css());
333 + }
334 + out
335 + }
336 +
337 + /// Emit the relational layer for one density as CSS declarations, no selector.
338 + ///
339 + /// Gaps reference their step rather than repeating a value, so the scale has
340 + /// exactly one definition and a reader can see which relationship maps where.
341 + #[must_use]
342 + pub fn gap_css_declarations(density: Density) -> String {
343 + let mut out = String::new();
344 + for gap in Gap::all() {
345 + let _ = writeln!(
346 + out,
347 + " --{}: var(--{});",
348 + gap.token(),
349 + gap.step_at(density).token()
350 + );
351 + }
352 + out
353 + }
354 +
355 + /// Emit the whole geometry layer as a `:root { … }` block at one density.
356 + ///
357 + /// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
358 + /// constant, so a web consumer should bake it in at build time rather than
359 + /// apply it from JS on every load.
360 + #[must_use]
361 + pub fn geometry_css_vars(density: Density) -> String {
362 + format!(
363 + ":root {{\n{}\n{}}}\n",
364 + scale_css_declarations(),
365 + gap_css_declarations(density)
366 + )
367 + }
368 +
369 + /// Emit a density preset as a scoped override block.
370 + ///
371 + /// Only the relational layer is emitted: the scale and the base do not change
372 + /// between presets, so an app ships [`geometry_css_vars`] at its default
373 + /// density and one of these per mode class it supports.
374 + ///
375 + /// ```
376 + /// # use makeover_geometry::{Density, gap_css_overrides};
377 + /// let css = gap_css_overrides(".ui-mode-mobile", Density::Touch);
378 + /// assert!(css.starts_with(".ui-mode-mobile {\n"));
379 + /// ```
380 + #[must_use]
381 + pub fn gap_css_overrides(selector: &str, density: Density) -> String {
382 + format!("{selector} {{\n{}}}\n", gap_css_declarations(density))
383 + }
384 +
385 + #[cfg(test)]
386 + mod tests {
387 + use super::*;
388 +
389 + #[test]
390 + fn the_hig_relationships_land_on_the_hig_values() {
391 + // Mac OS 8 HIG, Control Layout Guidelines. The ratios are ours, but at
392 + // the default base they must resolve to the numbers the HIG specifies,
393 + // or the departure has cost us the thing it was translating.
394 + assert_eq!(Gap::Bound.px(), 4);
395 + assert_eq!(Gap::Peer.px(), 6);
396 + assert_eq!(Gap::Group.px(), 10);
397 + assert_eq!(Gap::Section.px(), 12);
398 + }
399 +
400 + #[test]
401 + fn every_ratio_divides_the_default_base_exactly() {
402 + for step in Step::all() {
403 + let r = step.ratio();
404 + assert_eq!(
405 + u32::from(DEFAULT_BASE_PX) * u32::from(r.numerator) % u32::from(r.denominator),
406 + 0,
407 + "{step:?} is fractional at the default base"
408 + );
409 + }
410 + }
411 +
412 + #[test]
413 + fn ratios_scale_linearly() {
414 + for step in Step::all() {
415 + assert_eq!(
416 + step.ratio().px_at(DEFAULT_BASE_PX * 2),
417 + step.px() * 2,
418 + "{step:?} does not double with the base"
419 + );
420 + }
421 + }
422 +
423 + #[test]
424 + fn steps_ascend_and_never_repeat() {
425 + let px: Vec<u16> = Step::all().iter().map(|s| s.px()).collect();
426 + let mut sorted = px.clone();
427 + sorted.sort_unstable();
428 + sorted.dedup();
429 + assert_eq!(px, sorted, "steps must be strictly ascending");
430 + }
431 +
432 + #[test]
433 + fn gaps_ascend_with_their_relationships_at_every_density() {
434 + for density in [Density::Pointer, Density::Touch] {
435 + let px: Vec<u16> = Gap::all().iter().map(|g| g.px_at(density)).collect();
436 + let mut sorted = px.clone();
437 + sorted.sort_unstable();
438 + assert_eq!(px, sorted, "{density:?}: a looser relationship is tighter");
439 + }
440 + }
441 +
442 + #[test]
443 + fn touch_separates_targets_and_tightens_shells() {
444 + // The asymmetry is the whole reason a base scalar would not do.
445 + for gap in [Gap::Peer, Gap::Section] {
446 + assert!(
447 + gap.px_at(Density::Touch) > gap.px_at(Density::Pointer),
448 + "{gap:?} must open up for a fingertip"
449 + );
450 + }
451 + for gap in [Gap::Pane, Gap::Page] {
452 + assert!(
453 + gap.px_at(Density::Touch) < gap.px_at(Density::Pointer),
454 + "{gap:?} must tighten on a small screen"
455 + );
456 + }
457 + assert_eq!(
458 + Gap::Bound.px_at(Density::Touch),
459 + Gap::Bound.px_at(Density::Pointer),
460 + "bound things stay bound"
461 + );
462 + }
463 +
464 + #[test]
465 + fn tokens_are_unique() {
466 + let mut names: Vec<&str> = Step::all().iter().map(|s| s.token()).collect();
467 + names.extend(Gap::all().iter().map(|g| g.token()));
468 + let count = names.len();
469 + names.sort_unstable();
470 + names.dedup();
471 + assert_eq!(names.len(), count, "token names collide");
472 + }
473 +
474 + #[test]
475 + fn css_is_expressed_over_the_base_never_in_pixels() {
476 + let css = geometry_css_vars(Density::Pointer);
477 + assert!(css.starts_with(":root {\n"));
478 + assert!(css.trim_end().ends_with('}'));
479 + assert!(css.contains("--geometry-base: 1rem;"));
480 + for step in Step::all() {
481 + let line = format!("--{}: {}", step.token(), step.ratio().css());
482 + assert!(css.contains(&line), "missing or wrong: {line}");
483 + }
484 + // A hard pixel count anywhere in the scale defeats the point.
485 + let scale = scale_css_declarations();
486 + assert!(
487 + !scale.contains("px;"),
488 + "the scale must not emit pixel literals:\n{scale}"
489 + );
490 + }
491 +
492 + #[test]
493 + fn ratio_css_drops_redundant_arithmetic() {
494 + assert_eq!(Step::Loose.ratio().css(), "var(--geometry-base)");
495 + assert_eq!(Step::Vast.ratio().css(), "calc(var(--geometry-base) * 2)");
496 + assert_eq!(
497 + Step::Snug.ratio().css(),
498 + "calc(var(--geometry-base) * 3 / 8)"
499 + );
500 + }
Lines truncated