Skip to main content

max / quasi

A row is an inline run, and a slot needed no bound Row's six content members become a run of parts, each carrying the role it draws under. `primary`, `secondary` and `meta` are three text roles and kind cannot tell them apart, so a row's run entry is a role plus a node where a cell's is a bare node. The builders are unchanged, which is what kept 166 call sites still: `new`, `secondary`, `meta`, `token`, `act` and `meter` all mean what they meant and build the run underneath. `activate`, `current`, `selected`, `menu` and `toggle` stay fields, being facts about the row rather than content in it. `Row::primary` becomes a method, and it is the one cost the review flagged: a row's primary is no longer guaranteed to be one string. A link in a row and a figure in a row arrive with it, having each been a RowPart variant, a member, a renderer arm and a makeover-layout release away. The order is the description's now, where the old members drew in a fixed sequence however they were built, so a row wanting a tag between two facts gets one. Slot needed no call-site bound and gets none. The ladder forbids reaching up, and a region holding a leaf is going down; there is no violation for `Slot::with` to catch. Reading the renderer against the model did turn up one thing the model had wrong: `Opaque` claimed a bespoke region holds nothing, where the renderer deliberately draws the blocks the description put there and then the host's fill. The claim is about what the description can know, not about emptiness, and it says so now. Closes 1786cb94.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 15:46 UTC
Signed with PGP, not checked
Commit: c80e4734735a0ff036e77fc9064efe6fecf05fdf
Parent: 979f83f
6 files changed, +392 insertions, -165 deletions
@@ -77,11 +77,22 @@
77 77 Blocks,
78 78 /// A homogeneous sequence whose element type the container fixes.
79 79 Collection(Of),
80 - /// The app fills it; the description names the place and stops.
80 + /// The app fills it, so what is finally there is not the description's to
81 + /// know.
81 82 ///
82 83 /// What [`RegionKind::Bespoke`](crate::RegionKind::Bespoke) always was.
83 84 /// Under the enumeration it was an exception to a rule; here it is an
84 85 /// answer to the same question every other element answers.
86 + ///
87 + /// Not the same as holding nothing, which is what this said until `Slot`
88 + /// was migrated and the renderer was read against it: a bespoke region
89 + /// draws the blocks the description put in it and *then* the host's fill,
90 + /// which is a deliberate arrangement -- a heading the description owns
91 + /// above a canvas it does not. So the claim is about knowledge rather than
92 + /// about emptiness. A renderer cannot answer "what is in this region" from
93 + /// the description alone, which is the property every consumer of this
94 + /// actually needs, and it is why the answer is off the ladder rather than
95 + /// on top of it.
85 96 Opaque,
86 97 }
87 98
@@ -214,10 +225,11 @@
214 225 impl Element for Slot {
215 226 fn containment(&self) -> Containment {
216 227 // The one place `Opaque` is reached, and the reason it exists. A
217 - // bespoke region is filled by the host, so the description names the
218 - // place and says nothing about what goes in it. Every other region
219 - // holds blocks, regions included, which is the nesting that was always
220 - // accepted and that the row rule never touched.
228 + // bespoke region is filled by the host, so what is finally in it is not
229 + // answerable from the description -- not that the description put
230 + // nothing there, which is a different claim and the wrong one. Every
231 + // other region holds blocks, regions included, which is the nesting
232 + // that was always accepted and that the row rule never touched.
221 233 if matches!(self.kind, RegionKind::Bespoke { .. }) {
222 234 Containment::Opaque
223 235 } else {
@@ -377,7 +389,9 @@
377 389 #[test]
378 390 fn a_run_holds_no_blocks() {
379 391 // The rule stated the other way round, over the members that are runs.
380 - // A row and a cell hold parts on a line; neither may hold a list.
392 + // A row and a cell hold parts on a line; neither may hold a list. Both
393 + // are runs in fact and not only by declaration since `Row` moved:
394 + // `Row::part` and `Cell::part` assert this same answer at a call site.
381 395 for run in [Row::new("r").containment(), Cell::new("c").containment()] {
382 396 assert_eq!(run, Containment::Inlines);
383 397 assert_eq!(run.level(), Some(Level::Run));
@@ -409,6 +423,24 @@
409 423 assert!(choosing.kind.offers_options());
410 424 }
411 425
426 + #[test]
427 + fn a_region_holds_leaves_as_well_as_blocks() {
428 + // Going down the ladder is what containment is for, so a block holding
429 + // a leaf needs no permission and gets no assertion. `Cell::part` and
430 + // `Row::part` guard the one direction that has to be guarded, which is
431 + // a run reaching up at a block; there is no upward violation a slot can
432 + // commit, so `Slot::with` checks nothing.
433 + let pane = Slot::new("facts", RegionKind::Pane)
434 + .with(Node::section("Facts"))
435 + .with(Node::text("Two files"))
436 + .with(Node::Token(Tag::badge("beta")));
437 +
438 + assert_eq!(pane.containment(), Containment::Blocks);
439 + for node in &pane.body {
440 + assert!(node.containment().level() <= pane.containment().level());
441 + }
442 + }
443 +
412 444 #[test]
413 445 fn a_bespoke_region_is_opaque_rather_than_an_exception() {
414 446 // Under the enumeration this was a hole in the rule. Here it answers
@@ -113,7 +113,7 @@
113 113 pub use crate::router::{Handler, Router};
114 114 pub use crate::screen::{
115 115 Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node,
116 - Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag,
116 + Part, Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag,
117 117 };
118 118
119 119 #[cfg(test)]
@@ -1064,6 +1064,28 @@
1064 1064 /// [`Node::StandIn`].
1065 1065 pub readiness: layout::Readiness,
1066 1066 /// What is in it.
1067 + ///
1068 + /// Blocks, regions included, which is the nesting that was always accepted:
1069 + /// a region inside a region is a nested rect on every host. Leaves are
1070 + /// admitted too, and deliberately -- a fact under a heading is a
1071 + /// [`Node::Text`] straight in a pane, and it is the commonest thing in the
1072 + /// tree.
1073 + ///
1074 + /// # Why there is no bound here
1075 + ///
1076 + /// [`Cell::part`] and [`Row::part`] assert that what they are handed is a
1077 + /// leaf, and this does not, which looks like an oversight and is the model
1078 + /// working. The ladder forbids reaching *up*: a run may not hold a block,
1079 + /// because a run has to be drawable on one wrapped line. A block holding a
1080 + /// leaf is going down, and going down is what containment is for. There is
1081 + /// no upward violation for [`with`](Self::with) to catch, so an assertion
1082 + /// here would be a runtime check that can never fire.
1083 + ///
1084 + /// A region whose whole content is one badge is the case that made this
1085 + /// look like a question. It is describable, and it should be: a status pane
1086 + /// is a real screen. Whether it is a *good* screen is a judgement about
1087 + /// that screen rather than a property of the vocabulary, and the bound is
1088 + /// not the place to hold opinions about taste.
1067 1089 pub body: Vec<Node>,
1068 1090 }
1069 1091
@@ -1238,36 +1260,69 @@
1238 1260 }
1239 1261 }
1240 1262
1263 + /// One part of a row's run, and the role it takes.
1264 + ///
1265 + /// A cell's run entries carry no role because their kind already says which
1266 + /// part they are: text is the value, a [`Node::Link`] is the link, a
1267 + /// [`Node::Token`] is a chip, a [`Node::Act`] is a control. A row's
1268 + /// `primary`, `secondary` and `meta` are three *text* roles, and kind cannot
1269 + /// tell those apart, so a row says which one it means.
1270 + ///
1271 + /// The role is a style role and nothing else. [`layout::RowPart`] is unchanged
1272 + /// by the containment model: it says how a part is drawn, not what may sit in
1273 + /// it, and that is the half of it worth keeping.
1274 + #[derive(Debug, Clone, PartialEq, Eq)]
1275 + pub struct Part {
1276 + /// Which of the row's roles this part takes.
1277 + pub role: layout::RowPart,
1278 + /// What is in it. A leaf, since a row is an inline run.
1279 + pub node: Node,
1280 + }
1281 +
1241 1282 /// One row of a list.
1242 1283 ///
1243 - /// The four parts are [`layout::RowPart`]'s four, which is where they came
1244 - /// from: a row that needed a fifth would be a table.
1284 + /// # The run
1285 + ///
1286 + /// A row's content is an inline run of [`Part`]s, in the order the description
1287 + /// says them, the same way a [`Cell`]'s is. It was six members before
1288 + /// `1786cb94` -- `primary`, `secondary`, `meta`, `tokens`, `actions`, `meter`
1289 + /// -- each of which arrived as a counted-sites argument, a member here, a
1290 + /// [`layout::RowPart`] variant and a release: `RowPart::Tokens` at
1291 + /// makeover-layout 0.9.0 for a badge in a row, `RowPart::Proportion` at 0.11.0
1292 + /// for a bar in one. A link in a row was simply not sayable, and a figure in
1293 + /// one was not either. Under the run both are already sayable and cost nothing.
1294 + ///
1295 + /// The bound is that every part is a leaf, so a row is drawable on one wrapped
1296 + /// line without a renderer knowing what is in it. [`Row::part`] is where that
1297 + /// bites at a call site.
1298 + ///
1299 + /// Order is the description's. The old members were drawn in a fixed sequence
1300 + /// whatever order they were built in, so a row that wanted a tag between two
1301 + /// facts got the tag hoisted to the end; now it draws where it was put.
1302 + ///
1303 + /// # What stayed a field
1304 + ///
1305 + /// [`activate`](Self::activate), [`current`](Self::current),
1306 + /// [`selected`](Self::selected), [`menu`](Self::menu) and
1307 + /// [`toggle`](Self::toggle) are facts *about* the row rather than content in
1308 + /// it. A run of things on a line is not where "this row is the one the detail
1309 + /// pane is showing" belongs.
1310 + ///
1311 + /// # The cost
1312 + ///
1313 + /// [`primary()`](Self::primary) is no longer guaranteed to be one string, which
1314 + /// is what let a constrained renderer right-align a row cheaply. It answers the
1315 + /// text of the primary parts joined, and a row built the ordinary way still has
1316 + /// exactly one.
1245 1317 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1246 1318 pub struct Row {
1247 - /// The thing itself. What the row is called.
1248 - pub primary: String,
1249 - /// Supporting text under the primary, plain or markdown.
1319 + /// What is in the row, in order.
1250 1320 ///
1251 - /// [`Prose`] rather than a `String` since the row-prose decision: the part
1252 - /// is still a string and still not a node, and it now says which of the two
1253 - /// kinds of string it is.
1254 - pub secondary: Option<Prose>,
1255 - /// A short trailing fact: a count, a size, a date.
1256 - pub meta: Option<String>,
1257 - /// Small labelled things belonging to the row: badges, chips, tags.
1258 - ///
1259 - /// Against `makeover-layout`'s `RowPart::Tokens`, which arrived at 0.9.0 for
1260 - /// this. Before it, a row that carried two trailing facts had to join them
1261 - /// into [`meta`](Self::meta) as text, which kept both and lost what the
1262 - /// second one was: a toned status read as prose rather than as colour.
1263 - ///
1264 - /// [`meta`](Self::meta) is still the right place for a plain fact. The line
1265 - /// is whether the thing has its own standing — a tone of its own, or a click
1266 - /// to answer. "3 files" is meta; an amber status and a clickable tag are
1267 - /// tokens.
1268 - pub tokens: Vec<Tag>,
1269 - /// Controls that act on this row.
1270 - pub actions: Vec<Act>,
1321 + /// Built by the same constructors that named the old members:
1322 + /// [`Row::new`], [`secondary`](Row::secondary), [`meta`](Row::meta),
1323 + /// [`token`](Row::token), [`act`](Row::act) and [`meter`](Row::meter) all
1324 + /// still mean what they meant, so no builder call site moved.
1325 + pub parts: Vec<Part>,
1271 1326 /// The route that selects this row, if selecting it does anything.
1272 1327 pub activate: Option<Action>,
1273 1328 /// Whether this is the row the detail side is currently showing.
@@ -1292,23 +1347,10 @@
1292 1347 /// [`current`](Self::current). goingson's contacts and tasks screens both
1293 1348 /// drive bulk actions from it.
1294 1349 pub selected: Option<bool>,
1295 - /// How much of a set this row's thing has done.
1296 - ///
1297 - /// `da5666ae`, and [`RowPart::Proportion`](layout::RowPart::Proportion) is
1298 - /// the part it renders into. [`layout::Meter`] arrived at 0.10.0 and closed
1299 - /// two of the seven sites that wanted it; the other five sit in rows, and a
1300 - /// row holds no nodes by the 2026-08-08 ruling — the door through which a
1301 - /// description becomes a templating language. So this carries the
1302 - /// *description of a bar* rather than a node, exactly as
1303 - /// [`tokens`](Self::tokens) carries tags rather than nodes.
1304 - ///
1305 - /// Owned rather than borrowed for the reason everything here is. Without it
1306 - /// a row flattens the proportion into [`meta`](Self::meta) as "3/7
1307 - /// subtasks", keeping both numbers and losing the reading.
1308 - pub meter: Option<Meter>,
1309 1350 /// Everything else that can be done to this row.
1310 1351 ///
1311 - /// `5e02fbce`. [`actions`](Self::actions) is what the row shows; this is
1352 + /// `5e02fbce`. The [`Actions`](layout::RowPart::Actions) parts of the run
1353 + /// are what the row shows; this is
1312 1354 /// what it *offers*, reached by right-click on a pointer host, long-press on
1313 1355 /// a touch one, and a key in a terminal. That split is the whole reason it
1314 1356 /// belongs in the description rather than in a renderer: one description has
@@ -1319,9 +1361,9 @@
1319 1361 /// 680 lines of generic menu machinery between `components.js` and
1320 1362 /// `context-menus.js`.
1321 1363 ///
1322 - /// A `Vec<Act>` rather than a new part, because that is what a menu is: the
1323 - /// acts already carry their own tone, state, confirmation and key. The
1324 - /// 2026-08-08 row ruling is untouched — these are acts, not nodes.
1364 + /// A field rather than a role in the run, because a menu is not on the
1365 + /// line. The run is what the row draws; this is what it holds back until
1366 + /// the host asks, and no renderer draws it in sequence with the primary.
1325 1367 pub menu: Vec<Act>,
1326 1368 /// What ticking this row calls, if ticking it is the write.
1327 1369 ///
@@ -1344,9 +1386,21 @@
1344 1386
1345 1387 impl Row {
1346 1388 /// A row with only its primary text.
1389 + ///
1390 + /// An empty string is an empty run rather than a run holding an empty
1391 + /// string, so `Row::new("")` and [`Row::default`] are the same value. Same
1392 + /// rule as [`Cell::new`], and for the same reason.
1347 1393 pub fn new(primary: impl Into<String>) -> Self {
1394 + let primary = primary.into();
1348 1395 Self {
1349 - primary: primary.into(),
1396 + parts: if primary.is_empty() {
1397 + Vec::new()
1398 + } else {
1399 + vec![Part {
1400 + role: layout::RowPart::Primary,
1401 + node: Node::text(primary),
1402 + }]
1403 + },
1350 1404 ..Self::default()
1351 1405 }
1352 1406 }
@@ -1361,7 +1415,7 @@
1361 1415 /// How much of this row's set is done.
1362 1416 #[must_use]
1363 1417 pub fn meter(mut self, meter: Meter) -> Self {
1364 - self.meter = Some(meter);
1418 + self.set(layout::RowPart::Proportion, Node::Meter(meter));
1365 1419 self
1366 1420 }
1367 1421
@@ -1381,21 +1435,28 @@
1381 1435 /// Supporting text under the primary.
1382 1436 #[must_use]
1383 1437 pub fn secondary(mut self, text: impl Into<Prose>) -> Self {
1384 - self.secondary = Some(text.into());
1438 + let node = match text.into() {
1439 + Prose::Text(text) => Node::text(text),
1440 + Prose::Rich(source) => Node::rich(source),
1441 + };
1442 + self.set(layout::RowPart::Secondary, node);
1385 1443 self
1386 1444 }
1387 1445
1388 1446 /// A short trailing fact.
1389 1447 #[must_use]
1390 1448 pub fn meta(mut self, text: impl Into<String>) -> Self {
1391 - self.meta = Some(text.into());
1449 + self.set(layout::RowPart::Meta, Node::text(text));
1392 1450 self
1393 1451 }
1394 1452
1395 1453 /// Add a token, chaining.
1396 1454 #[must_use]
1397 1455 pub fn token(mut self, tag: Tag) -> Self {
1398 - self.tokens.push(tag);
1456 + self.parts.push(Part {
1457 + role: layout::RowPart::Tokens,
1458 + node: Node::Token(tag),
1459 + });
1399 1460 self
1400 1461 }
1401 1462
@@ -1419,9 +1480,78 @@
1419 1480 /// A control acting on this row.
1420 1481 #[must_use]
1421 1482 pub fn act(mut self, act: Act) -> Self {
1422 - self.actions.push(act);
1483 + self.parts.push(Part {
1484 + role: layout::RowPart::Actions,
1485 + node: Node::Act(act),
1486 + });
1423 1487 self
1424 1488 }
1489 +
1490 + /// Anything in this row, under the role it takes.
1491 + ///
1492 + /// The general form the constructors above are shorthands for, and the
1493 + /// point of the model: a link in a row and a figure in a row became
1494 + /// sayable at once, where each was previously a
1495 + /// [`layout::RowPart`] variant, a member here, a renderer arm and a
1496 + /// release.
1497 + ///
1498 + /// Appends rather than replacing, so a row can hold two of a role. The
1499 + /// named constructors keep the single-valued roles single-valued, which is
1500 + /// what their call sites already meant.
1501 + ///
1502 + /// # Panics
1503 + ///
1504 + /// If the node is not a leaf. A row is an inline run, so what goes in it
1505 + /// has to be drawable on one wrapped line without the renderer knowing what
1506 + /// it is -- the constrained-consumer bound, biting at a call site rather
1507 + /// than in a doc comment. Same assertion as [`Cell::part`].
1508 + #[must_use]
1509 + pub fn part(mut self, role: layout::RowPart, node: Node) -> Self {
1510 + assert!(
1511 + node.containment() == Containment::Text,
1512 + "a row is an inline run and holds leaves; {node:?} holds {:?}",
1513 + node.containment()
1514 + );
1515 + self.parts.push(Part { role, node });
1516 + self
1517 + }
1518 +
1519 + /// Set the one part taking a role, replacing it if it is already there.
1520 + ///
1521 + /// For the roles that are single-valued at every call site that has ever
1522 + /// existed: the primary, the supporting line, the trailing fact, the bar.
1523 + /// Building a row that calls `.meta` twice meant the second one won when
1524 + /// `meta` was an `Option`, and it still does.
1525 + fn set(&mut self, role: layout::RowPart, node: Node) {
1526 + match self.parts.iter_mut().find(|part| part.role == role) {
1527 + Some(part) => part.node = node,
1528 + None => self.parts.push(Part { role, node }),
1529 + }
1530 + }
1531 +
1532 + /// The parts taking one role, in order.
1533 + pub fn role(&self, role: layout::RowPart) -> impl Iterator<Item = &Node> {
1534 + self.parts
1535 + .iter()
1536 + .filter(move |part| part.role == role)
1537 + .map(|part| &part.node)
1538 + }
1539 +
1540 + /// The row's primary text.
1541 + ///
1542 + /// What every consumer of the old `primary` member wanted. A row built the
1543 + /// ordinary way has one primary part and answers its string; one that was
1544 + /// given two answers both, joined, in order.
1545 + #[must_use]
1546 + pub fn primary(&self) -> String {
1547 + self.role(layout::RowPart::Primary)
1548 + .filter_map(|node| match node {
1549 + Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
1550 + _ => None,
1551 + })
1552 + .collect::<Vec<_>>()
1553 + .join(" ")
1554 + }
1425 1555 }
1426 1556
1427 1557 /// One cell of a table row.
@@ -48,7 +48,7 @@
48 48 };
49 49 use makeover_webview::meter::meter_html;
50 50 use makeover_webview::placeholder::placeholder_html;
51 - use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
51 + use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Row, Slot, Tag};
52 52 use quasi_router::{Action, Method, Params};
53 53
54 54 /// Write a `class="..."` attribute, prefixed.
@@ -140,32 +140,6 @@
140 140 }
141 141 }
142 142
143 - /// A row part's prose into markup.
144 - ///
145 - /// [`Prose::Text`] escapes, exactly as it did when the part was a `String`.
146 - /// [`Prose::Rich`] renders through docengine's `phrase` preset, which is the
147 - /// preset this call site asked for: markdown with no block structure at all and
148 - /// no links, keeping the inline emphasis. A heading, a list and a quote each
149 - /// contribute their words without claiming a block of a row that has no room
150 - /// for one, and not even a paragraph survives.
151 - ///
152 - /// Links go because a row usually carries [`Row::activate`], so the row itself
153 - /// is already a target and an anchor inside it is a second target inside the
154 - /// first: ambiguous to click, worse to reach by keyboard, and pointing somewhere
155 - /// a one-line summary cannot usefully send anyone. Their text stays.
156 - ///
157 - /// This is the renderer deciding, which is the point of the description
158 - /// carrying the kind rather than a flattened string. A terminal renderer facing
159 - /// the same [`Prose::Rich`] can emit bold instead, and one that wants neither
160 - /// can call `docengine::render_plain`. None of them has to be told by the
161 - /// screen author which to do.
162 - fn row_prose_html(prose: &Prose) -> String {
163 - match prose {
164 - Prose::Text(text) => escape(text),
165 - Prose::Rich(source) => docengine::render_phrase(source),
166 - }
167 - }
168 -
169 143 /// Markdown source into markup, for [`Node::Rich`].
170 144 ///
171 145 /// The strict preset, which is a deliberate difference from the
@@ -534,49 +508,28 @@
534 508 out.push_str(" aria-label=\"Select\">");
535 509 }
536 510
537 - // The primary is a control when selecting the row does something, and plain
538 - // text when it does not. Emitting a button either way would give a screen
539 - // reader an affordance that answers nothing.
540 - match &row.activate {
541 - Some(action) => {
542 - let (open, close) = control_tag(action);
543 - out.push_str(open);
544 - class_attr(&["row-activate"], opts, out);
545 - action_attrs(action, Fires::Click, None, morphs, out);
546 - out.push('>');
547 - out.push_str(&escape(&row.primary));
548 - out.push_str(close);
549 - }
550 - None => {
551 - out.push_str("<span");
552 - class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
553 - out.push('>');
554 - out.push_str(&escape(&row.primary));
555 - out.push_str("</span>");
556 - }
511 + // A span per run of consecutive parts sharing a role, rather than a fixed
512 + // sequence of members. The old shape drew primary, secondary, meta, bar,
513 + // tokens, actions in that order however the description was built; the run
514 + // draws what it was given where it was put, and a row with a tag between
515 + // two facts now says so.
516 + //
517 + // Consecutive same-role parts share one wrapping span for the reason a
518 + // cell's tokens do: the part class carries the gap between siblings, so a
519 + // span each would space two badges as though they were unrelated.
520 + let mut rest = row.parts.as_slice();
521 + while let Some(head) = rest.first() {
522 + let role = head.role;
523 + let taken = rest.iter().take_while(|part| part.role == role).count();
524 + let (group, tail) = rest.split_at(taken);
525 + row_part_html(row, role, group, morphs, opts, out);
526 + rest = tail;
557 527 }
558 528
559 - if let Some(secondary) = &row.secondary {
560 - out.push_str("<span");
561 - class_attr(&[part_class(layout::RowPart::Secondary)], opts, out);
562 - out.push('>');
563 - out.push_str(&row_prose_html(secondary));
564 - out.push_str("</span>");
565 - }
566 -
567 - if let Some(meta) = &row.meta {
568 - out.push_str("<span");
569 - class_attr(&[part_class(layout::RowPart::Meta)], opts, out);
570 - out.push('>');
571 - out.push_str(&escape(meta));
572 - out.push_str("</span>");
573 - }
574 -
575 - // The menu before the bar, because it is not a fact about the row at all:
576 - // it is the set of things that can be done to it, and it renders as a
577 - // container the host opens its own way. A webview hangs a context menu off
578 - // it, a touch host an action sheet, a terminal a key-driven list; all three
579 - // read the same acts.
529 + // After the run, because a menu is not on the line: it is the set of things
530 + // that can be done to the row, and it renders as a container the host opens
531 + // its own way. A webview hangs a context menu off it, a touch host an action
532 + // sheet, a terminal a key-driven list; all three read the same acts.
580 533 if !row.menu.is_empty() {
581 534 out.push_str("<div");
582 535 class_attr(&["row-menu"], opts, out);
@@ -589,45 +542,83 @@
589 542 out.push_str("</div>");
590 543 }
591 544
592 - // The bar sits with the plain trailing facts and before the tokens, which
593 - // is where a proportion belongs in the emphasis order: it is a fact about
594 - // the row rather than a thing with a standing of its own. The trough and
595 - // its tones are makeover-webview's, unchanged, same as everywhere else.
596 - if let Some(meter) = &row.meter {
597 - out.push_str("<span");
598 - class_attr(&[part_class(layout::RowPart::Proportion)], opts, out);
599 - out.push('>');
600 - out.push_str(&meter_html(&meter.as_layout(), opts));
601 - out.push_str("</span>");
602 - }
603 -
604 - // Tokens after meta and before actions, which is emphasis order: the row's
605 - // own name, then what supports it, then the plain trailing fact, then the
606 - // things with a standing of their own, then the controls. `RowPart`'s
607 - // `intent` falls off in the same order and never rises again.
608 - if !row.tokens.is_empty() {
609 - out.push_str("<span");
610 - class_attr(&[part_class(layout::RowPart::Tokens)], opts, out);
611 - out.push('>');
612 - for tag in &row.tokens {
613 - tag_html(tag, morphs, opts, out);
614 - }
615 - out.push_str("</span>");
616 - }
617 -
618 - if !row.actions.is_empty() {
619 - out.push_str("<span");
620 - class_attr(&[part_class(layout::RowPart::Actions)], opts, out);
621 - out.push('>');
622 - for act in &row.actions {
623 - act_html(act, morphs, opts, out);
624 - }
625 - out.push_str("</span>");
626 - }
627 -
628 545 out.push_str("</li>");
629 546 }
630 547
548 + /// One run of consecutive row parts sharing a role.
549 + fn row_part_html(
550 + row: &Row,
551 + role: layout::RowPart,
552 + group: &[quasi_router::Part],
553 + morphs: bool,
554 + opts: &Emit,
555 + out: &mut String,
556 + ) {
557 + // The primary is a control when selecting the row does something, and plain
558 + // text when it does not. Emitting a button either way would give a screen
559 + // reader an affordance that answers nothing. `activate` stayed a field
560 + // through the run migration, so this is still the row's own answer rather
561 + // than something recomputed from a part.
562 + let activates = role == layout::RowPart::Primary && row.activate.is_some();
563 +
564 + let close = if let (true, Some(action)) = (activates, row.activate.as_ref()) {
565 + let (open, close) = control_tag(action);
566 + out.push_str(open);
567 + class_attr(&["row-activate"], opts, out);
568 + action_attrs(action, Fires::Click, None, morphs, out);
569 + out.push('>');
570 + close
571 + } else {
572 + out.push_str("<span");
573 + class_attr(&[part_class(role)], opts, out);
574 + out.push('>');
575 + "</span>"
576 + };
577 +
578 + for part in group {
579 + row_inline_html(&part.node, morphs, opts, out);
580 + }
581 +
582 + out.push_str(close);
583 + }
584 +
585 + /// One leaf inside a row's run.
586 + ///
587 + /// Markdown is the one place a run entry is not just `node_html`. A
588 + /// [`Node::Rich`] standing on its own is a block and renders as one; inside a
589 + /// row it goes through docengine's `phrase` preset instead, which is markdown
590 + /// with no block structure at all and no links, keeping the inline emphasis. A
591 + /// heading, a list and a quote each contribute their words without claiming a
592 + /// block of a row that has no room for one, and not even a paragraph survives.
593 + ///
594 + /// Links go because a row usually carries [`Row::activate`], so the row itself
595 + /// is already a target and an anchor inside it is a second target inside the
596 + /// first: ambiguous to click, worse to reach by keyboard, and pointing
597 + /// somewhere a one-line summary cannot usefully send anyone. Their text stays.
598 + ///
599 + /// This is the renderer deciding, which is the point of the description
600 + /// carrying the kind rather than a flattened string. A terminal renderer facing
601 + /// the same source can emit bold instead, and one that wants neither can call
602 + /// `docengine::render_plain`. None of them has to be told by the screen author
603 + /// which to do.
604 + fn row_inline_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) {
605 + match node {
606 + Node::Text { text, .. } => out.push_str(&escape(text)),
607 + Node::Rich { source } => out.push_str(&docengine::render_phrase(source)),
608 + Node::Token(tag) => tag_html(tag, morphs, opts, out),
609 + Node::Act(act) => act_html(act, morphs, opts, out),
610 + Node::Meter(meter) => out.push_str(&meter_html(&meter.as_layout(), opts)),
611 + // Every other leaf the model admits into a run. A link in a row and a
612 + // figure in a row were the two gaps the enumeration left open and could
613 + // not close without a `RowPart` variant each; here they arrive by
614 + // already being leaves.
615 + //
616 + // No fills: a bespoke region is a block and cannot reach a run, which
617 + // is what the containment bound guarantees.
618 + other => node_html(other, morphs, opts, &HashMap::new(), out),
619 + }
620 + }
621 +
631 622 /// One cell's inline run.
632 623 ///
633 624 /// A part per inline rather than a part per cell, which is the half of the
@@ -398,11 +398,85 @@
398 398 assert!(html.contains(r#"aria-label="3 of 7 subtasks""#));
399 399 }
400 400
401 + #[test]
402 + fn a_link_and_a_figure_in_a_row_cost_no_release() {
403 + // The two gaps the enumeration left open on the row side. Each was a
404 + // `RowPart` variant, a member on `Row`, a renderer arm and a
405 + // makeover-layout release away, which is what put the third of the seven
406 + // matrix pairings out of reach. Under the run they are leaves, and a leaf
407 + // in a run is already sayable.
408 + let html = fragment(&Node::list([Row::new("Invoice 41")
409 + .part(
410 + layout::RowPart::Meta,
411 + Node::Link {
412 + text: "makenot.work".into(),
413 + action: Action::get("/sites/1"),
414 + },
415 + )
416 + .part(
417 + layout::RowPart::Tokens,
418 + Node::Figure(Figure::new("41", "days")),
419 + )]));
420 +
421 + assert!(html.contains("makenot.work"), "{html}");
422 + assert!(html.contains("/sites/1"), "{html}");
423 + assert!(html.contains("41"), "{html}");
424 + // Each takes the class of the role it was given, and the leaves inside keep
425 + // makeover's own classes for what they are.
426 + assert!(html.contains("class=\"row-meta\""), "{html}");
427 + assert!(html.contains("class=\"row-tokens\""), "{html}");
428 + }
429 +
430 + #[test]
431 + #[should_panic(expected = "a row is an inline run and holds leaves")]
432 + fn a_row_refuses_a_block() {
433 + // The bound, at the call site. A row is drawable on one wrapped line, and a
434 + // list inside one is the door the 2026-08-08 rider was trying to shut with
435 + // a doc comment. This is the same rule, checked.
436 + let _ = Row::new("Ship it").part(
437 + layout::RowPart::Meta,
438 + Node::List {
439 + rows: Vec::new(),
440 + more: None,
441 + },
442 + );
443 + }
444 +
445 + #[test]
446 + fn a_rows_parts_draw_in_the_order_the_description_says_them() {
447 + // The members were drawn in a fixed sequence whatever order they were built
448 + // in, so a row wanting a tag between two facts got the tag hoisted to the
449 + // end. The run says the order itself.
450 + let html = fragment(&Node::list([Row::new("Ship it")
451 + .token(Tag::badge("beta"))
452 + .meta("2 files")]));
453 +
454 + let tokens = html.find("row-tokens").expect("a tokens strip");
455 + let meta = html.find("row-meta").expect("a meta part");
456 + assert!(tokens < meta, "{html}");
457 + }
458 +
459 + #[test]
460 + fn consecutive_parts_of_one_role_share_a_strip() {
461 + // Two badges are a strip and not two unrelated spans: the part class
462 + // carries the gap between siblings, which is the same reason a cell's
463 + // tokens group. Two badges with a fact between them are two strips, and
464 + // that is the description saying so.
465 + let html = fragment(&Node::list([Row::new("Ship it")
466 + .token(Tag::badge("beta"))
467 + .token(Tag::badge("draft"))]));
468 +
469 + assert_eq!(html.matches("row-tokens").count(), 1, "{html}");
470 + assert!(html.contains("beta"), "{html}");
471 + assert!(html.contains("draft"), "{html}");
472 + }
473 +
401 474 #[test]
402 475 fn a_proportion_in_a_row_is_a_bar_and_not_flattened_text() {
403 476 // `da5666ae`. `Meter` closed two of its seven sites at 0.10.0 and could not
404 - // reach the five that sit in rows, because a row holds no nodes. The row
405 - // carries the description of a bar instead, the way it carries tags.
477 + // reach the five that sit in rows, because a row held no nodes. The row
478 + // carried the description of a bar instead; under the run it carries the
479 + // meter as the leaf it always was, and `Row::meter` still builds it.
406 480 let html = fragment(&Node::list([
407 481 Row::new("Ship it").meter(Meter::new(3, 7).label("subtasks"))
408 482 ]));
@@ -8,7 +8,7 @@
8 8 use super::*;
9 9 use crate::router;
10 10 use quasi_router::layout::Readiness;
11 - use quasi_router::{Outcome, Request};
11 + use quasi_router::{Outcome, Request, Row};
12 12
13 13 fn state() -> AppState {
14 14 AppState::in_memory().expect("in-memory state")
@@ -53,7 +53,7 @@
53 53 slot.body
54 54 .iter()
55 55 .find_map(|node| match node {
56 - Node::List { rows, .. } => Some(rows.iter().map(|row| row.primary.clone()).collect()),
56 + Node::List { rows, .. } => Some(rows.iter().map(Row::primary).collect()),
57 57 _ => None,
58 58 })
59 59 .unwrap_or_default()
@@ -220,7 +220,7 @@
220 220 Node::List { rows, .. } => Some(
221 221 rows.iter()
222 222 .filter(|row| row.current)
223 - .map(|row| row.primary.clone())
223 + .map(Row::primary)
224 224 .collect::<Vec<_>>(),
225 225 ),
226 226 _ => None,