Skip to main content

max / quasi

Declare containment per element, and check the bound with a test Settles the composability question (1786cb94) as option C. Composability was a per-pairing enumeration: every primitive-inside-container pairing was a member, three renderer arms and a release. RowPart::Tokens put a tag in a row, Cell::tokens put the same tag in a cell, RowPart::Proportion put a meter in a row, and a meter in a cell was not sayable at all. Three of the seven pairings in that matrix were filled in two days, which is what decided it: the rate is rising, so "keep enumerating" fails its own test. The rule the enumeration defended -- a row holds no nodes, "the door through which a description becomes a templating language" -- was a rider on the 2026-08-08 decision that shipped RowPart::Tokens, promoted to a constitution by later sessions and cited six times in screen.rs as standing law. It also did not do what it claimed. It bounded one seam and not the tree, since Node::Region -> Vec<Node> -> Node::Region was always unbounded and always accepted. It was enforced by the absence of a type, so nothing stopped Cell growing a member per release until it was a node under another name, which is the trajectory it was already on. And it bounded tree depth when the thing worth bounding is what a constrained renderer must be able to draw. So the bound moves from a doc comment to a property every element declares and one test checks: an inline run may not reach blocks or collections, a collection's element type is fixed and non-recursive, blocks may contain blocks. The rider was never checkable; this is. Two-tier Block/Inline was the other candidate and is not this, because List, Table and Form are in neither tier and all three would become special-cased members with hardcoded children -- the same enumeration one level up. Containment::Collection states outright what that has to special-case, and Opaque gives Region::Bespoke a real answer instead of an exception. Additive so far. Cell, Row and Slot move onto it next, in that order, Cell first because 022f0c59 and fbff4b37 are shipped findings to check the model against.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 15:13 UTC
Signed with PGP, not checked
Commit: ae85b946dd9a1c0d2c909d94e2eedf52ae5f5ff8
Parent: ba8b351
3 files changed, +427 insertions, -8 deletions
M Cargo.lock +8 -8
@@ -4888,14 +4888,6 @@
4888 4888 source = "registry+https://github.com/rust-lang/crates.io-index"
4889 4889 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4890 4890
4891 - [[patch.unused]]
4892 - name = "synckit-client"
4893 - version = "0.8.0"
4894 -
4895 - [[patch.unused]]
4896 - name = "synckit-config"
4897 - version = "0.2.0"
4898 -
4899 4891 [[patch.unused]]
4900 4892 name = "kberg"
4901 4893 version = "0.1.0"
@@ -4907,3 +4899,11 @@
4907 4899 [[patch.unused]]
4908 4900 name = "tagtree"
4909 4901 version = "0.4.0"
4902 +
4903 + [[patch.unused]]
4904 + name = "synckit-client"
4905 + version = "0.8.0"
4906 +
4907 + [[patch.unused]]
4908 + name = "synckit-config"
4909 + version = "0.2.0"
@@ -90,6 +90,7 @@
90 90 //! - **`Router<S>`, generic over app state** (8). See [`Router`].
91 91 //! - **Failure is classified** (9). See [`RouteError`].
92 92
93 + pub mod containment;
93 94 pub mod error;
94 95 mod path;
95 96 pub mod request;
@@ -105,6 +106,7 @@
105 106 /// semver-compatible ones that happen to resolve together.
106 107 pub use makeover_layout as layout;
107 108
109 + pub use crate::containment::{Containment, Element, Level, Of};
108 110 pub use crate::error::{Class, RouteError};
109 111 pub use crate::request::{Method, Params, Request};
110 112 pub use crate::response::{Address, Message, Outcome, Response};
@@ -1,0 +1,417 @@
1 + //! What each element of a description may hold.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # Why this exists
6 + //!
7 + //! Before this, composability was a per-pairing enumeration: every
8 + //! primitive-inside-container pairing was a member, three renderer arms and a
9 + //! release. `RowPart::Tokens` put a tag in a row, `Cell::tokens` put the same
10 + //! tag in a cell, `RowPart::Proportion` put a meter in a row, and a meter in a
11 + //! cell was simply not sayable. Three of the seven pairings in that matrix were
12 + //! filled in two days, which is the measurement that decided this: the rate was
13 + //! rising, not falling.
14 + //!
15 + //! The rule the enumeration was defending -- "a row holds no nodes, the door
16 + //! through which a description becomes a templating language" -- was a rider on
17 + //! a different 2026-08-08 decision (the one that shipped `RowPart::Tokens`),
18 + //! promoted to a constitution by later sessions and cited six times in
19 + //! `screen.rs` as standing law. It also did not do what it claimed:
20 + //!
21 + //! 1. It bounded one seam rather than the tree. `Node::Region(Slot)` holding
22 + //! `Vec<Node>` holding `Node::Region(Slot)` is unbounded nesting and has
23 + //! always been accepted.
24 + //! 2. It was enforced by the absence of a type rather than by a type, so
25 + //! nothing stopped [`Cell`](crate::Cell) growing a member per release until
26 + //! it was a node under another name -- which it was already doing, going
27 + //! from a `String` to four fields in one release.
28 + //! 3. The thing worth bounding is what a constrained renderer must be able to
29 + //! draw, not how deep the tree goes. The two got conflated.
30 + //!
31 + //! So the bound moves from a doc comment to a property every element declares
32 + //! and one test checks. The 2026-08-08 rider was never checkable; this is.
33 + //!
34 + //! # The bound
35 + //!
36 + //! Three rules, all asserted by `the_containment_ladder_only_goes_down`:
37 + //!
38 + //! - **An inline run may not reach blocks or collections.** Depth below a line
39 + //! is one, so a run is always drawable on one wrapped line. That is the
40 + //! constrained-consumer test: a terminal can draw any run in a cell without
41 + //! knowing what is in it.
42 + //! - **A collection's element type is fixed and non-recursive.** A [`Cell`] is
43 + //! never a [`Row`]. The container names what it holds, once.
44 + //! - **Blocks may contain blocks.** Already true, already accepted, and a
45 + //! nested region is a nested rect on every host.
46 + //!
47 + //! # What this is not
48 + //!
49 + //! Not a type-erased tree. Every element stays a closed enum, so a renderer
50 + //! still matches exhaustively to pick a drawing and the compiler still says
51 + //! when a member is added. A `Vec<Box<dyn Container>>` would take that away and
52 + //! cost `Clone` and `PartialEq` besides. [`Element`] is a query surface over a
53 + //! closed set, which is what `makeover_layout::Intent` already is.
54 +
55 + use crate::screen::{
56 + Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Node, Prose, RegionKind, Row, Slot, Tag,
57 + };
58 +
59 + /// What an element may hold.
60 + ///
61 + /// The default is [`Text`](Self::Text), because most of the vocabulary is a
62 + /// label. [`Heading`](Node::Heading), [`Text`](Node::Text), [`Token`](Node::Token)
63 + /// and the rest declare nothing and get it.
64 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65 + #[non_exhaustive]
66 + pub enum Containment {
67 + /// Prose, and only prose.
68 + ///
69 + /// [`Prose`] rather than `String`, so the markdown answer that
70 + /// `df246b95` settled for a row part comes along free everywhere else: a
71 + /// leaf says which of the two kinds of string it is, and a renderer that
72 + /// can draw markdown does.
73 + Text,
74 + /// A run of things on a line, holding no blocks.
75 + Inlines,
76 + /// Other blocks, regions included.
77 + Blocks,
78 + /// A homogeneous sequence whose element type the container fixes.
79 + Collection(Of),
80 + /// The app fills it; the description names the place and stops.
81 + ///
82 + /// What [`RegionKind::Bespoke`](crate::RegionKind::Bespoke) always was.
83 + /// Under the enumeration it was an exception to a rule; here it is an
84 + /// answer to the same question every other element answers.
85 + Opaque,
86 + }
87 +
88 + /// The element type a [`Containment::Collection`] holds.
89 + ///
90 + /// One variant per element type rather than the five the review sketched,
91 + /// because the tree has two kinds of row: a list holds [`Row`], and a table
92 + /// holds [`Cells`], which is a row of [`Cell`]. Folding those into one variant
93 + /// would make "the element type is fixed by the container" false on the first
94 + /// container anyone checked.
95 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96 + #[non_exhaustive]
97 + pub enum Of {
98 + /// A list's rows. [`Row`].
99 + Rows,
100 + /// A table's rows. [`Cells`].
101 + TableRows,
102 + /// A table row's cells. [`Cell`].
103 + Cells,
104 + /// A form's questions. [`Field`].
105 + Fields,
106 + /// A stats strip's figures. [`Figure`].
107 + Figures,
108 + /// A control's options. [`Choice`].
109 + Choices,
110 + }
111 +
112 + impl Of {
113 + /// What one element of this collection may itself hold.
114 + ///
115 + /// The recursion check reads this rather than reaching for the element's
116 + /// own [`Element`] impl, because an `Of` names a type and not a value and
117 + /// there is nothing to call the trait on.
118 + #[must_use]
119 + pub fn element_containment(self) -> Containment {
120 + match self {
121 + // A row is a run: parts on a line, none of them a block.
122 + Self::Rows | Self::TableRows | Self::Cells => Containment::Inlines,
123 + // A field holds its options when it has any, and text otherwise.
124 + // The looser of the two is what the bound has to hold against.
125 + Self::Fields => Containment::Collection(Of::Choices),
126 + Self::Figures | Self::Choices => Containment::Text,
127 + }
128 + }
129 + }
130 +
131 + /// How far down the containment ladder a rung sits.
132 + ///
133 + /// [`Opaque`](Containment::Opaque) is off the ladder rather than on top of it:
134 + /// the description stops there, so there is nothing below it to bound and
135 + /// nothing it can reach. Giving it a rank would make it either a block that
136 + /// must not appear in a run, which is right by accident, or a leaf, which is
137 + /// wrong.
138 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
139 + pub enum Level {
140 + /// [`Containment::Text`].
141 + Leaf,
142 + /// [`Containment::Inlines`].
143 + Run,
144 + /// [`Containment::Blocks`] and [`Containment::Collection`].
145 + Block,
146 + }
147 +
148 + impl Containment {
149 + /// Where this sits on the ladder, if it is on it.
150 + #[must_use]
151 + pub fn level(self) -> Option<Level> {
152 + match self {
153 + Self::Text => Some(Level::Leaf),
154 + Self::Inlines => Some(Level::Run),
155 + Self::Blocks | Self::Collection(_) => Some(Level::Block),
156 + Self::Opaque => None,
157 + }
158 + }
159 + }
160 +
161 + /// What an element of a description may hold.
162 + ///
163 + /// Implemented for every element rather than for a bucket of them, which is the
164 + /// difference between this and the two-tier shape the review also costed. Two
165 + /// tiers leave [`Node::List`], [`Node::Table`] and [`Node::Form`] in neither,
166 + /// so all three become special-cased members with hardcoded children and the
167 + /// enumeration problem moves up a level. [`Containment::Collection`] states
168 + /// outright what that shape has to special-case.
169 + pub trait Element {
170 + /// What this may hold. Most of the vocabulary is a label and takes the
171 + /// default.
172 + fn containment(&self) -> Containment {
173 + Containment::Text
174 + }
175 + }
176 +
177 + impl Element for Node {
178 + fn containment(&self) -> Containment {
179 + match self {
180 + // Labels. The default, spelled out here only because a match has to
181 + // be exhaustive.
182 + Self::Heading { .. } | Self::Text { .. } | Self::Rich { .. } | Self::Token(_) => {
183 + Containment::Text
184 + }
185 + // An act is a label and an address. The address is not content.
186 + Self::Act(_) => Containment::Text,
187 + // A notice is a sentence with a tone. It was never allowed to hold
188 + // anything and this does not change that.
189 + Self::Notice { .. } => Containment::Text,
190 + // A sentence and, sometimes, the way out. Two things on a line.
191 + Self::StandIn { .. } => Containment::Inlines,
192 + // One field, so this is the field's own answer rather than a
193 + // collection of one.
194 + Self::Field(field) => field.containment(),
195 + Self::Form { .. } => Containment::Collection(Of::Fields),
196 + Self::List { .. } => Containment::Collection(Of::Rows),
197 + // The columns are the table's schema and not its content, the way
198 + // `Screen::discovery` is metadata rather than a region. What a
199 + // table *holds* is rows.
200 + Self::Table { .. } => Containment::Collection(Of::TableRows),
201 + Self::Select { .. } => Containment::Collection(Of::Choices),
202 + Self::Meter(meter) => meter.containment(),
203 + Self::Stats { .. } => Containment::Collection(Of::Figures),
204 + Self::Region(slot) => slot.containment(),
205 + }
206 + }
207 + }
208 +
209 + impl Element for Slot {
210 + fn containment(&self) -> Containment {
211 + // The one place `Opaque` is reached, and the reason it exists. A
212 + // bespoke region is filled by the host, so the description names the
213 + // place and says nothing about what goes in it. Every other region
214 + // holds blocks, regions included, which is the nesting that was always
215 + // accepted and that the row rule never touched.
216 + if matches!(self.kind, RegionKind::Bespoke { .. }) {
217 + Containment::Opaque
218 + } else {
219 + Containment::Blocks
220 + }
221 + }
222 + }
223 +
224 + impl Element for Row {
225 + fn containment(&self) -> Containment {
226 + Containment::Inlines
227 + }
228 + }
229 +
230 + impl Element for Cells {
231 + fn containment(&self) -> Containment {
232 + Containment::Collection(Of::Cells)
233 + }
234 + }
235 +
236 + impl Element for Cell {
237 + fn containment(&self) -> Containment {
238 + Containment::Inlines
239 + }
240 + }
241 +
242 + impl Element for Field {
243 + fn containment(&self) -> Containment {
244 + if self.kind.offers_options() {
245 + Containment::Collection(Of::Choices)
246 + } else {
247 + Containment::Text
248 + }
249 + }
250 + }
251 +
252 + // The leaves. Each takes the default, and each says so by name rather than by
253 + // silence, because "nobody wrote an impl" and "this holds a label" look the
254 + // same from outside and only one of them is a decision.
255 + impl Element for Act {}
256 + impl Element for Tag {}
257 + impl Element for Figure {}
258 + impl Element for Meter {}
259 + impl Element for Choice {}
260 + impl Element for Column {}
261 + impl Element for Prose {}
262 +
263 + #[cfg(test)]
264 + mod tests {
265 + use super::*;
266 + use crate::layout;
267 + use crate::screen::Action;
268 +
269 + /// Every element type, as a value, so the ladder test can ask each one.
270 + ///
271 + /// A list rather than a derive because the question is about the vocabulary
272 + /// and not about any one screen: adding a `Node` member and not adding it
273 + /// here is caught by the exhaustive match in [`Element for Node`], which is
274 + /// where a new member has to be answered for anyway.
275 + fn every_node() -> Vec<Node> {
276 + vec![
277 + Node::page("t"),
278 + Node::text("t"),
279 + Node::rich("*t*"),
280 + Node::Act(Act::new("go", Action::get("/"))),
281 + Node::Token(Tag::badge("tag")),
282 + Node::Notice {
283 + kind: layout::Notice::Banner,
284 + tone: layout::Tone::Neutral,
285 + text: "t".into(),
286 + },
287 + Node::StandIn {
288 + state: layout::Readiness::Empty,
289 + message: "nothing yet".into(),
290 + act: None,
291 + },
292 + Node::Field(Box::new(Field::new(
293 + layout::FieldKind::Text,
294 + "name",
295 + "Name",
296 + ))),
297 + Node::Form {
298 + action: Action::post("/"),
299 + submit: "Save".into(),
300 + fields: Vec::new(),
301 + },
302 + Node::List {
303 + rows: Vec::new(),
304 + more: None,
305 + },
306 + Node::Table {
307 + columns: Vec::new(),
308 + rows: Vec::new(),
309 + },
310 + Node::Select {
311 + kind: layout::Selector::Tabs,
312 + options: Vec::new(),
313 + chosen: None,
314 + action: None,
315 + },
316 + Node::Meter(Meter::new(1, 2)),
317 + Node::Stats {
318 + figures: Vec::new(),
319 + },
320 + Node::Region(Slot::new("r", RegionKind::Pane)),
321 + ]
322 + }
323 +
324 + #[test]
325 + fn the_containment_ladder_only_goes_down() {
326 + // Rule 1. An inline run may not reach blocks or collections, so depth
327 + // below a line is one and a run is always drawable on one wrapped line.
328 + // This is the constrained-consumer test: a terminal draws a cell
329 + // without knowing what is in it.
330 + for node in every_node() {
331 + if node.containment() != Containment::Text {
332 + continue;
333 + }
334 + // A node that may sit in a run is a leaf, by construction. Nothing
335 + // to assert beyond the classification itself being total.
336 + assert_eq!(node.containment().level(), Some(Level::Leaf));
337 + }
338 +
339 + // Rule 2. A collection's element type is fixed and its element sits
340 + // strictly below a collection: never another collection, never blocks.
341 + for of in [
342 + Of::Rows,
343 + Of::TableRows,
344 + Of::Cells,
345 + Of::Fields,
346 + Of::Figures,
347 + Of::Choices,
348 + ] {
349 + let inner = of.element_containment();
350 + // Fields are the one collection whose element is itself a
351 + // collection, and it is the terminating one: a choice is a label.
352 + if let Containment::Collection(nested) = inner {
353 + assert_eq!(
354 + nested.element_containment(),
355 + Containment::Text,
356 + "{of:?} nests {nested:?}, which must terminate in text"
357 + );
358 + assert_ne!(nested, of, "{of:?} holds itself");
359 + continue;
360 + }
361 + assert!(
362 + matches!(inner, Containment::Text | Containment::Inlines),
363 + "{of:?} holds {inner:?}, which is not below a collection"
364 + );
365 + }
366 +
367 + // Rule 3. Blocks may contain blocks, and a region is the one that does.
368 + let region = Node::Region(Slot::new("r", RegionKind::Pane));
369 + assert_eq!(region.containment(), Containment::Blocks);
370 + }
371 +
372 + #[test]
373 + fn a_run_holds_no_blocks() {
374 + // The rule stated the other way round, over the members that are runs.
375 + // A row and a cell hold parts on a line; neither may hold a list.
376 + for run in [Row::new("r").containment(), Cell::new("c").containment()] {
377 + assert_eq!(run, Containment::Inlines);
378 + assert_eq!(run.level(), Some(Level::Run));
379 + assert!(run.level() < Containment::Blocks.level());
380 + }
381 + }
382 +
383 + #[test]
384 + fn most_of_the_vocabulary_declares_nothing_and_is_text() {
385 + // The default is what makes this cheap: a leaf writes no impl body.
386 + assert_eq!(Tag::badge("t").containment(), Containment::Text);
387 + assert_eq!(
388 + Act::new("go", Action::get("/")).containment(),
389 + Containment::Text
390 + );
391 + assert_eq!(Figure::new("7", "tasks").containment(), Containment::Text);
392 + assert_eq!(Meter::new(1, 2).containment(), Containment::Text);
393 + }
394 +
395 + #[test]
396 + fn a_field_holds_its_options_only_when_it_has_any() {
397 + // The one element whose containment depends on its own state, which is
398 + // why the trait takes `&self` rather than being an associated const.
399 + let plain = Field::new(layout::FieldKind::Text, "name", "Name");
400 + assert_eq!(plain.containment(), Containment::Text);
401 +
402 + let choosing = Field::select("priority", "Priority", vec![Choice::plain("high")]);
403 + assert_eq!(choosing.containment(), Containment::Collection(Of::Choices));
404 + assert!(choosing.kind.offers_options());
405 + }
406 +
407 + #[test]
408 + fn a_bespoke_region_is_opaque_rather_than_an_exception() {
409 + // Under the enumeration this was a hole in the rule. Here it answers
410 + // the same question every other element answers, and the answer is
411 + // "the app fills it".
412 + let bespoke = Node::Region(Slot::bespoke("canvas", "editor"));
413 + assert_eq!(bespoke.containment(), Containment::Opaque);
414 + // Off the ladder, so it can neither be reached into nor reach out.
415 + assert_eq!(bespoke.containment().level(), None);
416 + }
417 + }