Skip to main content

max / quasi

A table cell can hold a tag Found starting S4, the batch conversion of the MNW server's dashboard tabs, by reading the first two candidates. Both put a badge in a cell: the sessions tab marks the device you are on, and the sales tab's whole Status column is one. Counted before adding it, the rule the acts were held to: 33 table cells across 22 of the server's templates carry a badge or a chip, which is more sites than the acts earned. Folding it into the cell's text is what a conversion would otherwise do, and it is the same regression that made the described SSH-keys tab read worse than the Askama one. Refunded and Paid are the same string to a renderer and different tones to a reader. Nothing new is named. Row::tokens already exists, holds the same type and means the same thing, so the two arrangements of a row agree; and makeover needs no change, because .badge and .chip are already defined and a cell takes markup. The bubbling guard widens with it. A chip that answers a click is a control, so it carries data-act and a row holding one filters its own trigger. A badge answers nothing and earns neither.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 23:56 UTC
Signed with PGP, not checked
Commit: fcf57a8059cbc5c43dd18e69db3a694abc828f44
Parent: f287ac1
4 files changed, +120 insertions, -9 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"
@@ -1316,6 +1316,21 @@
1316 1316 pub struct Cell {
1317 1317 /// The text, escaped by the renderer. Empty on an actions-only cell.
1318 1318 pub value: String,
1319 + /// The tags in this cell, in order.
1320 + ///
1321 + /// Counted before adding it, the same rule [`actions`](Self::actions) was
1322 + /// held to: 33 table cells across 22 of the MNW server's templates carry a
1323 + /// badge or a chip, which is more sites than the acts earned. A status
1324 + /// column is the common one and it is the reason this cannot be folded into
1325 + /// [`value`](Self::value) as text: `Refunded` and `Paid` are the same
1326 + /// string to a renderer and different tones to a reader, and flattening
1327 + /// them loses exactly what the column is for.
1328 + ///
1329 + /// The same part [`Row::tokens`] carries, under the same name and holding
1330 + /// the same type, so the two arrangements of a row do not disagree about
1331 + /// what a tag is. That symmetry is also the whole argument for the member:
1332 + /// nothing new is named here.
1333 + pub tokens: Vec<Tag>,
1319 1334 /// The controls in this cell, in order.
1320 1335 pub actions: Vec<Act>,
1321 1336 }
@@ -1325,14 +1340,35 @@
1325 1340 pub fn new(value: impl Into<String>) -> Self {
1326 1341 Self {
1327 1342 value: value.into(),
1343 + tokens: Vec::new(),
1328 1344 actions: Vec::new(),
1329 1345 }
1330 1346 }
1331 1347
1348 + /// A cell holding one tag and no text.
1349 + ///
1350 + /// What a status column is: the cell is the badge. `Cell::new("")` with a
1351 + /// token would say the same thing and reads as an oversight.
1352 + pub fn tag(tag: Tag) -> Self {
1353 + Self {
1354 + value: String::new(),
1355 + tokens: vec![tag],
1356 + actions: Vec::new(),
1357 + }
1358 + }
1359 +
1360 + /// A tag in this cell, chaining.
1361 + #[must_use]
1362 + pub fn token(mut self, tag: Tag) -> Self {
1363 + self.tokens.push(tag);
1364 + self
1365 + }
1366 +
1332 1367 /// A cell holding controls and no text.
1333 1368 pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
1334 1369 Self {
1335 1370 value: String::new(),
1371 + tokens: Vec::new(),
1336 1372 actions: actions.into_iter().collect(),
1337 1373 }
1338 1374 }
@@ -614,7 +614,16 @@
614 614 out.push_str(" aria-current=\"true\"");
615 615 }
616 616 if let Some(action) = &cells.activate {
617 - let fires = if cells.values.iter().any(|cell| !cell.actions.is_empty()) {
617 + // Any control in any cell, which is acts plus the chips that answer a
618 + // click. A badge is not one and does not earn the filter.
619 + let carries_control = cells.values.iter().any(|cell| {
620 + !cell.actions.is_empty()
621 + || cell
622 + .tokens
623 + .iter()
624 + .any(|tag| tag.kind.interactive() && tag.action.is_some())
625 + });
626 + let fires = if carries_control {
618 627 Fires::ClickBeside
619 628 } else {
620 629 Fires::Click
@@ -633,6 +642,18 @@
633 642 .iter()
634 643 .map(|cell| {
635 644 let mut inner = escape(&cell.value);
645 + // Tags after the text and before the controls, which is the
646 + // emphasis order a list row already falls in: the thing itself,
647 + // then what has a standing of its own, then what can be done to it.
648 + if !cell.tokens.is_empty() {
649 + inner.push_str("<span");
650 + class_attr(&["cell-tokens"], opts, &mut inner);
651 + inner.push('>');
652 + for tag in &cell.tokens {
653 + tag_html(tag, morphs, opts, &mut inner);
654 + }
655 + inner.push_str("</span>");
656 + }
636 657 if !cell.actions.is_empty() {
637 658 // Deliberately not `row-actions`: makeover hides that one until
638 659 // the row is hovered or focused, which is defensible on a dense
@@ -977,6 +998,11 @@
977 998 tone_attr(tone, out);
978 999
979 1000 if interactive {
1001 + // `data-act` for the same reason a button carries it: a chip that
1002 + // answers a click is a control, and a table row filtering its own
1003 + // trigger has to be able to tell one apart from its own text. A badge
1004 + // never gets it, because a badge answers nothing.
1005 + out.push_str(" data-act");
980 1006 if latched {
981 1007 // A chip standing for a filter is on or off, and its latched class
982 1008 // carries that fact visually through Depth::pressed either way.
@@ -790,6 +790,55 @@
790 790 }
791 791 }
792 792
793 + #[test]
794 + fn a_status_column_keeps_its_tone_instead_of_flattening_to_text() {
795 + // 33 table cells across 22 of MNW's templates carry a badge or a chip, more
796 + // sites than the acts earned. A status column is the common one, and it is
797 + // why this cannot be folded into the cell's text: `Refunded` and `Paid` are
798 + // the same string to a renderer and different tones to a reader.
799 + let html = fragment(&Node::Table {
800 + columns: vec![
801 + Column::new("Amount").width(layout::Width::Content),
802 + Column::new("Status").width(layout::Width::Content),
803 + ],
804 + rows: vec![Cells::new([
805 + Cell::new("$12.00"),
806 + Cell::tag(Tag::badge("Refunded").tone(layout::Tone::Warning)),
807 + ])],
808 + });
809 +
810 + assert!(html.contains("cell-tokens"), "{html}");
811 + assert!(
812 + html.contains("class=\"badge\" data-tone=\"warning\""),
813 + "{html}"
814 + );
815 + assert!(html.contains("Refunded"), "{html}");
816 + }
817 +
818 + #[test]
819 + fn a_chip_in_a_cell_is_a_control_and_a_badge_is_not() {
820 + // The bubbling guard has to cover both kinds of control a cell can hold,
821 + // or a chip that answers a click opens the row as well as answering it.
822 + let chip = fragment(&Node::Table {
823 + columns: vec![Column::new("Tag").width(layout::Width::Content)],
824 + rows: vec![
825 + Cells::new([Cell::tag(Tag::chip("Open", Action::get("/tasks?open=1")))])
826 + .activate(Action::get("/tasks/1")),
827 + ],
828 + });
829 + assert!(chip.contains("data-act"), "{chip}");
830 + assert!(chip.contains("closest("), "{chip}");
831 +
832 + // A badge answers nothing, so it is not a control and the row keeps htmx's
833 + // bare default.
834 + let badge = fragment(&Node::Table {
835 + columns: vec![Column::new("Status").width(layout::Width::Content)],
836 + rows: vec![Cells::new([Cell::tag(Tag::badge("Paid"))]).activate(Action::get("/sales/1"))],
837 + });
838 + assert!(!badge.contains("data-act"), "{badge}");
839 + assert!(!badge.contains("hx-trigger"), "{badge}");
840 + }
841 +
793 842 #[test]
794 843 fn a_cell_of_plain_text_is_still_a_string() {
795 844 // The `From<&str>` that keeps every value-only table unchanged. Without it