Skip to main content

Crate makeover_layout

Crate makeover_layout 

Source
Expand description

The renderer-agnostic half of the make-family design system.

makeover answers what colour, and varies by theme. makeover-geometry answers how much space, and varies by density and surface. This crate answers what the thing is, and varies by nothing.

§The deferral rule

A description names intents and relationships, never values. Say Fill::Raised, never #D9DDF4. Say Gap::Peer, never 6px. What is left once colour and spacing are deferred is composition: which edges are lit, what inverts on press, what nests in what.

The constraint that shapes all of it: a renderer that can only paint rectangles has to be able to express the result. egui has no box-shadow: inset and one stroke per widget with no per-side control; a terminal has box-drawing characters and one cell of resolution, and cannot draw a two-tone lit edge at all. A description that assumes per-side edges is a CSS description wearing a neutral name. So this crate names the intent — this region is a well — and each renderer chooses an expression it can actually produce, including dropping half of one.

§Scope

Depth came first: the bevel and the surfaces it shapes. That much was settled the hard way — the vocabulary here was read off audiofiles’ ui::theme and ui::widgets, which are the only implementation written by a consumer with no CSS, then checked against both webview apps. All three agreed once Balanced Breakfast’s fills were corrected.

0.2.0 adds the rest of the description, each member drawn the same way, from what the three apps already hand-write rather than from a taxonomy:

Validation was absent on purpose here, on the grounds that neither app had a shared story. That reasoning is retired — see 0.11.0 below, which is where the constraints arrived and why the argument did not survive contact with what the apps were measured to do.

0.3.0 closes a gap the first real adoption found, which is what adopting against goingson first was for. Selector described only the chosen option, so an unchosen one fell through to Depth::Flat and no renderer drew it; goingson’s tab strip recesses its unchosen tabs by hand and could not delete the line, because being recessed is why the chosen tab reads as coming forward. So Selector::unchosen joins chosen, and saying it needed Fill::Sunken and Depth::Sunken: a surface set back by colour with no edge, which is neither a well nor level-with.

0.7.0 adds State, the interaction axis, closing the gap that adopting against three apps rather than one made visible. The description named rest and, through Depth::pressed, pressed. It named neither focus nor disabled, so makeover-webview emitted a hover rule and stopped, and each consumer completed the primitive from outside by out-specifying a rule it did not own: 19 such rules in goingson, 21 in the MNW server, a further set in Balanced Breakfast, and three focus rings that do not match. The axis is deliberately two members wide, because hover and pressed belong where they already are. State’s own docs carry that argument.

0.8.0 finishes Field, which described a field well enough to label it and not well enough to draw it. Writing makeover-webview’s form emitter found three things missing and the renderer supplied all three from outside: the current value, a select’s options, and the placeholder. Two of those move here and one does not.

  • Field::placeholder is user-facing text sitting beside label and hint. There was never a reading on which it was renderer state; it was outside only because adding a field to a published struct is breaking.
  • Field::options moves because every renderer needs them and each was going to invent its own shape. Choice is the shape makeover-webview already arrived at, taken as-is rather than redesigned.
  • The current value stays renderer-side and is not coming here. It is the one of the three that is genuinely state: a webview reads it out of the DOM, an immediate-mode renderer holds a &mut to the app’s own field, and a description that carried it would be a form model.

0.9.0 opens RowPart, which was the last closed enum in the vocabulary, and adds RowPart::Tokens. Both halves come from the same finding, made by the first two real screens described through the router rather than by reading a stylesheet.

A goingson project card carries two trailing badges, a type and a toned status; a contact card carries a primary email and a strip of tags. Meta is one slot and one string, so both ports joined their facts with a separator and lost what the second one was: a status reads as text where it used to read as colour. Token already says exactly the right thing — a small labelled thing with a kind, a tone and an optional action — and could only ever be a node in its own right, never inside a row.

So the missing thing was permission rather than a concept. Tokens is that permission, and #[non_exhaustive] arrives with it so the next member is not a lockstep event across three renderers. The pairing is the point: this enum’s own consumer in makeover-webview carried a comment predicting it would stop compiling one day, which is a lockstep break written down and waited for rather than prevented.

Balanced Breakfast was checked before the member was added, because one consumer wanting something is not evidence. It packs a count and two icon buttons into the same single Meta slot while leaving Actions empty, so the slot was already straining under a second consumer for a different reason.

0.10.0 adds Meter, a proportion carried as a pair rather than as a percentage. Its own docs carry the argument; the short form is that the percentage shape had already been tried in goingson and had already needed a companion flag to recover what rounding and clamping threw away.

