Skip to main content

max / makeover-layout

Name a proportion, as a pair rather than a percentage Nine sites across goingson and Balanced Breakfast drew a progress bar and nothing here named one, so a described screen concatenated both numbers into its heading: "Subtasks 3/7", "Time Tracking 45m tracked / 30m est, over". Every fact survives that and the reading does not, which is the loss RowPart::Tokens closed when a toned badge became prose. Meter carries done and total, not the percentage the apps compute. The percentage had already been tried and had already failed at the site that mattered: goingson's Task::time_progress divides, rounds, then clamps to 100, and the app carries a separate is_over_estimate flag beside it to recover what the clamp threw away. A pair keeps the over-run without a companion field, and percent() is one call away for drawing. The pair is also what the call sites already have. All seven determinate bars write the ratio into the accessible layer and never a percentage, so a percentage member would have made the label mandatory everywhere, which is the concatenated text one layer down. Tone is carried rather than derived: the same fullness is success on a subtask rollup and danger on a time estimate, and only the app knows which. The progress of an operation gets nothing — the two sites that are one are imperative and live, and a description is built once and dropped.
Author: Max Johnson <me@maxj.phd> · 2026-08-09 13:02 UTC
Signed with PGP, not checked
Commit: ffe2da12f54d2ac16f26864326e9eee3ebb7f00e
Parent: 304c76a
2 files changed, +187 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.9.0"
3 + version = "0.10.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 +186
@@ -751,6 +751,135 @@
751 751 Pending,
752 752 }
753 753
754 + /// How much of a set is done.
755 + ///
756 + /// Added 0.10.0. Nine sites across the two webview apps drew a bar and nothing
757 + /// here named one, so every described screen concatenated the two numbers into
758 + /// its heading text instead: "Subtasks 3/7", "Time Tracking 45m tracked / 30m
759 + /// est, over". Every fact survives that and the reading does not, which is the
760 + /// same loss `RowPart::Tokens` closed when a toned status badge became prose.
761 + ///
762 + /// # Why a pair and not a percentage
763 + ///
764 + /// Both numbers, not the percentage the apps compute from them. The percentage
765 + /// was the obvious shape and it had already been tried: goingson's
766 + /// `Task::time_progress` divides, rounds, and then clamps to 100, which throws
767 + /// away the one case the bar exists to show — 45 minutes tracked against a
768 + /// 30-minute estimate. It carries a separate `is_over_estimate` boolean beside
769 + /// it to recover the fact the clamp dropped. A pair keeps the over-run without a
770 + /// companion flag, and [`percent`](Meter::percent) is still one call away for a
771 + /// renderer that wants it.
772 + ///
773 + /// The pair is also what the apps already have at every site. All seven
774 + /// determinate bars write the ratio into the accessible layer and never the
775 + /// percentage: `title="3/7 subtasks"`, `aria-label="3 of 7 subtasks completed"`,
776 + /// a milestone's own `3/7` span. Given 43 nothing can recover "3 of 7", so a
777 + /// percentage member would have made [`label`](Meter::label) mandatory at every
778 + /// call site, which is the concatenated text this member removes, moved one
779 + /// layer down.
780 + ///
781 + /// # What this is not
782 + ///
783 + /// The progress of an *operation*. Two of the nine sites are that — goingson's
784 + /// focus timer, Balanced Breakfast's feed fetch — and they get nothing here, on
785 + /// purpose. Both are imperative controllers over a live handle, driven by a tick
786 + /// or an event stream, and a description is built once and dropped. Holding one
787 + /// would mean growing a way to update a description between renders, which is a
788 + /// different feature. [`Readiness::Pending`] and a [`Notice::Toast`] carry the
789 + /// honest part.
790 + ///
791 + /// The two cases are distinguishable in the markup rather than by taste: every
792 + /// determinate bar in both apps carries a tone, and neither operation bar
793 + /// carries one. Two codebases drew that line the same way without coordinating.
794 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
795 + pub struct Meter<'a> {
796 + /// How much is done. May exceed [`total`](Self::total), and that is the
797 + /// case worth drawing.
798 + pub done: u32,
799 + /// How much there is to do. Zero means there is no set, not that the set is
800 + /// complete.
801 + pub total: u32,
802 + /// What the proportion means right now.
803 + ///
804 + /// Carried rather than derived, because no renderer can work it out. The
805 + /// same 90% is [`Tone::Success`] on a subtask rollup and [`Tone::Danger`] on
806 + /// a time estimate, and goingson picks between them from `is_over_estimate`,
807 + /// a fact about the data and not about the number.
808 + pub tone: Tone,
809 + /// What is being counted, if the bar says so: "subtasks", "tasks".
810 + ///
811 + /// The noun, not the ratio. A renderer builds "3 of 7 subtasks" from this
812 + /// and the two numbers; handing it the assembled string would put the
813 + /// sentence order in the description, where a terminal at one line and a
814 + /// tooltip want different ones.
815 + pub label: Option<&'a str>,
816 + }
817 +
818 + impl<'a> Meter<'a> {
819 + /// A proportion with no tone and no label.
820 + #[must_use]
821 + pub const fn new(done: u32, total: u32) -> Self {
822 + Self {
823 + done,
824 + total,
825 + tone: Tone::Neutral,
826 + label: None,
827 + }
828 + }
829 +
830 + /// What the proportion means.
831 + #[must_use]
832 + pub const fn tone(mut self, tone: Tone) -> Self {
833 + self.tone = tone;
834 + self
835 + }
836 +
837 + /// What is being counted.
838 + #[must_use]
839 + pub const fn label(mut self, label: &'a str) -> Self {
840 + self.label = Some(label);
841 + self
842 + }
843 +
844 + /// How full the bar is, 0 to 100, clamped.
845 + ///
846 + /// For drawing, which is the only thing a clamped number is good for. Ask
847 + /// [`overflowing`](Self::overflowing) before reporting it as a fact, or this
848 + /// is `time_progress`'s bug again with the clamp moved.
849 + ///
850 + /// An empty set reads as 0. Nothing is done, because there is nothing to do
851 + /// and no bar to fill; the apps guard on the count before drawing at all.
852 + #[must_use]
853 + pub const fn percent(&self) -> u8 {
854 + if self.total == 0 {
855 + return 0;
856 + }
857 + let scaled = (self.done as u64 * 100) / self.total as u64;
858 + if scaled > 100 { 100 } else { scaled as u8 }
859 + }
860 +
861 + /// Whether more is done than there was to do.
862 + ///
863 + /// The fact [`percent`](Self::percent) destroys, kept reachable so a
864 + /// renderer can mark the over-run rather than drawing a full bar and
865 + /// implying it landed exactly.
866 + #[must_use]
867 + pub const fn overflowing(&self) -> bool {
868 + self.done > self.total
869 + }
870 +
871 + /// Whether there is a set at all.
872 + ///
873 + /// A meter over nothing is sayable on purpose, for the same reason a field
874 + /// with no options is: it is what an app with an unloaded count actually
875 + /// has, and a renderer that shows an empty bar says so on screen rather than
876 + /// dividing by zero.
877 + #[must_use]
878 + pub const fn is_empty(&self) -> bool {
879 + self.total == 0
880 + }
881 + }
882 +
754 883 /// A named part of a screen.
755 884 ///
756 885 /// The thing `makeover-geometry` deliberately does not name: it names the space
@@ -1202,6 +1331,63 @@
1202 1331 mod tests {
1203 1332 use super::*;
1204 1333
1334 + #[test]
1335 + fn a_meter_keeps_the_over_run_the_percentage_throws_away() {
1336 + // The whole reason this is a pair. goingson's `Task::time_progress`
1337 + // clamps to 100 and then carries `is_over_estimate` beside it to say
1338 + // what the clamp dropped; a meter says both from one fact.
1339 + let over = Meter::new(45, 30);
1340 + assert_eq!(over.percent(), 100);
1341 + assert!(over.overflowing());
1342 +
1343 + let exact = Meter::new(30, 30);
1344 + assert_eq!(exact.percent(), over.percent());
1345 + assert!(!exact.overflowing());
1346 + }
1347 +
1348 + #[test]
1349 + fn an_empty_set_does_not_divide_by_zero() {
1350 + // Sayable on purpose, so it has to be answerable. A meter over an
1351 + // unloaded count is what an app actually has for a frame.
1352 + let none = Meter::new(0, 0);
1353 + assert_eq!(none.percent(), 0);
1354 + assert!(none.is_empty());
1355 + assert!(!none.overflowing());
1356 + }
1357 +
1358 + #[test]
1359 + fn the_ratio_survives_where_a_percentage_would_not() {
1360 + // Given 43 nothing can recover "3 of 7", which is why the numbers are
1361 + // carried and the label names only the noun.
1362 + let m = Meter::new(3, 7).label("subtasks");
1363 + assert_eq!(m.percent(), 42);
1364 + assert_eq!((m.done, m.total), (3, 7));
1365 + assert_eq!(m.label, Some("subtasks"));
1366 + }
1367 +
1368 + #[test]
1369 + fn tone_is_carried_because_no_renderer_can_derive_it() {
1370 + // The same fullness means opposite things on two of goingson's bars,
1371 + // and only the app knows which.
1372 + let subtasks = Meter::new(9, 10).tone(Tone::Success);
1373 + let estimate = Meter::new(9, 10).tone(Tone::Danger);
1374 + assert_eq!(subtasks.percent(), estimate.percent());
1375 + assert_ne!(subtasks.tone, estimate.tone);
1376 + // Untoned by default: a bar says nothing about status until something
1377 + // says so, the same way a row is not selectable until told.
1378 + assert_eq!(Meter::new(9, 10).tone, Tone::Neutral);
1379 + }
1380 +
1381 + #[test]
1382 + fn a_meter_does_not_overflow_on_large_counts() {
1383 + // done * 100 in u32 would wrap somewhere past 42 million. Counts that
1384 + // size are not tasks, but a description layer that silently reports 3%
1385 + // for a full bar is worse than one that is slow.
1386 + let big = Meter::new(u32::MAX, u32::MAX);
1387 + assert_eq!(big.percent(), 100);
1388 + assert!(!big.overflowing());
1389 + }
1390 +
1205 1391 #[test]
1206 1392 fn inset_is_raised_with_the_light_moved() {
1207 1393 let (rl, rd) = Bevel::Raised.edges();