Skip to main content

max / makeover-layout

0.11.0: a figure, a proportion in a row, field constraints, a file field
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:33 UTC
Signed with PGP, not checked
Commit: a096cb68f4ba8e5b1f8d389d61d2512c121bd185
Parent: ffe2da1
2 files changed, +241 insertions, -8 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.10.0"
3 + version = "0.11.0"
4 4 edition = "2024"
5 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 6 license = "MIT"
M src/lib.rs +240 -7
@@ -40,10 +40,10 @@
40 40 //! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how
41 41 //! a screen is put together.
42 42 //!
43 - //! **Validation** is absent on purpose rather than pending: neither app has a
44 - //! shared story, and a schema describing fields but not constraints acquires a
45 - //! constraint layer per app, which is how the divergence this crate exists to
46 - //! end got started.
43 + //! **Validation** was absent on purpose here, on the grounds that neither app
44 + //! had a shared story. That reasoning is retired — see 0.11.0 below, which is
45 + //! where the constraints arrived and why the argument did not survive contact
46 + //! with what the apps were measured to do.
47 47 //!
48 48 //! 0.3.0 closes a gap the first real adoption found, which is what adopting
49 49 //! against goingson first was for. [`Selector`] described only the *chosen*
@@ -107,6 +107,42 @@
107 107 //! the slot was already straining under a second consumer for a different
108 108 //! reason.
109 109 //!
110 + //! 0.10.0 adds [`Meter`], a proportion carried as a pair rather than as a
111 + //! percentage. Its own docs carry the argument; the short form is that the
112 + //! percentage shape had already been tried in goingson and had already needed a
113 + //! companion flag to recover what rounding and clamping threw away.
114 + //!
115 + //! 0.11.0 is four members from the quasi proving ground, batched into one
116 + //! release because pre-1.0 a minor is breaking and a cascade is nine repos.
117 + //! Three findings that arrived with them turned out not to belong here at all:
118 + //! this crate has no notion of an action, a route or a destination, so anything
119 + //! asking what a control *calls* was never the vocabulary's to say.
120 + //!
121 + //! - [`Figure`], a value with a caption. goingson had five of them across five
122 + //! screens with five class vocabularies for the one shape, which is the
123 + //! divergence this crate exists to end, sitting in plain sight and counted for
124 + //! the first time.
125 + //! - [`RowPart::Proportion`], so a [`Meter`] can sit in a row. `Meter` reached
126 + //! two of its seven sites at 0.10.0 and the other five are row-shaped. Exactly
127 + //! [`RowPart::Tokens`]'s problem with a different payload, and it takes
128 + //! `Tokens`' answer: the part carries the description of a bar, not a node.
129 + //! - [`Field::max_length`], [`Field::min`] and [`Field::max`], joining
130 + //! [`Field::required`], which had been sitting here as the sole constraint
131 + //! while the header above claimed there were none. The set stops before
132 + //! `pattern`, which fails the renderer test and is one site in one app.
133 + //! - [`FieldKind::File`]. Every host has an honest answer — a native picker, an
134 + //! `<input type="file">`, a path prompt, an argument — and it carries no
135 + //! accepted-types list because `accept` appears at zero sites in either app.
136 + //!
137 + //! The evidence rule changed under these, and it is worth recording because four
138 + //! earlier decisions were made under the old one. The two-app test said a shape
139 + //! earns a word once a second app wants it. It is backwards: a rule that
140 + //! withholds a word until a second app has duplicated the code guarantees the
141 + //! duplication, and app three writes it a third time. The bar is now generic
142 + //! against bespoke — is this furniture any app would have, or is it this app's
143 + //! own? Bespoke keeps [`Region::Bespoke`], which already carries a completion
144 + //! heatmap and is the right answer for a calendar nobody will build twice.
145 + //!
110 146 //! # Where the description stops
111 147 //!
112 148 //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
@@ -609,6 +645,20 @@
609 645 /// to pass, and it is why the tone lives on the token rather than on the
610 646 /// part.
611 647 Tokens,
648 + /// How much of a set the row's thing has done: a [`Meter`] in the row.
649 + ///
650 + /// Added 0.11.0, `da5666ae`, and it is [`Tokens`](Self::Tokens)'s problem
651 + /// again with a different payload. [`Meter`] arrived at 0.10.0 and closed
652 + /// two of the seven sites that asked for it; the other five sit in rows, and
653 + /// a row holds no nodes by the ruling that a row part may not carry an
654 + /// arbitrary node — the door through which a description becomes a
655 + /// templating language. So the part carries the *description of a bar*
656 + /// rather than a node, exactly as `Tokens` carries tags rather than nodes.
657 + ///
658 + /// Without it a row flattens the proportion into [`Meta`](Self::Meta) as
659 + /// "3/7 subtasks", which keeps both numbers and loses the reading, the same
660 + /// way a toned status badge read as prose before `Tokens`.
661 + Proportion,
612 662 }
613 663
614 664 impl RowPart {
@@ -637,6 +687,9 @@
637 687 // So do tokens: each one carries its own tone, and a part-level
638 688 // intent underneath it would fight the token that sits on it.
639 689 Self::Tokens => "content",
690 + // And so does a proportion, for the same reason: the meter carries
691 + // the tone, and it is about the ratio rather than about the row.
692 + Self::Proportion => "content",
640 693 }
641 694 }
642 695 }
@@ -880,6 +933,72 @@
880 933 }
881 934 }
882 935
936 + /// One figure with a caption: a number and what it counts.
937 + ///
938 + /// The dashboard shape. A large value over a small caption, several of them in a
939 + /// strip: a current streak, a completion rate, a total. Added 0.11.0,
940 + /// `93c6a174`, after goingson turned out to have five of them across five
941 + /// screens with five class vocabularies for the one shape — `task-overview-stat`,
942 + /// `stat-box`, `month-stat-item`, `contact-summary-stat`, `sync-stat`. Four put
943 + /// the value above the caption and one inverts it, which is drift inside the
944 + /// shape rather than a second shape.
945 + ///
946 + /// # Why the value is text
947 + ///
948 + /// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already
949 + /// formatted, and the formatting is the app's because only it knows whether the
950 + /// number is a percentage, a duration or a ratio. This carries none of the
951 + /// arithmetic [`Meter`] carries, and that is the difference between them: a
952 + /// meter is a proportion a renderer draws, and a figure is a fact a renderer
953 + /// sets in type.
954 + ///
955 + /// # Tone is carried, for [`Meter`]'s reason
956 + ///
957 + /// Three of the five sites tone the figure by their own means — `red`/`blue` on
958 + /// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on
959 + /// sync. So tone is carried at every site that needs it and derived at none, and
960 + /// no renderer can work out that a streak of zero is worth colouring.
961 + ///
962 + /// # What is not here
963 + ///
964 + /// Whether the figure answers a click. One of the five is a control — sync's
965 + /// "Not Applied: 3" opens the list — and an action is not something this crate
966 + /// can name: nothing here knows what a route is. That belongs beside the figure
967 + /// in whatever layer holds the actions, the same way a row's activation sits
968 + /// beside its parts rather than inside them.
969 + ///
970 + /// The arrangement is not here either. Several figures in a strip is a set, and
971 + /// a renderer given them one at a time cannot tell it is looking at one; the
972 + /// layer that holds the tree is where the set gets said.
973 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
974 + pub struct Figure<'a> {
975 + /// The number, formatted the way the app means it to read.
976 + pub value: &'a str,
977 + /// What it counts. The caption under the value.
978 + pub caption: &'a str,
979 + /// What the figure means right now. [`Tone::Neutral`] is an ordinary fact.
980 + pub tone: Tone,
981 + }
982 +
983 + impl<'a> Figure<'a> {
984 + /// A figure that is an ordinary fact.
985 + #[must_use]
986 + pub const fn new(value: &'a str, caption: &'a str) -> Self {
987 + Self {
988 + value,
989 + caption,
990 + tone: Tone::Neutral,
991 + }
992 + }
993 +
994 + /// What the figure means.
995 + #[must_use]
996 + pub const fn tone(mut self, tone: Tone) -> Self {
997 + self.tone = tone;
998 + self
999 + }
1000 + }
1001 +
883 1002 /// A named part of a screen.
884 1003 ///
885 1004 /// The thing `makeover-geometry` deliberately does not name: it names the space
@@ -1048,6 +1167,26 @@
1048 1167 Radio,
1049 1168 /// On or off.
1050 1169 Checkbox,
1170 + /// A file the user picks from wherever the host keeps files.
1171 + ///
1172 + /// Added 0.11.0, `844b5ae0`, from goingson's project-dashboard attachments
1173 + /// column. It was filed as a router finding — a control whose destination is
1174 + /// a host capability rather than an address — and splitting it is what made
1175 + /// it two answers instead of one member satisfying neither. *Opening* a file
1176 + /// is a one-way handoff and needs no new API. *Picking* one returns a value
1177 + /// into a write, which is a form concern, which is this.
1178 + ///
1179 + /// The membership test passes on every host and not by a stretch: a Tauri
1180 + /// app opens a native picker, a server renders `<input type="file">`, a
1181 + /// terminal prompts for a path, a CLI takes an argument. That is closer to
1182 + /// [`Email`](Self::Email), which exists because it changes the keyboard,
1183 + /// than to anything bespoke.
1184 + ///
1185 + /// It carries no accepted-types list and no multiple flag, and that is
1186 + /// measured rather than deferred: `accept` appears at zero sites in either
1187 + /// app. A member added for a case nobody has is a member designed against
1188 + /// nothing.
1189 + File,
1051 1190 /// Carried through the form and never shown.
1052 1191 Hidden,
1053 1192 }
@@ -1138,11 +1277,25 @@
1138 1277 /// that carried the value would have to carry a way to write it back, at which
1139 1278 /// point it is a form model and no longer a description.
1140 1279 ///
1141 - /// **Validation** is absent for the reason [`FieldKind`] records: [`error`] is
1142 - /// the *result* of validating, supplied by whoever validated. Nothing here
1143 - /// decides that a value is wrong.
1280 + /// **Constraints** are here and enforcement is not, which is one line rather
1281 + /// than two. [`required`], [`max_length`], [`min`] and [`max`] are facts about
1282 + /// the *question*, so a renderer can emit its host's idiom for each — an HTML
1283 + /// attribute, a marked label, a clamped spinner — and the platform helps the
1284 + /// user before anything is submitted. Deciding that a value is wrong stays with
1285 + /// whoever validated, and [`error`] is that decision arriving back.
1286 + ///
1287 + /// The set stops before `pattern`, and stops there on both tests at once. A
1288 + /// regex has an honest answer in a webview and none anywhere else: egui would
1289 + /// have to run it per keystroke and decide what a half-typed value means, which
1290 + /// is enforcement wearing description's clothes. And it is one site in goingson
1291 + /// and none in Balanced Breakfast, against 8 and 1 for `maxlength`. Measured
1292 + /// 2026-08-09, `2cbad3e2`.
1144 1293 ///
1145 1294 /// [`error`]: Field::error
1295 + /// [`required`]: Field::required
1296 + /// [`max_length`]: Field::max_length
1297 + /// [`min`]: Field::min
1298 + /// [`max`]: Field::max
1146 1299 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1147 1300 pub struct Field<'a> {
1148 1301 /// What kind of value it takes.
@@ -1179,6 +1332,23 @@
1179 1332 pub options: &'a [Choice<'a>],
1180 1333 /// Whether the form refuses to submit without it.
1181 1334 pub required: bool,
1335 + /// The longest the value may be, in characters.
1336 + ///
1337 + /// Added 0.11.0 with [`min`](Self::min) and [`max`](Self::max), joining
1338 + /// [`required`](Self::required), which had been the only constraint here
1339 + /// since before the crate wrote down that it carried none.
1340 + pub max_length: Option<u32>,
1341 + /// The lowest value accepted, as the host would write it.
1342 + ///
1343 + /// Text rather than a number, because the bound is only a number for some
1344 + /// of the kinds that take one. goingson's own sites are `min="1"` on a
1345 + /// duration and `min="2026-08-09T14:30"` on a datetime, and a numeric member
1346 + /// could say the first and not the second. The [`kind`](Self::kind) already
1347 + /// says how to read it, the same way it does for the value.
1348 + pub min: Option<&'a str>,
1349 + /// The highest value accepted, as the host would write it. See
1350 + /// [`min`](Self::min).
1351 + pub max: Option<&'a str>,
1182 1352 /// Whether the field lives behind a "more options" disclosure.
1183 1353 pub extended: bool,
1184 1354 }
@@ -1196,6 +1366,9 @@
1196 1366 placeholder: None,
1197 1367 options: &[],
1198 1368 required: false,
1369 + max_length: None,
1370 + min: None,
1371 + max: None,
1199 1372 extended: false,
1200 1373 }
1201 1374 }
@@ -1331,6 +1504,66 @@
1331 1504 mod tests {
1332 1505 use super::*;
1333 1506
1507 + #[test]
1508 + fn a_figure_carries_its_tone_because_no_renderer_can_derive_it() {
1509 + // Three of goingson's five sites tone the figure by their own means, so
1510 + // tone is carried at every site that needs it and derived at none. The
1511 + // same reasoning `Meter` reached, from a different direction.
1512 + let streak = Figure::new("0", "Current Streak").tone(Tone::Warning);
1513 + assert_eq!(streak.tone, Tone::Warning);
1514 + assert_eq!(Figure::new("17", "Total").tone, Tone::Neutral);
1515 + }
1516 +
1517 + #[test]
1518 + fn a_figures_value_is_text_because_only_the_app_knows_what_it_is() {
1519 + // "84%", "12/30", "3d". A figure is whatever the app computed, already
1520 + // formatted, and that is the line between this and `Meter`: a meter is
1521 + // a proportion a renderer draws, a figure is a fact it sets in type.
1522 + for value in ["84%", "12/30", "3d"] {
1523 + assert_eq!(Figure::new(value, "Rate").value, value);
1524 + }
1525 + }
1526 +
1527 + #[test]
1528 + fn a_proportion_is_a_row_part_and_takes_no_intent_of_its_own() {
1529 + // The meter carries the tone, so a part-level intent underneath would
1530 + // fight it. Same answer `Tokens` needed, for the same reason.
1531 + assert_eq!(RowPart::Proportion.intent(), RowPart::Tokens.intent());
1532 + assert!(!RowPart::Proportion.revealed_on_hover());
1533 + }
1534 +
1535 + #[test]
1536 + fn a_file_field_is_drawn_and_offers_no_options() {
1537 + // It is a control the user operates, unlike `Hidden`, and it does not
1538 + // pick from a list the description carries, unlike `Select`.
1539 + assert!(FieldKind::File.visible());
1540 + assert!(!FieldKind::File.offers_options());
1541 + assert!(!FieldKind::File.confidential());
1542 + }
1543 +
1544 + #[test]
1545 + fn a_constraint_is_a_fact_about_the_question_and_not_a_verdict() {
1546 + // The whole model: the description carries the rule, the renderer emits
1547 + // its host's idiom, and `error` is what arrives back when someone
1548 + // validated. Nothing here decides a value is wrong.
1549 + let field = Field {
1550 + max_length: Some(100),
1551 + min: Some("1"),
1552 + max: Some("240"),
1553 + required: true,
1554 + ..Field::new(FieldKind::Number, "minutes", "Minutes")
1555 + };
1556 + assert!(!field.invalid());
1557 +
1558 + // A bound is text because it is only a number for some of the kinds
1559 + // that take one. goingson has both shapes live.
1560 + let when = Field {
1561 + min: Some("2026-08-09T14:30"),
1562 + ..Field::new(FieldKind::Text, "starts", "Starts")
1563 + };
1564 + assert_eq!(when.min, Some("2026-08-09T14:30"));
1565 + }
1566 +
1334 1567 #[test]
1335 1568 fn a_meter_keeps_the_over_run_the_percentage_throws_away() {
1336 1569 // The whole reason this is a pair. goingson's `Task::time_progress`