Skip to main content

max / quasi

A cell is an inline run, and the model found two members while it moved Step two of the containment settlement (1786cb94), Cell first because 022f0c59 and fbff4b37 are shipped findings to check the model against. Cell was four members -- value, tokens, actions, activate -- added one release at a time as each pairing was argued for on counted sites, two of them in one release. It is now one Vec<Node> of leaves, with the bound enforced at the call site: Cell::part panics on anything that is not Text, naming the node and what it holds. The 2026-08-08 rider claimed exactly this and never checked it. No call site moved. new, tag, token, acts, act and activate all still mean what they meant and build the run instead of setting members, so the 43 Cell::new sites and the five constructors are untouched. activate rewrites the leading text into a Node::Link rather than setting a field beside it, which is the same fact said once instead of as a pair that could disagree. The renderer computes a part per inline instead of per cell, so a run says its own order: a tag between two words draws there rather than being hoisted to the end, which four members could not express because members have no order between them. Consecutive tokens and consecutive acts still share one strip, since cell-tokens and cell-actions carry the gap between siblings. Two members arrived because the model asked for them rather than because a port did. Node::Link is text that goes somewhere. The matrix had Link as a dash in the "as a Node" column, sayable only as Cell::activate; once a cell is a run, the run needs a leaf meaning "this text is a link" or it stops being sayable. makeover-webview 0.32.0 names the class, because inventing one here is the cell-activate defect again. Node::Figure is one figure on a line, and the bound is what found it: a cell is a run, Node::Stats is a collection, so a figure in a cell failed rather than quietly working. Not a one-element strip -- a strip claims "this is a row of tiles", which is why the set is the node there, and this claims "this number sits on this line", where the run is already the grouping. Both open gaps the review listed are closed, and neither cost a member on a container. Row and Slot next.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 15:22 UTC
Signed with PGP, not checked
Commit: 979f83f00f73be3bc5319f383132cd4acbbc31a9
Parent: ae85b94
7 files changed, +360 insertions, -172 deletions
M Cargo.lock +10 -10
@@ -1930,9 +1930,9 @@
1930 1930
1931 1931 [[package]]
1932 1932 name = "makeover-webview"
1933 - version = "0.31.0"
1933 + version = "0.32.0"
1934 1934 source = "registry+https://github.com/rust-lang/crates.io-index"
1935 - checksum = "f9f97c1694d50160ed5c980a17c96b1c8d007dbdb2d6b9472a2a5270e28cb4fd"
1935 + checksum = "9ccc45dfb72ca709785ced43eb8b30644df0ef59a618aca9fc6c6ab8553c77de"
1936 1936 dependencies = [
1937 1937 "makeover-geometry",
1938 1938 "makeover-layout",
@@ -4888,6 +4888,14 @@
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 +
4891 4899 [[patch.unused]]
4892 4900 name = "kberg"
4893 4901 version = "0.1.0"
@@ -4899,11 +4907,3 @@
4899 4907 [[patch.unused]]
4900 4908 name = "tagtree"
4901 4909 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"
@@ -16,7 +16,7 @@
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 18 makeover-layout = "0.16.0"
19 - makeover-webview = "0.31.0"
19 + makeover-webview = "0.32.0"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Sanitising comes with it, which is why the node can carry what a user typed.
22 22 #
@@ -182,6 +182,11 @@
182 182 Self::Heading { .. } | Self::Text { .. } | Self::Rich { .. } | Self::Token(_) => {
183 183 Containment::Text
184 184 }
185 + // Text that goes somewhere. The address is not content, the same
186 + // way an act's is not.
187 + Self::Link { .. } => Containment::Text,
188 + // One figure on a line. The strip is the collection; this is not.
189 + Self::Figure(_) => Containment::Text,
185 190 // An act is a label and an address. The address is not content.
186 191 Self::Act(_) => Containment::Text,
187 192 // A notice is a sentence with a tone. It was never allowed to hold
@@ -31,6 +31,7 @@
31 31
32 32 use makeover_layout as layout;
33 33
34 + use crate::containment::{Containment, Element};
34 35 use crate::request::{Method, Params};
35 36
36 37 /// Where an action goes.
@@ -1450,54 +1451,40 @@
1450 1451 /// confirmation, and that is the whole of what these cells hold.
1451 1452 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1452 1453 pub struct Cell {
1453 - /// The text, escaped by the renderer. Empty on an actions-only cell.
1454 - pub value: String,
1455 - /// The tags in this cell, in order.
1454 + /// What is in it, in order.
1456 1455 ///
1457 - /// Counted before adding it, the same rule [`actions`](Self::actions) was
1458 - /// held to: 33 table cells across 22 of the MNW server's templates carry a
1459 - /// badge or a chip, which is more sites than the acts earned. A status
1460 - /// column is the common one and it is the reason this cannot be folded into
1461 - /// [`value`](Self::value) as text: `Refunded` and `Paid` are the same
1462 - /// string to a renderer and different tones to a reader, and flattening
1463 - /// them loses exactly what the column is for.
1456 + /// An inline run: every part is a leaf, so the whole cell is drawable on
1457 + /// one wrapped line without a renderer knowing what is in it. That is the
1458 + /// bound, and [`Cell::part`] is where it is enforced.
1464 1459 ///
1465 - /// The same part [`Row::tokens`] carries, under the same name and holding
1466 - /// the same type, so the two arrangements of a row do not disagree about
1467 - /// what a tag is. That symmetry is also the whole argument for the member:
1468 - /// nothing new is named here.
1469 - pub tokens: Vec<Tag>,
1470 - /// The controls in this cell, in order.
1471 - pub actions: Vec<Act>,
1472 - /// Where this cell's value goes, when the value is itself a link.
1460 + /// This was four members -- `value`, `tokens`, `actions`, `activate` --
1461 + /// added one release at a time as each pairing was argued for on counted
1462 + /// sites. `022f0c59` added two of them at once. That trajectory is what
1463 + /// decided the containment model: a meter in a cell and a figure in a cell
1464 + /// were simply not sayable, and each would have been a fifth and sixth
1465 + /// member. Under the run they are already sayable and cost nothing.
1473 1466 ///
1474 - /// Counted before adding it, the same rule the other two members were held
1475 - /// to: 35 cells across 18 of the MNW server's templates are a value that is
1476 - /// a link, counting only anchors that are not button-styled, so the
1477 - /// `View`/`Edit` anchors [`actions`](Self::actions) already covers are
1478 - /// excluded. A title column is the common one.
1479 - ///
1480 - /// Distinct from [`Cells::activate`], which opens the whole row and can
1481 - /// therefore say only one destination. That is not enough: MNW's
1482 - /// `library_purchases` row carries four, to the item, the creator, the
1483 - /// project and the invoice, and a row-level link would have to drop three
1484 - /// of them.
1485 - ///
1486 - /// Distinct from an [`Act`] too, and the difference is what the reader
1487 - /// sees. An act is a control drawn as one, which is right for `Edit` and
1488 - /// wrong for a title: making every linked value a button would put a row of
1489 - /// bevels down the first column of half the dashboard.
1490 - pub activate: Option<Action>,
1467 + /// The constructors that named the old members are still here and still
1468 + /// mean what they meant, so no call site moved: [`Cell::new`],
1469 + /// [`tag`](Cell::tag), [`token`](Cell::token), [`acts`](Cell::acts),
1470 + /// [`act`](Cell::act) and [`activate`](Cell::activate) build the run.
1471 + pub parts: Vec<Node>,
1491 1472 }
1492 1473
1493 1474 impl Cell {
1494 1475 /// A cell holding text.
1476 + ///
1477 + /// An empty string is an empty run rather than a run holding an empty
1478 + /// string, so an actions-only cell built through [`acts`](Self::acts) and
1479 + /// one built as `Cell::new("").act(..)` are the same value.
1495 1480 pub fn new(value: impl Into<String>) -> Self {
1481 + let value = value.into();
1496 1482 Self {
1497 - value: value.into(),
1498 - tokens: Vec::new(),
1499 - actions: Vec::new(),
1500 - activate: None,
1483 + parts: if value.is_empty() {
1484 + Vec::new()
1485 + } else {
1486 + vec![Node::text(value)]
1487 + },
1501 1488 }
1502 1489 }
1503 1490
@@ -1507,34 +1494,28 @@
1507 1494 /// token would say the same thing and reads as an oversight.
1508 1495 pub fn tag(tag: Tag) -> Self {
1509 1496 Self {
1510 - value: String::new(),
1511 - tokens: vec![tag],
1512 - actions: Vec::new(),
1513 - activate: None,
1497 + parts: vec![Node::Token(tag)],
1514 1498 }
1515 1499 }
1516 1500
1517 1501 /// A tag in this cell, chaining.
1518 1502 #[must_use]
1519 1503 pub fn token(mut self, tag: Tag) -> Self {
1520 - self.tokens.push(tag);
1504 + self.parts.push(Node::Token(tag));
1521 1505 self
1522 1506 }
1523 1507
1524 1508 /// A cell holding controls and no text.
1525 1509 pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
1526 1510 Self {
1527 - value: String::new(),
1528 - tokens: Vec::new(),
1529 - actions: actions.into_iter().collect(),
1530 - activate: None,
1511 + parts: actions.into_iter().map(Node::Act).collect(),
1531 1512 }
1532 1513 }
1533 1514
1534 1515 /// A control in this cell, chaining.
1535 1516 #[must_use]
1536 1517 pub fn act(mut self, act: Act) -> Self {
1537 - self.actions.push(act);
1518 + self.parts.push(Node::Act(act));
1538 1519 self
1539 1520 }
1540 1521
@@ -1542,11 +1523,82 @@
1542 1523 ///
1543 1524 /// The value becomes the link. A cell with no value and an `activate` is a
1544 1525 /// link with nothing to press, so give it text.
1526 + ///
1527 + /// Under the run this rewrites the leading text into a [`Node::Link`]
1528 + /// rather than setting a member beside it, which is the same fact said once
1529 + /// instead of as a pair of fields that could disagree. A cell with no text
1530 + /// to link gains nothing, because a link with no label is a control nothing
1531 + /// draws.
1545 1532 #[must_use]
1546 1533 pub fn activate(mut self, action: Action) -> Self {
1547 - self.activate = Some(action);
1534 + if let Some(first) = self
1535 + .parts
1536 + .iter_mut()
1537 + .find(|part| matches!(part, Node::Text { .. }))
1538 + && let Node::Text { text, .. } = first
1539 + {
1540 + *first = Node::Link {
1541 + text: std::mem::take(text),
1542 + action,
1543 + };
1544 + }
1548 1545 self
1549 1546 }
1547 +
1548 + /// Anything in this cell, chaining.
1549 + ///
1550 + /// The general form the five constructors above are shorthands for, and the
1551 + /// whole point of the model: a meter in a cell, a figure in a cell and a
1552 + /// second linked value in a cell all became sayable at once, where each was
1553 + /// previously a member, three renderer arms and a release.
1554 + ///
1555 + /// # Panics
1556 + ///
1557 + /// If the node is not a leaf. A cell is an inline run, so what goes in it
1558 + /// has to be drawable on one wrapped line without the renderer knowing what
1559 + /// it is -- that is the constrained-consumer bound, and this is where it
1560 + /// bites at a call site rather than in a doc comment.
1561 + #[must_use]
1562 + pub fn part(mut self, node: Node) -> Self {
1563 + assert!(
1564 + node.containment() == Containment::Text,
1565 + "a cell is an inline run and holds leaves; {node:?} holds \
1566 + {:?}",
1567 + node.containment()
1568 + );
1569 + self.parts.push(node);
1570 + self
1571 + }
1572 +
1573 + /// The cell's text, with the parts that are not text left out.
1574 + ///
1575 + /// What every consumer of the old `value` member wanted. A cell that is one
1576 + /// string answers that string; one that mixes answers the text between its
1577 + /// tags and controls, in order.
1578 + #[must_use]
1579 + pub fn text(&self) -> String {
1580 + self.parts
1581 + .iter()
1582 + .filter_map(|part| match part {
1583 + Node::Text { text, .. } | Node::Link { text, .. } => Some(text.as_str()),
1584 + _ => None,
1585 + })
1586 + .collect::<Vec<_>>()
1587 + .join(" ")
1588 + }
1589 +
1590 + /// Whether anything in this cell answers a click.
1591 + ///
1592 + /// A badge is not one: it says something and answers nothing, which is why
1593 + /// this asks the tag rather than counting tags.
1594 + #[must_use]
1595 + pub fn carries_control(&self) -> bool {
1596 + self.parts.iter().any(|part| match part {
1597 + Node::Act(_) | Node::Link { .. } => true,
1598 + Node::Token(tag) => tag.kind.interactive() && tag.action.is_some(),
1599 + _ => false,
1600 + })
1601 + }
1550 1602 }
1551 1603
1552 1604 impl From<String> for Cell {
@@ -1658,6 +1710,43 @@
1658 1710 },
1659 1711 /// A control that calls a route.
1660 1712 Act(Act),
1713 + /// Text that goes somewhere.
1714 + ///
1715 + /// The containment model predicted this before anything asked for it: the
1716 + /// composability matrix had a `Link` row whose "as a `Node`" cell was a
1717 + /// dash, and the only way to say it was [`Cell::activate`], a member on one
1718 + /// container. Once a cell is a run of leaves, the run needs a leaf that
1719 + /// means "this text is a link" or the thing stops being sayable at all.
1720 + ///
1721 + /// Distinct from [`Act`](Self::Act), and the difference is what the reader
1722 + /// sees rather than what the route does. An act is a control drawn as one,
1723 + /// which is right for `Edit` and wrong for a title: making every linked
1724 + /// value a button would put a row of bevels down the first column of half a
1725 + /// dashboard. Both call a route; only one of them looks like a button.
1726 + Link {
1727 + /// What it says.
1728 + text: String,
1729 + /// Where it goes.
1730 + action: Action,
1731 + },
1732 + /// One figure, on a line.
1733 + ///
1734 + /// The second thing the containment model found, and it found it by
1735 + /// refusing: a cell is a run of leaves, [`Stats`](Self::Stats) is a
1736 + /// collection, so putting a figure in a cell failed the bound rather than
1737 + /// quietly working. That is the check doing its job -- the matrix had
1738 + /// "figure in a cell" as a dash nobody had attempted, and the dash turns
1739 + /// out to have been hiding a missing member rather than a missing renderer
1740 + /// arm.
1741 + ///
1742 + /// Not a duplicate of a one-element [`Stats`](Self::Stats), and the
1743 + /// difference is the claim being made. A strip says "this is a row of
1744 + /// tiles", which is why the set is the node there: a renderer handed one
1745 + /// tile at a time cannot tell it is looking at a set. This says "this
1746 + /// number sits on this line", where the run is already the grouping and
1747 + /// there is nothing for a set to add. A dashboard strip of one is still a
1748 + /// strip; a revenue column is not.
1749 + Figure(Figure),
1661 1750 /// A small labelled thing sitting inside something else.
1662 1751 Token(Tag),
1663 1752 /// Something the app is telling the user, unprompted.
@@ -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, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
51 + use quasi_router::screen::{Act, Cell, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
52 52 use quasi_router::{Action, Method, Params};
53 53
54 54 /// Write a `class="..."` attribute, prefixed.
@@ -628,6 +628,114 @@
628 628 out.push_str("</li>");
629 629 }
630 630
631 + /// One cell's inline run.
632 + ///
633 + /// A part per inline rather than a part per cell, which is the half of the
634 + /// containment model this crate had to learn. Before it, a cell was four
635 + /// members and this function was a fixed sequence: the value or the link, then
636 + /// the tokens strip, then the actions strip. The run says the order itself, so
637 + /// a cell holding a tag between two words draws that way instead of hoisting
638 + /// the tag to the end.
639 + ///
640 + /// Consecutive tokens and consecutive acts still share one wrapping strip.
641 + /// `cell-tokens` and `cell-actions` carry the gap between siblings, so a span
642 + /// each would space them as though they were unrelated, and the common case --
643 + /// a status column of three badges -- is exactly the consecutive one.
644 + fn cell_run_html(cell: &Cell, morphs: bool, opts: &Emit) -> String {
645 + // A cell that is one piece of text says so on the container through
646 + // `CellPart::Value`, so a wrapper span here would say nothing the container
647 + // has not. Anything else names its parts inside, or the content colour on
648 + // the cell reaches the tokens and the controls beside the text -- the drift
649 + // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
650 + // emitting.
651 + if let [Node::Text { text, .. }] = cell.parts.as_slice() {
652 + return escape(text);
653 + }
654 +
655 + let mut out = String::new();
656 + let mut rest = cell.parts.as_slice();
657 + while let Some((head, tail)) = rest.split_first() {
658 + match head {
659 + Node::Text { text, .. } => {
660 + out.push_str("<span");
661 + class_attr(&[cell_part_class(layout::CellPart::Value)], opts, &mut out);
662 + out.push('>');
663 + out.push_str(&escape(text));
664 + out.push_str("</span>");
665 + rest = tail;
666 + }
667 + // The row is a `div` and not an anchor even when it activates, so
668 + // this nests nothing: the href lands on the row element as an
669 + // attribute htmx reads, and the only `<a>` in the row is the one a
670 + // cell asked for.
671 + //
672 + // `data-act` for the same reason a button carries it. The row's
673 + // `ClickBeside` filter keys on that attribute, so without it a click
674 + // on the title would follow the link and open the row underneath.
675 + Node::Link { text, action } => {
676 + let (open, close) = control_tag(action);
677 + out.push_str(open);
678 + class_attr(&[cell_part_class(layout::CellPart::Link)], opts, &mut out);
679 + out.push_str(" data-act");
680 + action_attrs(action, Fires::Click, None, morphs, &mut out);
681 + out.push('>');
682 + out.push_str(&escape(text));
683 + out.push_str(close);
684 + rest = tail;
685 + }
686 + Node::Token(_) => {
687 + let run = rest.iter().take_while(|p| matches!(p, Node::Token(_)));
688 + out.push_str("<span");
689 + class_attr(&[cell_part_class(layout::CellPart::Tokens)], opts, &mut out);
690 + out.push('>');
691 + let mut taken = 0;
692 + for part in run {
693 + if let Node::Token(tag) = part {
694 + tag_html(tag, morphs, opts, &mut out);
695 + }
696 + taken += 1;
697 + }
698 + out.push_str("</span>");
699 + rest = &rest[taken..];
700 + }
701 + // Deliberately not `row-actions`. That was a hover-reveal rule
702 + // until makeover-webview 0.23.0 retired it, and it is a list row's
703 + // class besides: `cell-actions` is the table's own, and it carries
704 + // no colour so a button here is not painted as text.
705 + Node::Act(_) => {
706 + let run = rest.iter().take_while(|p| matches!(p, Node::Act(_)));
707 + out.push_str("<span");
708 + class_attr(
709 + &[cell_part_class(layout::CellPart::Actions)],
710 + opts,
711 + &mut out,
712 + );
713 + out.push('>');
714 + let mut taken = 0;
715 + for part in run {
716 + if let Node::Act(act) = part {
717 + act_html(act, morphs, opts, &mut out);
718 + }
719 + taken += 1;
720 + }
721 + out.push_str("</span>");
722 + rest = &rest[taken..];
723 + }
724 + // Every other leaf the model now admits into a run. A meter and a
725 + // figure in a cell were the two gaps the enumeration left open and
726 + // could not close without a member each; here they arrive by
727 + // already being leaves.
728 + other => {
729 + // No fills: a bespoke region is a block and cannot reach a run,
730 + // which the containment bound is what guarantees.
731 + node_html(other, morphs, opts, &HashMap::new(), &mut out);
732 + rest = tail;
733 + }
734 + }
735 + }
736 + out
737 + }
738 +
631 739 /// One row of a table.
632 740 fn cells_row_html(
633 741 cells: &Cells,
@@ -650,16 +758,11 @@
650 758 out.push_str(" aria-current=\"true\"");
651 759 }
652 760 if let Some(action) = &cells.activate {
653 - // Any control in any cell, which is acts plus the chips that answer a
654 - // click. A badge is not one and does not earn the filter.
655 - let carries_control = cells.values.iter().any(|cell| {
656 - !cell.actions.is_empty()
657 - || cell.activate.is_some()
658 - || cell
659 - .tokens
660 - .iter()
661 - .any(|tag| tag.kind.interactive() && tag.action.is_some())
662 - });
761 + // Any control in any cell, which is acts and links plus the chips that
762 + // answer a click. A badge is not one and does not earn the filter. The
763 + // walk moved onto `Cell` with the run, so this reads the description's
764 + // answer rather than recomputing it from members.
765 + let carries_control = cells.values.iter().any(Cell::carries_control);
663 766 let fires = if carries_control {
664 767 Fires::ClickBeside
665 768 } else {
@@ -677,111 +780,19 @@
677 780 let filled: Vec<String> = cells
678 781 .values
679 782 .iter()
680 - .map(|cell| {
681 - // A value that is a link is the link, rather than text with a
682 - // control beside it. The row is a `div` and not an anchor even when
683 - // it activates, so this nests nothing: the href lands on the row
684 - // element as an attribute htmx reads, and the only `<a>` in the row
685 - // is the one a cell asked for.
686 - //
687 - // `data-act` for the same reason a button carries it. The row's
688 - // `ClickBeside` filter keys on that attribute, so without it a click
689 - // on the title would follow the link and open the row underneath.
690 - // Whether the value shares the cell with anything. A cell that is
691 - // only its value says so on the container through `CellPart::Value`
692 - // below, and needs no wrapper; one that mixes has to name its parts
693 - // inside, or the content colour on the cell would reach the tokens
694 - // and the controls beside the text. That is the drift
695 - // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
696 - // emitting.
697 - let mixes = !cell.tokens.is_empty() || !cell.actions.is_empty();
698 -
699 - let mut inner = match &cell.activate {
700 - Some(action) => {
701 - let (open, close) = control_tag(action);
702 - let mut linked = String::from(open);
703 - // `cell-link`, which is what `CellPart::Link` is spelled as
704 - // in the generated stylesheet. It was `cell-activate`, a
705 - // name this crate invented while the shared vocabulary had
706 - // none, and a class with no rule behind it.
707 - class_attr(
708 - &[cell_part_class(layout::CellPart::Link)],
709 - opts,
710 - &mut linked,
711 - );
712 - linked.push_str(" data-act");
713 - action_attrs(action, Fires::Click, None, morphs, &mut linked);
714 - linked.push('>');
715 - linked.push_str(&escape(&cell.value));
716 - linked.push_str(close);
717 - linked
718 - }
719 - // An actions-only or tokens-only cell has no value, and an
720 - // empty span for the one it does not have is an element saying
721 - // nothing.
722 - None if mixes && !cell.value.is_empty() => {
723 - let mut valued = String::from("<span");
724 - class_attr(
725 - &[cell_part_class(layout::CellPart::Value)],
726 - opts,
727 - &mut valued,
728 - );
729 - valued.push('>');
730 - valued.push_str(&escape(&cell.value));
731 - valued.push_str("</span>");
732 - valued
733 - }
734 - None => escape(&cell.value),
735 - };
736 - // Tags after the text and before the controls, which is the
737 - // emphasis order a list row already falls in: the thing itself,
738 - // then what has a standing of its own, then what can be done to it.
739 - if !cell.tokens.is_empty() {
740 - inner.push_str("<span");
741 - class_attr(
742 - &[cell_part_class(layout::CellPart::Tokens)],
743 - opts,
744 - &mut inner,
745 - );
746 - inner.push('>');
747 - for tag in &cell.tokens {
748 - tag_html(tag, morphs, opts, &mut inner);
749 - }
750 - inner.push_str("</span>");
751 - }
752 - if !cell.actions.is_empty() {
753 - // Deliberately not `row-actions`. That was a hover-reveal rule
754 - // until makeover-webview 0.23.0 retired it, and it is a list
755 - // row's class besides: `cell-actions` is the table's own, and
756 - // it carries no colour so a button here is not painted as text.
757 - inner.push_str("<span");
758 - class_attr(
759 - &[cell_part_class(layout::CellPart::Actions)],
760 - opts,
761 - &mut inner,
762 - );
763 - inner.push('>');
764 - for act in &cell.actions {
765 - act_html(act, morphs, opts, &mut inner);
766 - }
767 - inner.push_str("</span>");
768 - }
769 - inner
770 - })
783 + .map(|cell| cell_run_html(cell, morphs, opts))
771 784 .collect();
772 - // The container says what the cell is only when the cell is nothing but its
773 - // value, which is the case where a wrapper span would say nothing the
774 - // container has not already said. Anything else names its parts inside --
775 - // the anchor is a `cell-link`, the strips are `cell-tokens` and
785 + // The container says what the cell is only when the cell is nothing but one
786 + // piece of text, which is the case where a wrapper span would say nothing
787 + // the container has not already said. Anything else names its parts inside
788 + // -- the anchor is a `cell-link`, the strips are `cell-tokens` and
776 789 // `cell-actions` -- because a colour on the container would reach all of
777 790 // them, and that is the drift makeover-layout 0.14.0 named.
778 791 let parts: Vec<Option<layout::CellPart>> = cells
779 792 .values
780 793 .iter()
781 794 .map(|cell| {
782 - let only_a_value =
783 - cell.tokens.is_empty() && cell.actions.is_empty() && cell.activate.is_none();
784 - only_a_value.then_some(layout::CellPart::Value)
795 + matches!(cell.parts.as_slice(), [Node::Text { .. }]).then_some(layout::CellPart::Value)
785 796 })
786 797 .collect();
787 798 let borrowed: Vec<layout::Column<'_>> = columns
@@ -865,8 +876,28 @@
865 876
866 877 Node::Act(act) => act_html(act, morphs, opts, out),
867 878
879 + // Text that goes somewhere. `control_tag` picks the anchor or the
880 + // button from the method, the same way a cell link already did, and
881 + // `link` is the class makeover names it with.
882 + Node::Link { text, action } => {
883 + let (open, close) = control_tag(action);
884 + out.push_str(open);
885 + class_attr(&["link"], opts, out);
886 + out.push_str(" data-act");
887 + action_attrs(action, Fires::Click, None, morphs, out);
888 + out.push('>');
889 + out.push_str(&escape(text));
890 + out.push_str(close);
891 + }
892 +
868 893 Node::Token(tag) => tag_html(tag, morphs, opts, out),
869 894
895 + // One figure, through the same emitter the strip uses. What differs is
896 + // that nothing wraps it: the run it sits in is already the grouping.
897 + Node::Figure(figure) => {
898 + out.push_str(&figure_html(&figure.as_layout(), opts));
899 + }
900 +
870 901 Node::Notice { kind, tone, text } => {
871 902 out.push_str("<div");
872 903 class_attr(
@@ -1076,6 +1076,62 @@
1076 1076 assert!(html.contains("Refunded"), "{html}");
1077 1077 }
1078 1078
1079 + #[test]
1080 + fn a_meter_and_a_figure_in_a_cell_were_the_two_gaps_the_enumeration_left_open() {
1081 + // The whole argument for the containment model in one test. Under the
1082 + // per-pairing enumeration these were the two cells of the matrix nobody had
1083 + // filled, and each would have cost a member on `Cell`, three renderer arms
1084 + // and a release. Under the run they arrive by already being leaves, and the
1085 + // renderer draws them through the emitters it already had.
1086 + let html = fragment(&Node::Table {
1087 + columns: vec![
1088 + Column::new("Task").width(layout::Width::Fill),
1089 + Column::new("Done").width(layout::Width::Content),
1090 + Column::new("Revenue").width(layout::Width::Content),
1091 + ],
1092 + rows: vec![Cells::new([
1093 + Cell::new("Write the renderer"),
1094 + Cell::new("").part(Node::Meter(Meter::new(3, 7))),
1095 + Cell::new("").part(Node::Figure(Figure::new("$12.00", "this month"))),
1096 + ])],
1097 + });
1098 +
1099 + assert!(html.contains("progress"), "the meter is drawn: {html}");
1100 + assert!(html.contains("figure"), "the figure is drawn: {html}");
1101 + assert!(html.contains("$12.00"), "{html}");
1102 + }
1103 +
1104 + #[test]
1105 + fn a_run_says_its_own_order_rather_than_hoisting_the_tags_to_the_end() {
1106 + // What a part per cell could not express. The old renderer emitted the
1107 + // value, then every token, then every act, whatever order the description
1108 + // put them in, because the cell had one member per kind and members have no
1109 + // order between them.
1110 + let html = fragment(&Node::Table {
1111 + columns: vec![Column::new("Note").width(layout::Width::Fill)],
1112 + rows: vec![Cells::new([Cell::new("shipped")
1113 + .token(Tag::badge("beta"))
1114 + .part(Node::text("to staging"))])],
1115 + });
1116 +
1117 + let badge = html.find("beta").expect("the tag is drawn");
1118 + let before = html.find("shipped").expect("the first text");
1119 + let after = html.find("to staging").expect("the second text");
1120 + assert!(before < badge && badge < after, "{html}");
1121 + }
1122 +
1123 + #[test]
1124 + #[should_panic(expected = "a cell is an inline run and holds leaves")]
1125 + fn a_cell_refuses_a_block() {
1126 + // The bound, at a call site. A list in a cell is the thing the 2026-08-08
1127 + // rider was defending against and never actually checked; here it is a
1128 + // panic with the offending node in the message.
1129 + let _ = Cell::new("x").part(Node::List {
1130 + rows: Vec::new(),
1131 + more: None,
1132 + });
1133 + }
1134 +
1079 1135 #[test]
1080 1136 fn a_chip_in_a_cell_is_a_control_and_a_badge_is_not() {
1081 1137 // The bubbling guard has to cover both kinds of control a cell can hold,
@@ -1110,7 +1166,14 @@
1110 1166 cells.values,
1111 1167 vec![Cell::new("kick.wav"), Cell::new("2.1 MB")]
1112 1168 );
1113 - assert!(cells.values.iter().all(|cell| cell.actions.is_empty()));
1169 + // Each is one piece of text and nothing else, which is what the container
1170 + // reads to decide the cell needs no wrapper span.
1171 + assert!(
1172 + cells
1173 + .values
1174 + .iter()
1175 + .all(|cell| matches!(cell.parts.as_slice(), [Node::Text { .. }]))
1176 + );
1114 1177 }
1115 1178
1116 1179 #[test]
@@ -39,4 +39,4 @@
39 39 # here is not one app a release behind: it is every app anyone scaffolds from
40 40 # now on. 2.4.1 and 0.13.0 sat here while the suite reached 2.5.0 and 0.20.1.
41 41 makeover = "2.5"
42 - makeover-build = "0.24.0"
42 + makeover-build = "0.25.0"