Skip to main content

max / makeover-layout

0.12.0: four readiness states, and a column that can be sorted
Author: Max Johnson <me@maxj.phd> · 2026-08-09 16:23 UTC
Signed with PGP, not checked
Commit: 05b82fa9d9d534f74223f5e18d38116a9d73f6bd
Parent: a096cb6
2 files changed, +201 insertions, -7 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.11.0"
3 + version = "0.12.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 +200 -6
@@ -143,6 +143,28 @@
143 143 //! own? Bespoke keeps [`Region::Bespoke`], which already carries a completion
144 144 //! heatmap and is the right answer for a calendar nobody will build twice.
145 145 //!
146 + //! 0.12.0 is two more from the same proving ground, and the same sorting
147 + //! happened first: six findings came out of a measurement of goingson's whole
148 + //! frontend, and four of them turned out to be asking what a control *calls*,
149 + //! which this crate cannot say. The two that were really here:
150 + //!
151 + //! - [`Readiness`] grows from two states to four. It named `Ready` and
152 + //! `Pending` and stopped, so a screen whose list came back empty had nothing
153 + //! to say about it; goingson draws an empty state at 27 sites and Balanced
154 + //! Breakfast at 9. `Empty` and `Failed` are the same axis rather than a new
155 + //! member beside it, because a region shows one of the four and never two.
156 + //! `#[non_exhaustive]` arrives with them, the pairing [`RowPart`] made at
157 + //! 0.9.0 and for the same reason.
158 + //! - [`Column::sortable`], [`Column::sorted`] and [`Sort`]. The one finding in
159 + //! the set that completes a member rather than adding one: `Column` shipped
160 + //! with a width and a priority and could not say that a table is ordered by a
161 + //! column, so a described table could draw no caret and offer no reordering.
162 + //!
163 + //! What each of those deliberately leaves out is the address — what pressing a
164 + //! header calls, and where an empty state's "Add your first project" button
165 + //! goes. That is the boundary this crate is defined by, and four findings moved
166 + //! across it rather than being answered here.
167 + //!
146 168 //! # Where the description stops
147 169 //!
148 170 //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
@@ -788,7 +810,7 @@
788 810 }
789 811 }
790 812
791 - /// Whether the content of a region has arrived.
813 + /// What is in a region right now.
792 814 ///
793 815 /// The state, not the shimmer. Whether pending paints a skeleton, a spinner or
794 816 /// nothing at all is renderer policy, the same class of decision that got
@@ -796,12 +818,78 @@
796 818 /// each grew a skeleton with differently-named parts; both keep them, as the
797 819 /// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none
798 820 /// and needs none, because an immediate-mode renderer simply repaints.
821 + ///
822 + /// # Four states and not two, as of 0.12.0
823 + ///
824 + /// `703f4cd2`. It named `Ready` and `Pending` and stopped, so a described screen
825 + /// whose list came back empty had to render an empty region or invent its own
826 + /// placeholder text, and neither says what it is. goingson draws one at 27 sites
827 + /// across 12 files and Balanced Breakfast at 9, with a class family that had
828 + /// already drifted into `empty-state`, `empty-state--error`, `error-state` and
829 + /// six more.
830 + ///
831 + /// The four are one axis because they are mutually exclusive: a region shows its
832 + /// content, or a sign that it is coming, or a sign that there is none, or a sign
833 + /// that it broke. Never two. That is the test for one enum against several
834 + /// fields, and it is why this grew rather than a new member arriving beside it.
835 + ///
836 + /// # What is not here
837 + ///
838 + /// **The message.** "No projects yet" is content, and this names a state. It
839 + /// lives with whatever holds the region — in quasi's case a `Slot` — alongside
840 + /// the action that leads out of the emptiness, since an address is the one thing
841 + /// this crate never names.
842 + ///
843 + /// **How much room it gets.** goingson's `--compact`, `--dashboard` and
844 + /// `--padded` are the same state at three sizes, and a size is
845 + /// `makeover-geometry`'s question. Naming them here would be this crate stating
846 + /// values again.
847 + ///
848 + /// **The icon.** Presentation, and each host has its own answer or none.
799 849 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
850 + #[non_exhaustive]
800 851 pub enum Readiness {
801 852 /// The content is here.
802 853 Ready,
803 854 /// The content is on its way.
804 855 Pending,
856 + /// The content arrived and there is none of it.
857 + ///
858 + /// Not a failure. An empty list is the normal state of a new install, and a
859 + /// renderer that drew it in a danger tone would be reporting a fault where
860 + /// there is none.
861 + Empty,
862 + /// The content did not arrive.
863 + Failed,
864 + }
865 +
866 + impl Readiness {
867 + /// Whether the region draws its own content, or something standing in for
868 + /// it.
869 + ///
870 + /// The question every renderer asks first, so it is answered once here
871 + /// rather than by a `matches!` in each. A state added later is a stand-in
872 + /// until proven otherwise: falling back to drawing content that may not be
873 + /// there is the worse of the two mistakes.
874 + #[must_use]
875 + pub const fn shows_content(self) -> bool {
876 + matches!(self, Self::Ready)
877 + }
878 +
879 + /// What the state means, for a renderer choosing a colour.
880 + ///
881 + /// Derived rather than carried, which is the opposite of [`Meter`] and
882 + /// [`Figure`], and the difference is worth stating: a proportion's meaning
883 + /// depends on what is being counted and only the app knows it, while
884 + /// "nothing here yet" and "this broke" mean the same thing in every app that
885 + /// will ever have them.
886 + #[must_use]
887 + pub const fn tone(self) -> Tone {
888 + match self {
889 + Self::Failed => Tone::Danger,
890 + _ => Tone::Neutral,
891 + }
892 + }
805 893 }
806 894
807 895 /// How much of a set is done.
@@ -1477,6 +1565,62 @@
1477 1565 pub width: Width,
1478 1566 /// What it is worth when room runs out.
1479 1567 pub priority: Priority,
1568 + /// Whether the user can reorder the table by this column.
1569 + ///
1570 + /// `ce620871`. What reordering *calls* is not here — that is an address, and
1571 + /// this crate names none — so a host pairs this with the route the way it
1572 + /// pairs a row's parts with the row's activation. This says the affordance
1573 + /// exists, which is what a renderer needs to draw a header a user can press
1574 + /// rather than a heading they cannot.
1575 + pub sortable: bool,
1576 + /// Which way the table is ordered by this column, if it is.
1577 + ///
1578 + /// `None` on every column but the one in force. A renderer draws the caret
1579 + /// from this and a webview sets `aria-sort`, which is why it is per column
1580 + /// rather than a single fact on the table: the host idiom is a property of
1581 + /// the header cell.
1582 + ///
1583 + /// Independent of [`sortable`](Self::sortable) rather than implied by it,
1584 + /// because both combinations mean something. A column sorted and not
1585 + /// sortable is a list ordered by a key the user cannot change, which is a
1586 + /// real thing to describe and a caret worth drawing.
1587 + pub sorted: Option<Sort>,
1588 + }
1589 +
1590 + /// Which way a column is ordered.
1591 + ///
1592 + /// Two, because there is no third. "Unsorted" is [`Column::sorted`] being
1593 + /// `None`, and folding it in here would be the same absence said twice.
1594 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1595 + pub enum Sort {
1596 + /// Smallest, earliest or first alphabetically at the top.
1597 + Ascending,
1598 + /// The other way.
1599 + Descending,
1600 + }
1601 +
1602 + impl Sort {
1603 + /// The other direction, for a header that flips when pressed.
1604 + #[must_use]
1605 + pub const fn reversed(self) -> Self {
1606 + match self {
1607 + Self::Ascending => Self::Descending,
1608 + Self::Descending => Self::Ascending,
1609 + }
1610 + }
1611 +
1612 + /// What a webview writes into `aria-sort`.
1613 + ///
1614 + /// Named here rather than in the webview renderer because a terminal and an
1615 + /// immediate-mode painter both want the same two words for a caret's label,
1616 + /// and three renderers picking their own is the drift this crate ends.
1617 + #[must_use]
1618 + pub const fn as_str(self) -> &'static str {
1619 + match self {
1620 + Self::Ascending => "ascending",
1621 + Self::Descending => "descending",
1622 + }
1623 + }
1480 1624 }
1481 1625
1482 1626 impl<'a> Column<'a> {
@@ -1487,6 +1631,8 @@
1487 1631 name,
1488 1632 width: Width::Fill,
1489 1633 priority: Priority::Secondary,
1634 + sortable: false,
1635 + sorted: None,
1490 1636 }
1491 1637 }
1492 1638
@@ -1504,6 +1650,54 @@
1504 1650 mod tests {
1505 1651 use super::*;
1506 1652
1653 + #[test]
1654 + fn the_four_readiness_states_are_one_axis_and_only_one_shows_content() {
1655 + // Mutually exclusive is the test for one enum against several fields: a
1656 + // region shows its content, or that it is coming, or that there is none,
1657 + // or that it broke. Never two.
1658 + assert!(Readiness::Ready.shows_content());
1659 + for state in [Readiness::Pending, Readiness::Empty, Readiness::Failed] {
1660 + assert!(!state.shows_content());
1661 + }
1662 + }
1663 +
1664 + #[test]
1665 + fn an_empty_region_is_not_a_broken_one() {
1666 + // An empty list is the normal state of a new install. Drawing it in a
1667 + // danger tone reports a fault where there is none, and this is the one
1668 + // place the distinction is carried.
1669 + assert_eq!(Readiness::Empty.tone(), Tone::Neutral);
1670 + assert_eq!(Readiness::Failed.tone(), Tone::Danger);
1671 + assert_eq!(Readiness::Pending.tone(), Tone::Neutral);
1672 + }
1673 +
1674 + #[test]
1675 + fn a_column_can_be_sorted_without_being_sortable() {
1676 + // Both combinations mean something, which is why the two fields are
1677 + // independent rather than one implying the other. A list ordered by a
1678 + // key the user cannot change is a real thing with a caret worth drawing.
1679 + let fixed = Column {
1680 + sorted: Some(Sort::Descending),
1681 + ..Column::new("Created")
1682 + };
1683 +
1684 + assert!(!fixed.sortable);
1685 + assert_eq!(fixed.sorted.map(Sort::as_str), Some("descending"));
1686 +
1687 + let offered = Column {
1688 + sortable: true,
1689 + ..Column::new("Name")
1690 + };
1691 + assert_eq!(offered.sorted, None);
1692 + }
1693 +
1694 + #[test]
1695 + fn a_direction_flips_and_says_what_it_is() {
1696 + assert_eq!(Sort::Ascending.reversed(), Sort::Descending);
1697 + assert_eq!(Sort::Descending.reversed().reversed(), Sort::Descending);
1698 + assert_eq!(Sort::Ascending.as_str(), "ascending");
1699 + }
1700 +
1507 1701 #[test]
1508 1702 fn a_figure_carries_its_tone_because_no_renderer_can_derive_it() {
1509 1703 // Three of goingson's five sites tone the figure by their own means, so
@@ -2048,19 +2242,19 @@
2048 2242 fn columns_drop_by_priority_and_never_by_position() {
2049 2243 let cols = [
2050 2244 Column {
2051 - name: "Title",
2052 2245 width: Width::Fill,
2053 2246 priority: Priority::Essential,
2247 + ..Column::new("Title")
2054 2248 },
2055 2249 Column {
2056 - name: "Due",
2057 2250 width: Width::Fixed,
2058 2251 priority: Priority::Secondary,
2252 + ..Column::new("Due")
2059 2253 },
2060 2254 Column {
2061 - name: "Estimate",
2062 2255 width: Width::Fixed,
2063 2256 priority: Priority::Optional,
2257 + ..Column::new("Estimate")
2064 2258 },
2065 2259 ];
2066 2260
@@ -2095,18 +2289,18 @@
2095 2289 let before = [
2096 2290 Column::new("Title"),
2097 2291 Column {
2098 - name: "Estimate",
2099 2292 width: Width::Fixed,
2100 2293 priority: Priority::Optional,
2294 + ..Column::new("Estimate")
2101 2295 },
2102 2296 ];
2103 2297 let after = [
2104 2298 Column::new("Title"),
2105 2299 Column::new("Project"), // inserted
2106 2300 Column {
2107 - name: "Estimate",
2108 2301 width: Width::Fixed,
2109 2302 priority: Priority::Optional,
2303 + ..Column::new("Estimate")
2110 2304 },
2111 2305 ];
2112 2306