Skip to main content

max / makeover-geometry

7.2 KB · 176 lines History Blame Raw
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 ## Type, named the same way
34
35 The type axis makes the same move. `Text` names what a piece of text is and the
36 size follows:
37
38 | Role | What it is | At the default base |
39 |---|---|---|
40 | `text-fine` | timestamps, badges, legal lines | 12px |
41 | `text-note` | metadata, table cells, captions, form help | 14px |
42 | `text-body` | running copy | 16px |
43 | `text-lead` | emphasised copy, card titles | 18px |
44 | `text-subhead` | the third heading level | 20px |
45 | `text-head` | section headings, the second level | 24px |
46 | `text-title` | the page's own title | 32px |
47 | `text-display` | display copy, above the hierarchy | 40px |
48 | `text-hero` | a landing hero, at most one per page | 48px |
49
50 Whether a caption should be 13px or 14px cannot be reviewed. Whether a piece of
51 text is a caption can.
52
53 Nothing below 12px. A size under that is a legibility problem rather than a
54 tier, and text that should recede can recede by colour or weight instead.
55
56 Type does not shift on touch. Density is a claim about the contact patch, and
57 text is not a tap target; the reader's own root font size is already the knob,
58 and it moves this whole ramp.
59
60 ## Corners
61
62 `Radius` names what the corner belongs to. Four rungs and a circle, which is
63 the whole scale on purpose:
64
65 | Rung | What it is | At the default base |
66 |---|---|---|
67 | `radius-square` | containers: cards, panels, dropdowns, page shells | 0 |
68 | `radius-fine` | inline code, small badges, status chips | 2px |
69 | `radius-control` | buttons, inputs, selects | 4px |
70 | `radius-panel` | media covers, callout boxes, surfaces that round | 8px |
71 | `radius-round` | a circle, whatever the element's size | 50% |
72
73 Rounding says a thing is meant to be pressed, so it carries one bit of meaning
74 and a long scale is one nobody can choose from. `square` is a rung rather than
75 the absence of one: a container states that it is square, and a reader can tell
76 that apart from a rule nobody wrote.
77
78 `round` is the one value that is not a ratio of the base. 50% is a proportion of
79 the element's own box, so it does not scale with `--geometry-base`, and
80 `Radius::ratio` returns `None` for it rather than pretending otherwise.
81
82 ## Ratios, not pixel counts
83
84 The deliberate departure from the HIG, which is written in hard device pixels
85 because in 1997 there was one pixel density and one text size.
86
87 Every step is a ratio of one base unit, `--geometry-base`, which defaults to
88 `1rem`. At that base the ratios land exactly on the HIG's numbers, so nothing
89 is lost in translation. What is gained: the layout tracks the user's text size
90 instead of fighting it, an accessibility setting is one value rather than a
91 sweep, and the scale means the same thing at any display density.
92
93 ```css
94 --step-snug: calc(var(--geometry-base) * 3 / 8); /* 6px at the default base */
95 --gap-peer: var(--step-snug);
96 ```
97
98 ## Density presets
99
100 Because no call site names a size, a preset can change what every relationship
101 resolves to without touching one of them. Mobile and desktop builds should
102 differ mostly by which preset they emit.
103
104 The presets are not scaled copies of each other, which is also why a single
105 base scalar could not express this. Touch is a claim about the contact patch
106 and nothing else: a fingertip is coarse, so adjacent things that do different
107 things need more room between them to be hit reliably.
108
109 | Gap | Pointer | Touch |
110 |---|---|---|
111 | bound | 4 | 4 |
112 | peer | 6 | 10 |
113 | group | 10 | 12 |
114 | section | 12 | 16 |
115 | pane | 24 | 24 |
116 | page | 32 | 32 |
117
118 `gap-peer`, `gap-group` and `gap-section` separate distinct tap targets, so
119 they open. `gap-pane` and `gap-page` are shells rather than targets, so the
120 input device has no opinion about them and they hold. How much screen you have
121 is a separate question from what you are pointing with, and it does not belong
122 on this axis. `gap-bound` never moves: it is the one relationship that says
123 "these are one object", and separating it would say the opposite.
124
125 The one rule across presets is that Touch never resolves tighter than Pointer.
126
127 ```rust
128 use makeover_geometry::{Density, geometry_css_vars, gap_css_overrides};
129
130 let mut css = geometry_css_vars(Density::Pointer);
131 css.push_str(&gap_css_overrides(".ui-mode-mobile", Density::Touch));
132 ```
133
134 The override block emits only the relational layer. The base and the scale are
135 declared once.
136
137 ## Surfaces, and why a terminal is not a third preset
138
139 A terminal is not a density. It is a surface whose smallest representable step
140 is one cell rather than one pixel, and that single difference is the whole of
141 it. `Ratio::quanta` takes a quantum and answers in whole units of it, so the
142 relational vocabulary reaches a character grid with nothing added.
143
144 Quantising is not a terminal special case either. A display quantises to the
145 pixel; it is only that rounding 6.0 to the nearest pixel is uninteresting, while
146 rounding three eighths of a cell to the nearest cell decides the layout.
147
148 ```rust
149 use makeover_geometry::{Density, Gap, Surface};
150
151 let t = Surface::terminal();
152 assert_eq!(t.gap(Gap::Peer, Density::Pointer), 0); // cells
153 assert_eq!(t.gap(Gap::Section, Density::Pointer), 1);
154 assert_eq!(t.gap(Gap::Page, Density::Pointer), 2);
155 ```
156
157 `bound` and `peer` collapsing to zero cells is correct rather than lossy: in a
158 grid that dense, both relationships are expressed by adjacency. A coarse surface
159 genuinely has fewer distinctions available, and the model should say so instead
160 of inventing a gap to keep six names apart. What it must never do is *invert*
161 two relationships, and there is a test across several quanta pinning that.
162
163 So three orthogonal axes: `Gap` is what is being separated, `Density` is who is
164 operating it, `Surface` is what it is drawn on.
165
166 ## Consumers
167
168 Web surfaces bake the CSS in at build time; unlike colour, none of this changes
169 at runtime, so there is nothing for JS to apply on load. egui and ratatui
170 surfaces resolve through `Surface` instead, which is the reason this is a crate
171 rather than a stylesheet.
172
173 ## Licence
174
175 MIT.
176