Skip to main content

max / quasi

A table cell names its parts in the shared vocabulary rather than in ours Takes makeover-layout 0.14.0 and makeover-webview 0.25.1, which is where CellPart landed and where the classes behind it are generated. This crate has been emitting cell-tokens, cell-actions and cell-activate since 022f0c59, against a stylesheet that had rules for none of them: makeover emitted one undifferentiated .cell, so a button in a cell inherited the cell's text colour. The names were ours to invent because the vocabulary had none. cell-activate becomes cell-link, which is CellPart::Link's spelling, and the three now have rules: .cell-value takes the content colour and the other three take none. Which mechanism a cell uses depends on what it holds. A cell that is only its value says so on the container through list::Cell::part, where a wrapper span would add an element and no information. A cell that mixes names its parts inside, because a colour on the container reaches all of them, and that is the drift the vocabulary exists to end. An actions-only cell emits no empty value span for a value it does not have.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 18:35 UTC
Signed with PGP, not checked
Commit: 9b62325fa0541b690b4f7e69a28ab6d0b211f22f
Parent: 1c48b9f
5 files changed, +97 insertions, -28 deletions
M Cargo.lock +10 -16
@@ -1914,34 +1914,28 @@
1914 1914
1915 1915 [[package]]
1916 1916 name = "makeover-layout"
1917 - version = "0.12.0"
1917 + version = "0.14.0"
1918 1918 source = "registry+https://github.com/rust-lang/crates.io-index"
1919 - checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
1920 -
1921 - [[package]]
1922 - name = "makeover-layout"
1923 - version = "0.13.0"
1924 - source = "registry+https://github.com/rust-lang/crates.io-index"
1925 - checksum = "3e503a11485fe2e17d9e7b0326cb7fef8a9ff0968e1d37d101b45ea08298b0b9"
1919 + checksum = "e08cfaa62476d03061dc86a2befc0c24129399ba37a6f7645fbb72b011abf2f0"
1926 1920
1927 1921 [[package]]
1928 1922 name = "makeover-touch"
1929 - version = "0.8.1"
1923 + version = "0.8.2"
1930 1924 source = "registry+https://github.com/rust-lang/crates.io-index"
1931 - checksum = "d9427a7a6890849f5c815a0e77a63faa4791d7a37e7efaff4802bcafa3b8235a"
1925 + checksum = "42481d14654fcbe37a3b2fcfca239448e3c6c7cf474e26a9837b02447fbb1e5c"
1932 1926 dependencies = [
1933 1927 "makeover-geometry",
1934 - "makeover-layout 0.12.0",
1928 + "makeover-layout",
1935 1929 ]
1936 1930
1937 1931 [[package]]
1938 1932 name = "makeover-webview"
1939 - version = "0.24.0"
1933 + version = "0.25.1"
1940 1934 source = "registry+https://github.com/rust-lang/crates.io-index"
1941 - checksum = "6e88bf1eafa0806bbef3002435b91bfb28ce25a989fe484f19eb1e16c58eafc6"
1935 + checksum = "41fb147debeae14c46a48286459712f70cd89d1cce49595fd46a947528c302d5"
1942 1936 dependencies = [
1943 1937 "makeover-geometry",
1944 - "makeover-layout 0.13.0",
1938 + "makeover-layout",
1945 1939 "makeover-touch",
1946 1940 ]
1947 1941
@@ -2622,7 +2616,7 @@
2622 2616 name = "quasi-router"
2623 2617 version = "0.1.0"
2624 2618 dependencies = [
2625 - "makeover-layout 0.13.0",
2619 + "makeover-layout",
2626 2620 ]
2627 2621
2628 2622 [[package]]
@@ -2651,7 +2645,7 @@
2651 2645 version = "0.1.0"
2652 2646 dependencies = [
2653 2647 "docengine",
2654 - "makeover-layout 0.13.0",
2648 + "makeover-layout",
2655 2649 "makeover-webview",
2656 2650 "quasi-http",
2657 2651 "quasi-router",
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.13.0"
16 + makeover-layout = "0.14.0"
@@ -15,8 +15,8 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 - makeover-layout = "0.13.0"
19 - makeover-webview = "0.24.0"
18 + makeover-layout = "0.14.0"
19 + makeover-webview = "0.25.1"
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 #
@@ -684,11 +684,24 @@
684 684 // `data-act` for the same reason a button carries it. The row's
685 685 // `ClickBeside` filter keys on that attribute, so without it a click
686 686 // on the title would follow the link and open the row underneath.
687 + // Whether the value shares the cell with anything. A cell that is
688 + // only its value says so on the container through `CellPart::Value`
689 + // below, and needs no wrapper; one that mixes has to name its parts
690 + // inside, or the content colour on the cell would reach the tokens
691 + // and the controls beside the text. That is the drift
692 + // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
693 + // emitting.
694 + let mixes = !cell.tokens.is_empty() || !cell.actions.is_empty();
695 +
687 696 let mut inner = match &cell.activate {
688 697 Some(action) => {
689 698 let (open, close) = control_tag(action);
690 699 let mut linked = String::from(open);
691 - class_attr(&["cell-activate"], opts, &mut linked);
700 + // `cell-link`, which is what `CellPart::Link` is spelled as
701 + // in the generated stylesheet. It was `cell-activate`, a
702 + // name this crate invented while the shared vocabulary had
703 + // none, and a class with no rule behind it.
704 + class_attr(&["cell-link"], opts, &mut linked);
692 705 linked.push_str(" data-act");
693 706 action_attrs(action, Fires::Click, None, morphs, &mut linked);
694 707 linked.push('>');
@@ -696,6 +709,17 @@
696 709 linked.push_str(close);
697 710 linked
698 711 }
712 + // An actions-only or tokens-only cell has no value, and an
713 + // empty span for the one it does not have is an element saying
714 + // nothing.
715 + None if mixes && !cell.value.is_empty() => {
716 + let mut valued = String::from("<span");
717 + class_attr(&["cell-value"], opts, &mut valued);
718 + valued.push('>');
719 + valued.push_str(&escape(&cell.value));
720 + valued.push_str("</span>");
721 + valued
722 + }
699 723 None => escape(&cell.value),
700 724 };
701 725 // Tags after the text and before the controls, which is the
@@ -711,10 +735,10 @@
711 735 inner.push_str("</span>");
712 736 }
713 737 if !cell.actions.is_empty() {
714 - // Deliberately not `row-actions`: makeover hides that one until
715 - // the row is hovered or focused, which is defensible on a dense
716 - // list and wrong for a table whose last column exists to hold
717 - // the button. A cell's controls are always shown.
738 + // Deliberately not `row-actions`. That was a hover-reveal rule
739 + // until makeover-webview 0.23.0 retired it, and it is a list
740 + // row's class besides: `cell-actions` is the table's own, and
741 + // it carries no colour so a button here is not painted as text.
718 742 inner.push_str("<span");
719 743 class_attr(&["cell-actions"], opts, &mut inner);
720 744 inner.push('>');
@@ -726,6 +750,21 @@
726 750 inner
727 751 })
728 752 .collect();
753 + // The container says what the cell is only when the cell is nothing but its
754 + // value, which is the case where a wrapper span would say nothing the
755 + // container has not already said. Anything else names its parts inside --
756 + // the anchor is a `cell-link`, the strips are `cell-tokens` and
757 + // `cell-actions` -- because a colour on the container would reach all of
758 + // them, and that is the drift makeover-layout 0.14.0 named.
759 + let parts: Vec<Option<layout::CellPart>> = cells
760 + .values
761 + .iter()
762 + .map(|cell| {
763 + let only_a_value =
764 + cell.tokens.is_empty() && cell.actions.is_empty() && cell.activate.is_none();
765 + only_a_value.then_some(layout::CellPart::Value)
766 + })
767 + .collect();
729 768 let borrowed: Vec<layout::Column<'_>> = columns
730 769 .iter()
731 770 .map(quasi_router::screen::Column::as_layout)
@@ -733,7 +772,12 @@
733 772 let cells: Vec<Emitted<'_>> = borrowed
734 773 .iter()
735 774 .zip(filled.iter())
736 - .map(|(column, value)| Emitted::new(column.name, Markup(value)))
775 + .zip(parts)
776 + .map(|((column, value), part)| Emitted {
777 + column: column.name,
778 + part,
779 + content: Markup(value),
780 + })
737 781 .collect();
738 782 out.push_str(&cells_html(&borrowed, &cells, opts));
739 783
@@ -726,9 +726,37 @@
726 726 assert!(html.contains("hx-post=\"/keys/7/delete\""));
727 727 assert!(html.contains("cell-actions"));
728 728
729 - // Not `row-actions`, which makeover holds at `opacity: 0` until the row is
730 - // hovered. A table's actions column exists to show the button.
729 + // Not `row-actions`, which is a list row's class. A table's actions column
730 + // exists to show the button, and `cell-actions` is the class the generated
731 + // stylesheet gives no colour, so the button is not painted as text.
731 732 assert!(!html.contains("row-actions"));
733 +
734 + // A cell that is only its value says so on the container, where a wrapper
735 + // span would add an element and no information.
736 + assert!(html.contains("cell-value"), "{html}");
737 + assert!(!html.contains("<span class=\"cell-value\""), "{html}");
738 + }
739 +
740 + #[test]
741 + fn a_cell_that_mixes_parts_names_them_inside_rather_than_on_itself() {
742 + // makeover-layout 0.14.0's CellPart, and the reason it is four members
743 + // rather than a flag. A cell holding text AND tokens AND a control is three
744 + // parts in one container: a content colour on the container would reach the
745 + // badge and the button, which is the drift the vocabulary ends.
746 + let html = fragment(&Node::Table {
747 + columns: vec![Column::new("Item").width(layout::Width::Fill)],
748 + rows: vec![Cells::new([Cell::new("Release notes")
749 + .token(Tag::badge("Published"))
750 + .act(Act::new("Remove", Action::post("/blog/7/delete")))])],
751 + });
752 +
753 + // Each part named where it is, inside the cell.
754 + assert!(html.contains("<span class=\"cell-value\">"), "{html}");
755 + assert!(html.contains("cell-tokens"), "{html}");
756 + assert!(html.contains("cell-actions"), "{html}");
757 +
758 + // And the container claims none of them, or the colour would cascade.
759 + assert!(!html.contains("cell-keeps cell-value"), "{html}");
732 760 }
733 761
734 762 #[test]
@@ -877,7 +905,10 @@
877 905 // A read is an anchor with a real href, so middle-click and copy-link work
878 906 // and the page is still navigable with JS off.
879 907 assert!(html.contains("href=\"/blog/7\""), "{html}");
880 - assert!(html.contains("cell-activate"), "{html}");
908 + // `cell-link`, which is what makeover-layout 0.14.0's `CellPart::Link` is
909 + // spelled as in the generated stylesheet. It was `cell-activate` while this
910 + // crate was inventing the name itself.
911 + assert!(html.contains("cell-link"), "{html}");
881 912 assert!(html.contains(">Release notes</a>"), "{html}");
882 913
883 914 // The value is still text, whatever the value happens to say.