0.11.0 is four members from the quasi proving ground, batched into one release because pre-1.0 a minor is breaking and a cascade is nine repos. Three findings that arrived with them turned out not to belong here at all: this crate has no notion of an action, a route or a destination, so anything asking what a control calls was never the vocabulary’s to say.

  • Figure, a value with a caption. goingson had five of them across five screens with five class vocabularies for the one shape, which is the divergence this crate exists to end, sitting in plain sight and counted for the first time.
  • RowPart::Proportion, so a Meter can sit in a row. Meter reached two of its seven sites at 0.10.0 and the other five are row-shaped. Exactly RowPart::Tokens’s problem with a different payload, and it takes Tokens’ answer: the part carries the description of a bar, not a node.
  • Field::max_length, Field::min and Field::max, joining Field::required, which had been sitting here as the sole constraint while the header above claimed there were none. The set stops before pattern, which fails the renderer test and is one site in one app.
  • FieldKind::File. Every host has an honest answer — a native picker, an <input type="file">, a path prompt, an argument — and it carries no accepted-types list because accept appears at zero sites in either app.

The evidence rule changed under these, and it is worth recording because four earlier decisions were made under the old one. The two-app test said a shape earns a word once a second app wants it. It is backwards: a rule that withholds a word until a second app has duplicated the code guarantees the duplication, and app three writes it a third time. The bar is now generic against bespoke — is this furniture any app would have, or is it this app’s own? Bespoke keeps Region::Bespoke, which already carries a completion heatmap and is the right answer for a calendar nobody will build twice.

0.12.0 is two more from the same proving ground, and the same sorting happened first: six findings came out of a measurement of goingson’s whole frontend, and four of them turned out to be asking what a control calls, which this crate cannot say. The two that were really here:

  • Readiness grows from two states to four. It named Ready and Pending and stopped, so a screen whose list came back empty had nothing to say about it; goingson draws an empty state at 27 sites and Balanced Breakfast at 9. Empty and Failed are the same axis rather than a new member beside it, because a region shows one of the four and never two. #[non_exhaustive] arrives with them, the pairing RowPart made at 0.9.0 and for the same reason.
  • Column::sortable, Column::sorted and Sort. The one finding in the set that completes a member rather than adding one: Column shipped with a width and a priority and could not say that a table is ordered by a column, so a described table could draw no caret and offer no reordering.

0.14.0 is two additive members on two #[non_exhaustive] enums, released together because publishing twice for that is waste and the cascade below this crate is nine repos.

  • Depth::Overlay. The enum could say raised, well, sunken and flat, and could not say that a surface sits over the page. Every renderer already had the surface — makeover-tui’s Palette::overlay, makeover-immediate’s Palette::elevation, makeover-webview’s --elevation-overlay — and none of them could be reached from a description. buckets_of_money has 16 modals waiting on it.
  • CellPart, which is RowPart for tables. A row’s parts have carried their own content intent since 0.2.0, so .row-actions inherits rather than taking a text colour; a table cell had no such vocabulary and makeover-webview emitted one undifferentiated .cell, so a button in a cell was painted as text. The four members are the four things quasi’s Cell was measured to hold, and the count is in that crate’s history rather than assumed here.

0.15.0 adds FieldKind::Date and FieldKind::DateTime, on the argument FieldKind::Email was admitted on: a webview emits a different type=, which is a native picker, the platform’s validation and a different keyboard on a touch device. Described as text with a “YYYY-MM-DD” hint, all three are lost.

Two members and not one or five, from a count rather than from symmetry: 13 sites of date and 13 of datetime-local across the MNW server and goingson, and zero of time, month or week. The wire format each takes is named here as DATE_FORMAT and DATETIME_FORMAT, because a host left to pick its own would disagree with a server silently, and FieldKind::temporal is the pair asked about once rather than at each renderer. FieldKind’s own comment claiming radio was the last HTML input type missing was already false when 0.8.1 wrote it; these are what it was missing.

What each of the 0.12.0 findings deliberately leaves out is the address — what pressing a header calls, and where an empty state’s “Add your first project” button goes. That is the boundary this crate is defined by, and four findings moved across it rather than being answered here.

0.19.0 narrows State to State::Disabled alone. State::Focus is gone: a description never states what has focus, because what focus is differs per host and every renderer had already decided for itself — the webview draws it from :focus-visible, egui refused the variant outright, and quasi-tui honoured it once at startup and overrode it thereafter.

0.20.0 adds Region::Widget, the third tier, and #[non_exhaustive] to Region with it. Every vocabulary finding until now had two answers available — grow the primitive set, or Region::Bespoke — and a whole class of thing is wrong for both. A carousel is not a primitive, because a terminal has none and that is the test Node::Html failed. It is not bespoke either, because bespoke is what one app owns and every part of a carousel is furniture plus members this crate already has.

The cost of the binary was that refusing a primitive was expensive: the app hand-rolls the thing forever, so the pressure always ran toward growing the primitive set with one host’s idioms. A named assembly changes what “no” costs without changing what the vocabulary can say.

