Skip to main content

max / makeover-layout

0.43.0: split Bespoke into Handover and Ceded One opaque member could not say the difference between a region the app had ruled undescribable and one nobody had converted yet. Both drew as a silently empty box on the two renderers that answer no fill, so a reader could not tell a finished screen from an unfinished one and neither could we. `Handover { name }` is a fill owed in every host's currency; a renderer without one is looking at a hole and should say so. `Ceded { name }` is a fill no host is owed: a chart, a waveform, domain data with marks painted over it. Silence is the correct drawing there, which is why this is two members rather than a flag -- the two want opposite behaviour from a renderer that cannot fill them, and one name cannot carry both. `owed()` sits beside `described()` and is the one bit the renderers read. A method rather than a `matches!` at each renderer, for `described`'s reason: three renderers writing the same match is three chances to disagree about what an empty region means.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 22:05 UTC
Signed with PGP, not checked
Commit: 5b85611a0acdec8dc5ca87c2c087c3002fb27620
Parent: f2059d0
2 files changed, +103 insertions, -27 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.42.0"
3 + version = "0.43.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +102 -26
@@ -45,8 +45,8 @@
45 45 //!
46 46 //! - **Generic against bespoke.** Is this furniture any app would have, or is
47 47 //! it this app's own? A rule that withholds a word until a second app has
48 - //! duplicated the code guarantees the duplication. Bespoke keeps
49 - //! [`Region::Bespoke`].
48 + //! duplicated the code guarantees the duplication. What the app owns
49 + //! keeps [`Region::Handover`] and [`Region::Ceded`].
50 50 //! - **Every host has an honest answer.** A member no renderer can express
51 51 //! without borrowing one host's idiom is not a description.
52 52 //! - **It can be laid out before it is filled.** See *First paint is final
@@ -201,7 +201,8 @@
201 201 //! columns, weeks as rows, blanks for the offset. If a screen wants one,
202 202 //! measure the members it needs before adding any.
203 203 //!
204 - //! [`Region::Bespoke`] remains for the genuinely app-owned. The description
204 + //! [`Region::Handover`] and [`Region::Ceded`] remain for the genuinely
205 + //! app-owned. The description
205 206 //! names the *place* and the app owns the contents, so a screen containing a
206 207 //! timeline is still a whole screen and still routable. Without it, the screens
207 208 //! that make an app worth using would need a second, undescribed path beside
@@ -1899,7 +1900,8 @@
1899 1900 /// The thing `makeover-geometry` deliberately does not name: it names the space
1900 1901 /// *between* things by relationship, and nothing named the things. Six named
1901 1902 /// members, taken from what the two webview apps actually use, plus
1902 - /// [`Region::Bespoke`] for the parts no description should reach. Both apps'
1903 + /// [`Region::Handover`] and [`Region::Ceded`] for the parts no description
1904 + /// should reach. Both apps'
1903 1905 /// `layout.css` currently names exactly two things, `.raised` and `.well`, so
1904 1906 /// this layer is absent rather than divergent, which makes it the cheapest of
1905 1907 /// the schemas to add and the easiest to over-build.
@@ -1955,7 +1957,7 @@
1955 1957 /// body, the way it already was. A group of related toggles with no heading
1956 1958 /// is a real thing and a mandatory slot would forbid it.
1957 1959 ///
1958 - /// **A depth.** [`Depth::Flat`], on [`Bespoke`](Self::Bespoke)'s reasoning:
1960 + /// **A depth.** [`Depth::Flat`], on [`Handover`](Self::Handover)'s reasoning:
1959 1961 /// it inherits, and an app that wants its group in a well puts it in a
1960 1962 /// [`Pane`](Self::Pane), which composes rather than adding a knob here.
1961 1963 ///
@@ -2008,8 +2010,8 @@
2008 2010 TabGroup,
2009 2011 /// Content over a scrim, taking input until dismissed.
2010 2012 Modal,
2011 - /// A region this crate names the *place* of and nothing else. The app owns
2012 - /// what goes in it.
2013 + /// A region this crate names the *place* of, whose contents the app still
2014 + /// owes every host.
2013 2015 ///
2014 2016 /// The escape hatch, and the thing that keeps the description honest about
2015 2017 /// its own limits. A day-plan timeline, a kanban board, a calendar and the
@@ -2026,13 +2028,46 @@
2026 2028 /// So the description says "a thing called `day-plan` goes here" and stops.
2027 2029 /// The name is opaque: this crate never interprets it, and no renderer is
2028 2030 /// expected to know what it means beyond handing the space over.
2029 - Bespoke {
2031 + ///
2032 + /// # What separates it from [`Ceded`](Self::Ceded)
2033 + ///
2034 + /// **A fill is owed here in every host's currency.** A renderer handed one
2035 + /// of these and given nothing to put in it is looking at a hole the app
2036 + /// meant to fill, and saying so is the honest drawing. [`owed`](Self::owed)
2037 + /// is how it asks.
2038 + ///
2039 + /// That is the whole of the split. Before it there was one opaque member,
2040 + /// so a region the description had given up on and a region nobody had
2041 + /// converted yet were the same value, and both drew as a silently empty
2042 + /// box on the two renderers that answer no fill.
2043 + Handover {
2044 + /// What the app calls it. Never interpreted here.
2045 + name: &'a str,
2046 + },
2047 + /// A region this crate names the place of, whose contents no host is owed.
2048 + ///
2049 + /// The other half of the old single opaque member. The app has decided this
2050 + /// space is not the description's to fill and is not going to become so:
2051 + /// a chart, a waveform, a rendered picture of domain data with marks
2052 + /// painted over it at positions no description knows.
2053 + ///
2054 + /// **Silence is the correct drawing.** A renderer with no fill for this
2055 + /// draws nothing and is right to; unlike [`Handover`](Self::Handover) there
2056 + /// is nothing missing. That is what makes the pair worth two members rather
2057 + /// than a flag: the two want opposite behaviour from a renderer that cannot
2058 + /// fill them, and one name cannot carry both.
2059 + ///
2060 + /// The measured sites are MNW's analytics charts, which already carry the
2061 + /// ruling that a bar chart is not describable and should not be, and
2062 + /// audiofiles' waveform, whose exclusion had no vocabulary to live in and
2063 + /// was recorded in a doc comment instead.
2064 + Ceded {
2030 2065 /// What the app calls it. Never interpreted here.
2031 2066 name: &'a str,
2032 2067 },
2033 2068 /// A named assembly of things the vocabulary already says.
2034 2069 ///
2035 - /// The third tier, between a primitive and [`Bespoke`](Self::Bespoke).
2070 + /// The third tier, between a primitive and the two opaque members.
2036 2071 ///
2037 2072 /// # What separates it from the two members either side
2038 2073 ///
@@ -2041,7 +2076,7 @@
2041 2076 /// that test — a terminal has no carousel — which is the same refusal
2042 2077 /// `Node::Html` got and is why the carousel sat unsayable for months.
2043 2078 ///
2044 - /// [`Bespoke`](Self::Bespoke) fails it from the other side. Bespoke is for
2079 + /// [`Handover`](Self::Handover) fails it from the other side. It is for
2045 2080 /// what one app owns and nobody will build twice, and it carries *no*
2046 2081 /// contents: the description names the place and stops. A carousel is
2047 2082 /// furniture any app would have, and every part of it — an ordered set of
@@ -2063,7 +2098,7 @@
2063 2098 /// knowing every widget. An unrecognised widget degrades to its assembly
2064 2099 /// instead of failing, so a second or third party can name one without
2065 2100 /// three renderers releasing in lockstep to accept it. Contrast
2066 - /// [`Bespoke`](Self::Bespoke), which no renderer can degrade: there is
2101 + /// [`Handover`](Self::Handover), which no renderer can degrade: there is
2067 2102 /// nothing under it to fall back to.
2068 2103 ///
2069 2104 /// # What it does not do
@@ -2108,7 +2143,7 @@
2108 2143 // depth set here would be this crate deciding that a carousel is
2109 2144 // raised on every host, which is the kind of value the deferral
2110 2145 // rule exists to refuse.
2111 - Self::Bespoke { .. } | Self::Widget { .. } => Depth::Flat,
2146 + Self::Handover { .. } | Self::Ceded { .. } | Self::Widget { .. } => Depth::Flat,
2112 2147 }
2113 2148 }
2114 2149
@@ -2120,27 +2155,44 @@
2120 2155 /// is exactly one opaque member and there should stay exactly one.
2121 2156 ///
2122 2157 /// [`Widget`](Self::Widget) is described, and that is the whole of what
2123 - /// separates it from [`Bespoke`](Self::Bespoke) here. Both carry a name
2124 - /// this crate never interprets; only one of them carries contents under it.
2158 + /// separates it from the two opaque members here. All three carry a name
2159 + /// this crate never interprets; only the widget carries contents under it.
2125 2160 /// A renderer that does not recognise a widget's name still walks its body,
2126 2161 /// so there is nothing for it to hand over and nothing it cannot draw.
2127 2162 #[must_use]
2128 2163 pub const fn described(self) -> bool {
2129 - !matches!(self, Self::Bespoke { .. })
2164 + !matches!(self, Self::Handover { .. } | Self::Ceded { .. })
2165 + }
2166 +
2167 + /// Whether a fill is owed here, for a renderer that has none.
2168 + ///
2169 + /// The question the single opaque member could not answer. True for
2170 + /// [`Handover`](Self::Handover): the app meant to fill this and a renderer
2171 + /// with nothing to put in it should say so. False for everything else,
2172 + /// [`Ceded`](Self::Ceded) included, where silence is the correct drawing
2173 + /// because nothing is missing.
2174 + ///
2175 + /// A method rather than a `matches!` at each renderer, for
2176 + /// [`described`](Self::described)'s reason: three renderers writing the
2177 + /// same match is three chances to disagree about what an empty region
2178 + /// means.
2179 + #[must_use]
2180 + pub const fn owed(self) -> bool {
2181 + matches!(self, Self::Handover { .. })
2130 2182 }
2131 2183
2132 2184 /// The name an app gave this region, if it gave one.
2133 2185 ///
2134 - /// [`Bespoke`](Self::Bespoke) and [`Widget`](Self::Widget) are the two
2135 - /// members that carry a name, for two different purposes: one says what the
2136 - /// app will fill the space with, the other says what the assembly under it
2137 - /// is called. A renderer dispatching on either wants the string without
2186 + /// [`Handover`](Self::Handover), [`Ceded`](Self::Ceded) and
2187 + /// [`Widget`](Self::Widget) are the members that carry a name, for two
2188 + /// different purposes: the first two say what the app puts in the space,
2189 + /// the third says what the assembly under it is called. A renderer dispatching on either wants the string without
2138 2190 /// caring which member it came from, and writing that `matches!` at each
2139 2191 /// renderer is how the two drift apart.
2140 2192 #[must_use]
2141 2193 pub const fn name(self) -> Option<&'a str> {
2142 2194 match self {
2143 - Self::Bespoke { name } | Self::Widget { name } => Some(name),
2195 + Self::Handover { name } | Self::Ceded { name } | Self::Widget { name } => Some(name),
2144 2196 // Spelled out rather than a wildcard, so a member added later has
2145 2197 // to answer whether it carries a name instead of inheriting `None`
2146 2198 // by sitting under a `_`.
@@ -5852,7 +5904,7 @@
5852 5904 // renderer that does not lay them across still draws every column.
5853 5905 // Stacking them vertically is honouring this member, not degrading it.
5854 5906 assert!(Region::Columns.described());
5855 - assert!(!Region::Bespoke { name: "timeline" }.described());
5907 + assert!(!Region::Handover { name: "timeline" }.described());
5856 5908 }
5857 5909
5858 5910 #[test]
@@ -6003,7 +6055,31 @@
6003 6055 ] {
6004 6056 assert!(r.described(), "{r:?} should be describable");
6005 6057 }
6006 - assert!(!Region::Bespoke { name: "day-plan" }.described());
6058 + assert!(!Region::Handover { name: "day-plan" }.described());
6059 + }
6060 +
6061 + #[test]
6062 + fn a_region_nobody_converted_yet_is_not_a_region_the_app_gave_up_on() {
6063 + // The whole of why this is two members. Both are opaque and neither is
6064 + // described, so the old single member made them one value; a renderer
6065 + // with no fill drew both as an empty box and said nothing either way.
6066 + assert!(Region::Handover { name: "day-plan" }.owed());
6067 + assert!(
6068 + !Region::Ceded {
6069 + name: "revenue-chart"
6070 + }
6071 + .owed()
6072 + );
6073 +
6074 + // Everything that carries its own contents owes nothing, which is the
6075 + // reading a renderer needs for the members it already draws.
6076 + assert!(!Region::Pane.owed());
6077 + assert!(!Region::Widget { name: "carousel" }.owed());
6078 +
6079 + // Opaqueness is the axis they still share.
6080 + assert!(!Region::Ceded { name: "waveform" }.described());
6081 + assert_eq!(Region::Ceded { name: "waveform" }.name(), Some("waveform"));
6082 + assert_eq!(Region::Ceded { name: "waveform" }.depth(), Depth::Flat);
6007 6083 }
6008 6084
6009 6085 #[test]
@@ -6047,7 +6123,7 @@
6047 6123 // Writing that `matches!` at each renderer is how the two drift apart.
6048 6124 assert_eq!(Region::Widget { name: "carousel" }.name(), Some("carousel"));
6049 6125 assert_eq!(
6050 - Region::Bespoke { name: "day-plan" }.name(),
6126 + Region::Handover { name: "day-plan" }.name(),
6051 6127 Some("day-plan")
6052 6128 );
6053 6129
@@ -6068,8 +6144,8 @@
6068 6144 fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() {
6069 6145 // The app owns the contents, not the placement. An app that wants its
6070 6146 // timeline in a well frames it in a Pane.
6071 - assert_eq!(Region::Bespoke { name: "day-plan" }.depth(), Depth::Flat);
6072 - assert_eq!(Region::Bespoke { name: "kanban" }.depth(), Depth::Flat);
6147 + assert_eq!(Region::Handover { name: "day-plan" }.depth(), Depth::Flat);
6148 + assert_eq!(Region::Handover { name: "kanban" }.depth(), Depth::Flat);
6073 6149 }
6074 6150
6075 6151 #[test]
@@ -6079,7 +6155,7 @@
6079 6155 // interesting four need a second path beside the router.
6080 6156 let day_plan = [
6081 6157 Region::Band,
6082 - Region::Bespoke { name: "day-plan" },
6158 + Region::Handover { name: "day-plan" },
6083 6159 Region::Sidebar,
6084 6160 ];
6085 6161 assert_eq!(day_plan.iter().filter(|r| r.described()).count(), 2);