Skip to main content

max / makeover-layout

Seed makeover-layout with the depth vocabulary First cut of the renderer-agnostic description: Bevel, Edge, Fill, Depth. Read off audiofiles' ui::theme and ui::widgets, the only implementation written by a consumer with no CSS, then checked against both webview apps. Fill and bevel are named together because naming them apart is what let them disagree. Depth::Raised is surface-raised plus the raised bevel and cannot be anything else; a raised bevel over a recessed fill is now unrepresentable rather than a bug each app finds separately. Depth only. Badge versus chip, toast versus banner, the list row's parts, heading levels, segmented controls and loading state are naming decisions rather than transcriptions, and are filed as subtasks instead of guessed at. 7 tests, clippy clean at -D warnings. Unpublished: 0.1.0 waits on a second renderer exercising the vocabulary.
Author: Max Johnson <me@maxj.phd> · 2026-07-28 21:09 UTC
Signed with PGP, not checked
Commit: 5bedb80601d2c5402820a4527fc3f7c288d4093f
6 files changed, +394 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-layout"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-layout"
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 +51
@@ -1,0 +1,51 @@
1 + # makeover-layout
2 +
3 + The renderer-agnostic half of the make-family design system.
4 +
5 + `makeover` answers *what colour*, and varies by theme. `makeover-geometry`
6 + answers *how much space*, and varies by density and surface. This crate answers
7 + *what the thing is*, and varies by nothing.
8 +
9 + ## The deferral rule
10 +
11 + A description names intents and relationships, never values. Say `Fill::Raised`,
12 + never `#D9DDF4`. Say `Gap::Peer`, never `6px`. What is left once colour and
13 + spacing are deferred is composition: which edges are lit, what inverts on press,
14 + what nests in what.
15 +
16 + The constraint that shapes it: a renderer that can only paint rectangles has to
17 + be able to express the result. egui has no `box-shadow: inset` and one stroke per
18 + widget with no per-side control. A terminal has box-drawing characters and one
19 + cell of resolution, and cannot draw a two-tone lit edge at all. So this crate
20 + names the intent, and each renderer picks an expression it can produce, including
21 + dropping half of one.
22 +
23 + ## What is here
24 +
25 + Depth, and only depth: `Bevel`, `Edge`, `Fill`, `Depth`. Fill and bevel are named
26 + together, because naming them apart is what let them disagree. Every consumer had
27 + at least one region carrying a raised bevel over a recessed fill; audiofiles
28 + fixed it and wrote the bug down, Balanced Breakfast still had twelve of them a
29 + year later. `Depth` makes that unrepresentable.
30 +
31 + The vocabulary was read off audiofiles, the only implementation written by a
32 + consumer with no CSS, then checked against both webview apps.
33 +
34 + ## What is not here yet
35 +
36 + Badge versus chip, toast versus banner, the list row's parts, heading levels,
37 + segmented controls, and whether a description names loading state at all. Each is
38 + a naming decision rather than a transcription, and each is tracked as a subtask
39 + of the extraction task. Guessing at them now is how a description becomes a
40 + framework.
41 +
42 + ## Status
43 +
44 + Not published. Per the sequencing decision, 0.1.0 does not go to crates.io until
45 + a second renderer has exercised the vocabulary; a description with one renderer
46 + is an API with extra steps. Design and open questions live in the wiki note
47 + `makeover-layout`; the backlog is in GoingsOn under the project of the same name.
48 +
49 + ## Licence
50 +
51 + MIT.
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
A src/lib.rs +272
@@ -1,0 +1,272 @@
1 + //! The renderer-agnostic half of the make-family design system.
2 + //!
3 + //! <!-- wiki: makeover-layout -->
4 + //!
5 + //! `makeover` answers *what colour*, and varies by theme. `makeover-geometry`
6 + //! answers *how much space*, and varies by density and surface. This crate
7 + //! answers *what the thing is*, and varies by nothing.
8 + //!
9 + //! # The deferral rule
10 + //!
11 + //! A description names intents and relationships, never values. Say
12 + //! [`Fill::Raised`], never `#D9DDF4`. Say `Gap::Peer`, never `6px`. What is
13 + //! left once colour and spacing are deferred is **composition**: which edges
14 + //! are lit, what inverts on press, what nests in what.
15 + //!
16 + //! The constraint that shapes all of it: a renderer that can only paint
17 + //! rectangles has to be able to express the result. egui has no
18 + //! `box-shadow: inset` and one stroke per widget with no per-side control; a
19 + //! terminal has box-drawing characters and one cell of resolution, and cannot
20 + //! draw a two-tone lit edge at all. A description that assumes per-side edges
21 + //! is a CSS description wearing a neutral name. So this crate names the
22 + //! *intent* — this region is a well — and each renderer chooses an expression
23 + //! it can actually produce, including dropping half of one.
24 + //!
25 + //! # Scope of this first cut
26 + //!
27 + //! Depth only: the bevel and the surfaces it shapes. That much is settled,
28 + //! and settled the hard way — the vocabulary here was read off audiofiles'
29 + //! `ui::theme` and `ui::widgets`, which are the only implementation written
30 + //! by a consumer with no CSS, then checked against both webview apps. All
31 + //! three agreed once Balanced Breakfast's fills were corrected.
32 + //!
33 + //! Deliberately absent, because each is a naming decision rather than a
34 + //! transcription: badge versus chip, toast versus banner, the list row's
35 + //! parts, heading levels, segmented controls, and whether a description names
36 + //! loading state at all. Those are tracked as subtasks of the extraction task
37 + //! and land as they are settled. Guessing at them now is how a description
38 + //! becomes a framework.
39 +
40 + #![forbid(unsafe_code)]
41 +
42 + /// A colour intent this crate refers to but never resolves.
43 + ///
44 + /// The string is the token name `makeover` publishes, so a renderer can look
45 + /// it up without this crate knowing what colour came back.
46 + pub trait Intent {
47 + /// The `makeover` intent token this resolves against.
48 + fn token(self) -> &'static str;
49 + }
50 +
51 + /// Which way the light falls across a two-tone edge.
52 + ///
53 + /// The whole content of a bevel, once colour and thickness are deferred. The
54 + /// light is always assumed to come from the top left: every consumer measured
55 + /// agreed on that and none of them ever varied it, so it is an invariant here
56 + /// rather than a parameter.
57 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58 + pub enum Bevel {
59 + /// Lit from the top left: light on top and left, dark on bottom and right.
60 + Raised,
61 + /// The same edge inverted, which is also the pressed state of anything
62 + /// that draws itself [`Bevel::Raised`].
63 + Inset,
64 + }
65 +
66 + impl Bevel {
67 + /// The edge intents, as `(top_left, bottom_right)`.
68 + ///
69 + /// Split out from any painting because the inversion *is* the idea, and
70 + /// it is the one part every renderer implements identically.
71 + #[must_use]
72 + pub const fn edges(self) -> (Edge, Edge) {
73 + match self {
74 + Self::Raised => (Edge::Light, Edge::Dark),
75 + Self::Inset => (Edge::Dark, Edge::Light),
76 + }
77 + }
78 +
79 + /// Pressing inverts. A raised control reads as inset while held.
80 + ///
81 + /// Stated here rather than left to each consumer because a cascade can
82 + /// carry a pressed state and an immediate-mode renderer cannot: audiofiles
83 + /// resolves this per call site, eighteen times.
84 + #[must_use]
85 + pub const fn pressed(self) -> Self {
86 + match self {
87 + Self::Raised => Self::Inset,
88 + Self::Inset => Self::Raised,
89 + }
90 + }
91 + }
92 +
93 + /// One side of a bevel, named by the intent it takes.
94 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95 + pub enum Edge {
96 + /// The lit side.
97 + Light,
98 + /// The shadowed side.
99 + Dark,
100 + }
101 +
102 + impl Intent for Edge {
103 + fn token(self) -> &'static str {
104 + match self {
105 + Self::Light => "bevel-light",
106 + Self::Dark => "bevel-dark",
107 + }
108 + }
109 + }
110 +
111 + /// A surface intent a region is filled with.
112 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113 + pub enum Fill {
114 + /// The page behind everything.
115 + Page,
116 + /// A surface lifted off the page: cards, controls, menus, toasts.
117 + Raised,
118 + /// A surface floating above the page rather than resting on it.
119 + Overlay,
120 + /// The inside of a well.
121 + Well,
122 + }
123 +
124 + impl Intent for Fill {
125 + fn token(self) -> &'static str {
126 + match self {
127 + Self::Page => "surface-page",
128 + Self::Raised => "surface-raised",
129 + Self::Overlay => "surface-overlay",
130 + Self::Well => "surface-well",
131 + }
132 + }
133 + }
134 +
135 + impl Fill {
136 + /// What to use where [`Fill::token`] is not available yet.
137 + ///
138 + /// `surface-well` is derived by makeover 2.3.0, which is bumped in-tree
139 + /// and not published, so consumers on 2.2.0 have no such token. A well
140 + /// filled `surface-page` is what audiofiles ships today and is close
141 + /// enough to read as recessed; it is wrong only in that it does not track
142 + /// the raised surface it was cut from.
143 + ///
144 + /// Delete this once 2.3.0 is published and the three consumers adopt it.
145 + #[must_use]
146 + pub const fn fallback(self) -> Option<Self> {
147 + match self {
148 + Self::Well => Some(Self::Page),
149 + _ => None,
150 + }
151 + }
152 + }
153 +
154 + /// How a region sits relative to the surface behind it.
155 + ///
156 + /// Fill and bevel are named together because naming them apart is what let
157 + /// them disagree. Every consumer measured had at least one region carrying a
158 + /// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame`
159 + /// and recorded the bug in its doc comment, and Balanced Breakfast still had
160 + /// twelve of them a year later. A single name for the pair makes that
161 + /// unrepresentable.
162 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
163 + pub enum Depth {
164 + /// Level with its surroundings. No edge.
165 + Flat,
166 + /// A card laid on the panel it sits in.
167 + Raised,
168 + /// A hole in the panel, with content down inside it. For anything the
169 + /// user looks *into*: a table body, a tag tree, a text field.
170 + Well,
171 + }
172 +
173 + impl Depth {
174 + /// The edge this depth is drawn with, if it has one.
175 + #[must_use]
176 + pub const fn bevel(self) -> Option<Bevel> {
177 + match self {
178 + Self::Flat => None,
179 + Self::Raised => Some(Bevel::Raised),
180 + Self::Well => Some(Bevel::Inset),
181 + }
182 + }
183 +
184 + /// The surface this depth is filled with.
185 + ///
186 + /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on,
187 + /// which is the difference between level-with and painted-the-same-colour.
188 + #[must_use]
189 + pub const fn fill(self) -> Option<Fill> {
190 + match self {
191 + Self::Flat => None,
192 + Self::Raised => Some(Fill::Raised),
193 + Self::Well => Some(Fill::Well),
194 + }
195 + }
196 +
197 + /// Pressing a raised region reads as a well, and nothing else moves.
198 + #[must_use]
199 + pub const fn pressed(self) -> Self {
200 + match self {
201 + Self::Raised => Self::Well,
202 + other => other,
203 + }
204 + }
205 + }
206 +
207 + #[cfg(test)]
208 + mod tests {
209 + use super::*;
210 +
211 + #[test]
212 + fn inset_is_raised_with_the_light_moved() {
213 + let (rl, rd) = Bevel::Raised.edges();
214 + let (il, id) = Bevel::Inset.edges();
215 + assert_eq!((rl, rd), (Edge::Light, Edge::Dark));
216 + assert_eq!((il, id), (rd, rl));
217 + }
218 +
219 + #[test]
220 + fn pressing_twice_is_a_no_op() {
221 + for b in [Bevel::Raised, Bevel::Inset] {
222 + assert_eq!(b.pressed().pressed(), b);
223 + }
224 + }
225 +
226 + #[test]
227 + fn a_raised_region_is_never_filled_with_a_recessed_surface() {
228 + // The bug this vocabulary exists to make unrepresentable.
229 + assert_eq!(Depth::Raised.fill(), Some(Fill::Raised));
230 + assert_eq!(Depth::Raised.bevel(), Some(Bevel::Raised));
231 + assert_eq!(Depth::Well.bevel(), Some(Bevel::Inset));
232 + assert_ne!(Depth::Well.fill(), Depth::Raised.fill());
233 + }
234 +
235 + #[test]
236 + fn flat_has_neither_edge_nor_fill() {
237 + assert_eq!(Depth::Flat.bevel(), None);
238 + assert_eq!(Depth::Flat.fill(), None);
239 + }
240 +
241 + #[test]
242 + fn pressing_a_card_makes_a_well() {
243 + assert_eq!(Depth::Raised.pressed(), Depth::Well);
244 + assert_eq!(
245 + Depth::Raised.pressed().bevel(),
246 + Depth::Raised.bevel().map(Bevel::pressed)
247 + );
248 + // Only raised regions respond to being pressed.
249 + assert_eq!(Depth::Flat.pressed(), Depth::Flat);
250 + assert_eq!(Depth::Well.pressed(), Depth::Well);
251 + }
252 +
253 + #[test]
254 + fn intents_name_makeover_tokens_and_nothing_else() {
255 + assert_eq!(Edge::Light.token(), "bevel-light");
256 + assert_eq!(Edge::Dark.token(), "bevel-dark");
257 + assert_eq!(Fill::Raised.token(), "surface-raised");
258 + assert_eq!(Fill::Well.token(), "surface-well");
259 + // No value ever leaves this crate.
260 + for t in [Edge::Light.token(), Edge::Dark.token()] {
261 + assert!(!t.starts_with('#'), "{t} looks like a value");
262 + }
263 + }
264 +
265 + #[test]
266 + fn only_the_well_needs_a_fallback() {
267 + assert_eq!(Fill::Well.fallback(), Some(Fill::Page));
268 + for f in [Fill::Page, Fill::Raised, Fill::Overlay] {
269 + assert_eq!(f.fallback(), None);
270 + }
271 + }
272 + }