MNW’s carousel is the first consumer and was the finding that started it: one partial, three pages, an ordered set of frames with a position, prev/next and a dot strip, all of it sayable already and none of it nameable. See wiki widget-tier for the ownership model, which is why this member carries a name a renderer may decline to know.

0.21.0 adds Image and Fit, found by trying to describe MNW’s carousel under 0.20.0’s widget tier and getting one step in. Nothing named a picture. The vocabulary could say a number with a caption, a badge, a meter and a table, and could not say the thing three of MNW’s public pages are mostly made of.

It reads as an oversight and is a measurement: 24 <img> sites across 22 MNW templates, against one in goingson and none in Balanced Breakfast or audiofiles. A picture is furniture a content platform has, and MNW is the only one in the tree, so the evidence never arrived from the two-app direction the earlier rule looked in. Under the generic-against-bespoke bar it is not close: a picture is not one app’s own.

A primitive rather than a widget, which is worth stating now that the tier makes it a real question. A widget is an assembly of things already sayable and a picture is a leaf, assembled from nothing. It also passes the test Node::Html failed — every host has an honest answer, including a terminal, which has a graphics protocol or has Image::alt.

Image carries no source, the split Act already makes: an address is not this crate’s to hold. See its own docs, which is where the argument is.

0.22.0 finishes Image, which 0.21.0 shipped unable to say how much room a picture needs. Without that a renderer cannot reserve space, so a picture occupies nothing until its bytes arrive and then takes its full height at once. Measured on MNW’s landing page: a 478px jump per frame and a cumulative layout shift of 0.087 for the page.

  • Image::intrinsic, the picture’s own dimensions, carried as Extent. A fact about the asset rather than a display size, which is what keeps it on this side of the deferral rule: 5120x3412 is what the file is, and no renderer can learn it without fetching the bytes.
  • Loading, and the default flips to Loading::Eager. 0.21.0 emitted the webview’s loading="lazy" for every picture, which read one consumer’s habit as a rule. Deferring a picture that is on screen at first paint saves nothing and makes its shift land later. The carousel is the case that proves this cannot be one renderer-wide setting: its first frame is on screen and its others are not, in one widget, at one moment.

0.23.0 adds Showing, which is three open findings collapsing into one member. A tab group could not say which tab was open, a carousel could not say which frame was up, and a disclosure could not say whether its child was showing. All three are the same missing sentence, and while it was missing a renderer had two moves: match on a widget name, or draw every child.

So the widget tier was taking the blame for a gap one level below it. With this a renderer derives its chrome from the description — labels get a strip, no labels get previous/position/next — once, for every widget there will ever be, and Region::Widget’s name goes back to being app vocabulary a renderer may decline to know.

Only the kind lives here. Which child is up, and what each child is called, sit with whatever holds the regions, the same split Selector already made against Node::Select.

§Reach, focus and the focus ring

Three terms, and no others, for what 0.19.0 moved out of the description. Reach is which things can take focus and in what order; a browser reads it off the document, a TUI derives it from draw order, egui from its own id stack. Focus is which reached thing has the keyboard right now: the renderer’s, live, never described and never round-tripped through a description. The focus ring is the visible cue; the token (focus-ring, derived by makeover from the action colour) is the one shared artifact and the drawing is the renderer’s. Retired as names for any of this: “focus stroke”, “focus cue”, “wants focus”. “Caret” is a different thing — the text cursor inside a field — and keeps its name.

§Where the description stops

The rule is that a member is added when an app needs a fact the vocabulary cannot state, and refused when what it wants is presentation it should be asking a renderer for. That is the whole test. It is not a quota, and the goal is every screen described.

§What the timeline refusal got wrong, 2026-08-15

This section used to read “a day-plan timeline, a kanban board and a calendar are not describable here and will not become describable”, and it propagated: 12 files across three apps, three libraries and the design wiki cited it, including audiofiles and the MNW server, neither of which has a timeline. It is withdrawn, and Track is the member it was refusing.

The error was pricing. The argument assumed a timeline needs a component library’s worth of vocabulary, and nobody measured it. Held against goingson’s day-planning-render.js, the members it actually needed and could not get were two integers: where a thing starts, and how long it lasts. Labels, gridlines, item bodies and tones were all furniture this crate already named. A refusal that expensive should have carried a measurement, and did not.

The reasoning underneath it survives and is still the test: slot heights, gridline colour, how overlapping things stack, which hour scrolls into view. Those are presentation, they stay the renderer’s, and Track carries none of them. What changed is the conclusion, not the principle.

§The other two, measured 2026-08-15

The same sentence refused a kanban board and a calendar. Both were counted the way the timeline should have been, and neither came out where the refusal put it.

