Skip to main content

max / makeover-geometry

git clone https://makenot.work/git/max/makeover-geometry.git git clone git@ssh.makenot.work:max/makeover-geometry.git
Name Size
/ dist/
/ examples/
/ scripts/
/ src/
· .gitignore 99 B
· bento.toml 335 B
· Cargo.toml 1.4 KB
· LICENSE 1.0 KB
· README.md 7.2 KB
· rust-toolchain.toml 86 B

README

makeover-geometry

The invariant half of the make-family design system. makeover resolves colour, which varies by theme; this crate carries everything that does not.

Spacing is a relationship, not a size

The Mac OS 8 Human Interface Guidelines specify white space by what two things are being separated, and define no grid unit. A control and its satellite pop-up sit 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.

So the vocabulary here names the relationship and lets the size follow, the same way surface-raised names an intent and lets the hex follow:

RelationshipWhat it separates
gap-bounda control and the thing it belongs to
gap-peeritems of the same kind in a list
gap-groupa container’s inner margin, sibling groups
gap-sectionseparated groups, rows of actions
gap-panepanel padding, content shells
gap-pagethe outermost shell margin

Whether a gap should be 6px or 8px is unanswerable on its own. Whether two things are peers is not. That is the whole argument for the layer.

A raw step-* scale sits underneath for distances no relationship describes, such as an optical nudge inside a badge. Reaching for it is a smell worth a second look.

Type, named the same way

The type axis makes the same move. Text names what a piece of text is and the size follows:

RoleWhat it isAt the default base
text-finetimestamps, badges, legal lines12px
text-notemetadata, table cells, captions, form help14px
text-bodyrunning copy16px
text-leademphasised copy, card titles18px
text-subheadthe third heading level20px
text-headsection headings, the second level24px
text-titlethe page’s own title32px
text-displaydisplay copy, above the hierarchy40px
text-heroa landing hero, at most one per page48px

Whether a caption should be 13px or 14px cannot be reviewed. Whether a piece of text is a caption can.

Nothing below 12px. A size under that is a legibility problem rather than a tier, and text that should recede can recede by colour or weight instead.

Type does not shift on touch. Density is a claim about the contact patch, and text is not a tap target; the reader’s own root font size is already the knob, and it moves this whole ramp.

Corners

Radius names what the corner belongs to. Four rungs and a circle, which is the whole scale on purpose:

RungWhat it isAt the default base
radius-squarecontainers: cards, panels, dropdowns, page shells0
radius-fineinline code, small badges, status chips2px
radius-controlbuttons, inputs, selects4px
radius-panelmedia covers, callout boxes, surfaces that round8px
radius-rounda circle, whatever the element’s size50%

Rounding says a thing is meant to be pressed, so it carries one bit of meaning and a long scale is one nobody can choose from. square is a rung rather than the absence of one: a container states that it is square, and a reader can tell that apart from a rule nobody wrote.

round is the one value that is not a ratio of the base. 50% is a proportion of the element’s own box, so it does not scale with --geometry-base, and Radius::ratio returns None for it rather than pretending otherwise.

Ratios, not pixel counts

The deliberate departure from the HIG, which is written in hard device pixels because in 1997 there was one pixel density and one text size.

Every step is a ratio of one base unit, --geometry-base, which defaults to 1rem. At that base the ratios land exactly on the HIG’s numbers, so nothing is lost in translation. What is gained: the layout tracks the user’s text size instead of fighting it, an accessibility setting is one value rather than a sweep, and the scale means the same thing at any display density.

--step-snug: calc(var(--geometry-base) * 3 / 8);   /* 6px at the default base */
--gap-peer:  var(--step-snug);

Density presets

Because no call site names a size, a preset can change what every relationship resolves to without touching one of them. Mobile and desktop builds should differ mostly by which preset they emit.

The presets are not scaled copies of each other, which is also why a single base scalar could not express this. Touch is a claim about the contact patch and nothing else: a fingertip is coarse, so adjacent things that do different things need more room between them to be hit reliably.

GapPointerTouch
bound44
peer610
group1012
section1216
pane2424
page3232

gap-peer, gap-group and gap-section separate distinct tap targets, so they open. gap-pane and gap-page are shells rather than targets, so the input device has no opinion about them and they hold. How much screen you have is a separate question from what you are pointing with, and it does not belong on this axis. gap-bound never moves: it is the one relationship that says “these are one object”, and separating it would say the opposite.

The one rule across presets is that Touch never resolves tighter than Pointer.

use makeover_geometry::{Density, geometry_css_vars, gap_css_overrides};

let mut css = geometry_css_vars(Density::Pointer);
css.push_str(&gap_css_overrides(".ui-mode-mobile", Density::Touch));

The override block emits only the relational layer. The base and the scale are declared once.

Surfaces, and why a terminal is not a third preset

A terminal is not a density. It is a surface whose smallest representable step is one cell rather than one pixel, and that single difference is the whole of it. Ratio::quanta takes a quantum and answers in whole units of it, so the relational vocabulary reaches 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.

use makeover_geometry::{Density, Gap, Surface};

let t = Surface::terminal();
assert_eq!(t.gap(Gap::Peer, Density::Pointer), 0);     // cells
assert_eq!(t.gap(Gap::Section, Density::Pointer), 1);
assert_eq!(t.gap(Gap::Page, Density::Pointer), 2);

bound and peer collapsing to zero cells 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 apart. What it must never do is invert two relationships, and there is a test across several quanta pinning that.

So three orthogonal axes: Gap is what is being separated, Density is who is operating it, Surface is what it is drawn on.

Consumers

Web surfaces bake the CSS in at build time; unlike colour, none of this changes at runtime, so there is nothing for JS to apply on load. egui and ratatui surfaces resolve through Surface instead, which is the reason this is a crate rather than a stylesheet.

Licence

MIT.