Kanban: one member, and it is Region::Columns. Held against goingson’s tasks-kanban.js, every card fact was already sayable — title, project, due date, the blocked and unblocks badges, subtask progress, the open action and the context menu are Row’s existing parts. A column is a heading, a count and a list. What nothing could say was that the columns are peers: Arrangement offers list-detail and sidebar-content, and a board described as either is a lie about the screen. Dragging a card between columns never entered into it — a drop’s effect is “set status”, a discrete action Row’s menu already carries, and the drag itself is affordance.

Calendar: no members, no consumer, and a sharper reason (Max, 2026-08-15). The month grid’s primacy in calendar apps is an artifact of paper: paper cannot be queried, so it has to show every day at once as a fallback index. Routes, search and ranking do that job better, which is the argument events-calendar.js already lost to a segmented list on 2026-08-11.

Three jobs survive that reasoning, and only one of them needs a grid:

  1. Spans across days — a stretch of leave, a trip, a sprint. You cannot see “away the 3rd to the 17th” in a list without diffing dates. This is Track with Unit::Days, not a calendar, and Track::days is it.
  2. Density at a glance — which weeks were heavy. That is a heatmap, and goingson describes both of its heatmaps as lists already.
  3. Weekday periodicity — “every other Tuesday”, “the 15th is a Saturday”. This is the only job that needs the seven-column wrap, because alignment is the whole of what makes it visible.

So the open question is not “is a calendar describable” but “is job 3 worth a member”, and nothing in the tree asks for job 3 yet. GoingsOn quasicoherent 4a1237b6.

A month grid renders today as a Table: seven weekday columns, weeks as rows, blanks for the offset. goingson’s monthly review reached this conclusion before this note did and describes its month as a list of days that had something on them, marking today with an ordinary badge. What the tree actually contains is two completion heatmaps — one scalar per day — and no calendar at all: events-calendar.js was deleted 2026-08-11 in favour of a segmented list, and no MNW template mentions one. So the refusal was defending a screen nobody has. If one is built, measure again; the facts already fit and only the grid’s shape would be in question.

The pattern worth keeping from all three: one sentence refused three things for one reason, and the reason was wrong three different ways. Count the members.

Region::Bespoke remains for the genuinely app-owned, and its justification does not depend on the withdrawn claim. The description names the place and the app owns the contents, so a screen containing a timeline is still a whole screen and still routable. Without it, the four goingson screens that make the app worth using would need a second, undescribed path beside the router, and two paths is how a vocabulary starts drifting from its app again.

Region::Widget sits between that limit and the primitives, and it does not move the limit. A widget is an assembly of members this crate already has, under a name a renderer may or may not recognise. Anything that needs a member the vocabulary does not have is still a finding about the vocabulary or still bespoke; naming an assembly buys no new expressive power, which is exactly why it is safe to let the set grow outside this crate.

Structs§

Act
Choice
One option offered by a field FieldKind::offers_options accepts.
Column
One column of a table.
Extent
A picture’s own pixel dimensions.
Field
One field of a form.
Figure
One figure with a caption: a number and what it counts.
Image
A picture, and what it says to someone who is not looking at it.
Meter
How much of a set is done.
Placement
Where a thing sits on a Track, and for how long.
Share
How much of the width an arrangement’s first region takes.
Span
A window on an axis, in whatever Unit its Track counts.
Track
A time axis: things placed by when they happen, rather than flowed.

Enums§

Arrangement
How a screen is laid out.
Bevel
Which way the light falls across a two-tone edge.
CellPart
What a table cell holds.
Depth
How a region sits relative to the surface behind it.
Edge
One side of a bevel, named by the intent it takes.
FieldKind
What kind of value a form field takes.
Fill
A surface intent a region is filled with.
Fit
Something the user can do, and what it costs to say so.
Heading
How far down the heading tree a title sits.
Loading
When a picture is needed.
Measure
How wide the content of a whole screen runs.
Notice
Something the app is telling the user, unprompted.
Priority
What a column is worth when there is not room for all of them.
Readiness
What is in a region right now.
Region
A named part of a screen.
RowPart
The parts of a list row.
Selector
A control that picks between things.
Showing
How many of a region’s children are visible at once.
Sort
Which way a column is ordered.
State
An interaction state a region can be in, beside whatever Depth it is.
Token
A small labelled thing that sits inside something else.
Tone
What a region is saying, when it is saying something.
Unit
What a Track’s integers count.
Width
How much room a column asks for.

Constants§

DATETIME_FORMAT
The wire format a FieldKind::DateTime value takes: YYYY-MM-DDTHH:MM, local, carrying no zone and no seconds.
DATE_FORMAT
The wire format a FieldKind::Date value takes: ISO 8601, YYYY-MM-DD.

Traits§

Intent
A colour intent this crate refers to but never resolves.