Skip to main content

max / quasi

Emit the class names makeover actually defines A described screen was emitting `act`, `tone-info`, `tone-success`, `tone-warning`, `tone-danger` and `chip-latched`. makeover has never defined one of them, and no app had rendered a description into a browser before, so nothing had noticed. That is why the SSH-keys tab came out as unstyled text beside a hand-written tab that looked right. Each has a name already. A control is `button`, which carries makeover's whole interactive set and was never a different concept from `act`. A tone is `data-tone`, which is how the badge, the progress fill, the figure and the stand-in all spell it, and it comes from Tone::token rather than a match here so a tone added upstream cannot be named twice. A held-down chip is `latched`, matching `.chip.latched`. A table's headings now carry their column's classes too, taken from makeover's own column_classes. The header is emitted here and the cells by cells_html, so it is the one place the two lists could disagree, and disagreeing means a narrow viewport drops a column's cells while keeping its heading and every heading past the cut sits over the wrong values.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 23:10 UTC
Signed with PGP, not checked
Commit: f287ac165a8845545ae3ebe430de66de7f25746b
Parent: b4e3e21
4 files changed, +131 insertions, -53 deletions
M Cargo.lock +2 -2
@@ -1930,9 +1930,9 @@
1930 1930
1931 1931 [[package]]
1932 1932 name = "makeover-webview"
1933 - version = "0.22.1"
1933 + version = "0.23.0"
1934 1934 source = "registry+https://github.com/rust-lang/crates.io-index"
1935 - checksum = "9110b5a405a2378d2ef7d93efedc2b65f4bdd32a3de902ee8192b20a8dc3f7f5"
1935 + checksum = "0303225f8e8be6ef237ed268652e7fbfd501bd17843323680a18a9df30b38fe3"
1936 1936 dependencies = [
1937 1937 "makeover-geometry",
1938 1938 "makeover-layout",
@@ -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.12.0"
19 - makeover-webview = "0.22.1"
19 + makeover-webview = "0.23.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 #
@@ -23,13 +23,16 @@
23 23 use std::fmt::Write as _;
24 24
25 25 use makeover_layout as layout;
26 + // `Tone::token` is the trait method, and `data-tone` is spelled from it rather
27 + // than from a match here, so a tone added upstream cannot be named two ways.
28 + use makeover_layout::Intent as _;
26 29 use makeover_webview::Emit;
27 30 use makeover_webview::figure::figure_html;
28 31 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
29 32 // `Cell` is a name both crates use: makeover's is the emitted table cell, ours
30 33 // is the described one. Aliased rather than qualified at the call site, so the
31 34 // two never read as the same type.
32 - use makeover_webview::list::{Cell as Emitted, cells_html};
35 + use makeover_webview::list::{Cell as Emitted, cells_html, column_classes};
33 36 use makeover_webview::meter::meter_html;
34 37 use makeover_webview::placeholder::placeholder_html;
35 38 use quasi_router::screen::{Act, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
@@ -52,18 +55,26 @@
52 55 out.push('"');
53 56 }
54 57
55 - /// The class suffix naming a tone.
58 + /// Write the attribute naming a tone, if the tone is worth naming.
56 59 ///
57 - /// [`Tone::Neutral`] has none: ordinary content is the default, and a class
58 - /// meaning "nothing unusual" is a class on every element in the document.
59 - fn tone_class(tone: layout::Tone) -> Option<&'static str> {
60 - match tone {
61 - layout::Tone::Neutral => None,
62 - layout::Tone::Info => Some("tone-info"),
63 - layout::Tone::Success => Some("tone-success"),
64 - layout::Tone::Warning => Some("tone-warning"),
65 - layout::Tone::Danger => Some("tone-danger"),
60 + /// [`Tone::Neutral`] writes nothing: ordinary content is the default, and an
61 + /// attribute meaning "nothing unusual" is an attribute on every element in the
62 + /// document.
63 + ///
64 + /// An attribute and not a class, which is the correction. This emitted
65 + /// `tone-info`, `tone-success`, `tone-warning` and `tone-danger` as classes, and
66 + /// makeover has never defined one of them: its whole vocabulary keys tone off
67 + /// `data-tone`, from `.badge[data-tone="danger"]` to the progress fill to a
68 + /// figure's value. So every toned thing a description produced arrived with a
69 + /// class no stylesheet in the tree had heard of, which is why the SSH-keys tab's
70 + /// Remove button came out the same colour as everything else.
71 + fn tone_attr(tone: layout::Tone, out: &mut String) {
72 + if matches!(tone, layout::Tone::Neutral) {
73 + return;
66 74 }
75 + out.push_str(" data-tone=\"");
76 + out.push_str(tone.token());
77 + out.push('"');
67 78 }
68 79
69 80 /// What goes back in the box, for a form being offered again after a refusal.
@@ -375,14 +386,16 @@
375 386
376 387 /// A control that calls a route.
377 388 pub(crate) fn act_html(act: &Act, morphs: bool, opts: &Emit, out: &mut String) {
378 - let mut classes = vec!["act"];
379 - if let Some(tone) = tone_class(act.tone) {
380 - classes.push(tone);
381 - }
382 -
389 + // `button`, which is makeover's name for this and carries its whole
390 + // interactive set: the raised bevel, the hover fill, the pressed inset, the
391 + // focus ring and the disabled treatment. This emitted `act`, a second name
392 + // for the same thing that no stylesheet in the tree defined, so a described
393 + // control rendered as unstyled text. There was never a concept here that
394 + // `button` was not already the word for.
383 395 let (open, close) = control_tag(&act.action);
384 396 out.push_str(open);
385 - class_attr(&classes, opts, out);
397 + class_attr(&["button"], opts, out);
398 + tone_attr(act.tone, out);
386 399 // Named rather than found by class, so anything binding to "this is a
387 400 // control" survives a host setting `Emit::class_prefix`. `Fires::ClickBeside`
388 401 // is the first reader; a table row uses it to tell its own click apart from
@@ -673,12 +686,9 @@
673 686 }
674 687
675 688 Node::Text { text, tone } => {
676 - let mut classes = vec!["text"];
677 - if let Some(tone) = tone_class(*tone) {
678 - classes.push(tone);
679 - }
680 689 out.push_str("<p");
681 - class_attr(&classes, opts, out);
690 + class_attr(&["text"], opts, out);
691 + tone_attr(*tone, out);
682 692 out.push('>');
683 693 out.push_str(&escape(text));
684 694 out.push_str("</p>");
@@ -718,15 +728,16 @@
718 728 Node::Token(tag) => tag_html(tag, morphs, opts, out),
719 729
720 730 Node::Notice { kind, tone, text } => {
721 - let mut classes = vec![match kind {
722 - layout::Notice::Toast => "toast",
723 - layout::Notice::Banner => "banner",
724 - }];
725 - if let Some(tone) = tone_class(*tone) {
726 - classes.push(tone);
727 - }
728 731 out.push_str("<div");
729 - class_attr(&classes, opts, out);
732 + class_attr(
733 + &[match kind {
734 + layout::Notice::Toast => "toast",
735 + layout::Notice::Banner => "banner",
736 + }],
737 + opts,
738 + out,
739 + );
740 + tone_attr(*tone, out);
730 741 // A danger or warning notice interrupts; anything else waits for a
731 742 // pause. The description already says which through its tone, so
732 743 // the renderer does not need a second field to be told.
@@ -756,7 +767,7 @@
756 767 field_group_html(field, morphs, opts, out);
757 768 }
758 769 out.push_str("<button type=\"submit\"");
759 - class_attr(&["act", "act-submit"], opts, out);
770 + class_attr(&["button", "act-submit"], opts, out);
760 771 out.push('>');
761 772 out.push_str(&escape(submit));
762 773 out.push_str("</button></form>");
@@ -782,7 +793,7 @@
782 793
783 794 let (open, close) = control_tag(&rest.action);
784 795 out.push_str(open);
785 - class_attr(&["act", "rest-more"], opts, out);
796 + class_attr(&["button", "rest-more"], opts, out);
786 797 action_attrs(&rest.action, Fires::Click, None, morphs, out);
787 798 out.push('>');
788 799 match rest.remaining {
@@ -803,13 +814,15 @@
803 814 .iter()
804 815 .map(quasi_router::screen::Column::as_layout)
805 816 .collect();
806 - // No inline grid tracks. `makeover_webview::list::narrowing_css`
807 - // emits the track list and the per-column hiding together, per
808 - // breakpoint, from these same columns; writing tracks here as well
809 - // would put the widest layout in the markup where it outranks the
810 - // narrow rules, which is goingson's mobile bug reintroduced from
811 - // the other side. The markup owes the cells their column classes
812 - // and nothing more, and `cells_html` is what knows those.
817 + // No CSS travels with the table, and none can. A described table's
818 + // columns are known here rather than at build time, so a track list
819 + // would have to be emitted per table: a `<style>` element beside it,
820 + // which needs `style-src 'unsafe-inline'`, or a head block, which a
821 + // fragment swap does not carry. makeover's stylesheet lays the table
822 + // out with `display: table` instead, aligning columns across rows
823 + // knowing nothing about how many there are, and hides a dropped
824 + // column by the drop class its cells carry. The markup owes the
825 + // cells and headings those classes and nothing more.
813 826 out.push_str("<div role=\"table\"");
814 827 class_attr(&["table"], opts, out);
815 828 out.push('>');
@@ -819,7 +832,16 @@
819 832 out.push('>');
820 833 for (column, described) in borrowed.iter().zip(columns) {
821 834 out.push_str("<span role=\"columnheader\"");
822 - class_attr(&["table-heading"], opts, out);
835 + // The heading carries the same classes its column's cells do,
836 + // or the header and the body disagree about which column just
837 + // dropped and every heading below the cut sits over the wrong
838 + // values. `column_classes` is makeover's, so the two lists
839 + // cannot be assembled differently in two places.
840 + out.push_str(" class=\"");
841 + out.push_str(&escape(&class("table-heading", opts)));
842 + out.push(' ');
843 + out.push_str(&escape(&column_classes(column, opts)));
844 + out.push('"');
823 845 // `aria-sort` is what a table actually says about its order;
824 846 // the caret in `state_rules` is this renderer's expression of
825 847 // the same fact for everyone not using a screen reader.
@@ -928,11 +950,12 @@
928 950 layout::Token::Badge => "badge",
929 951 layout::Token::Chip { .. } => "chip",
930 952 }];
931 - if let Some(tone) = tone_class(tone) {
932 - classes.push(tone);
933 - }
953 + // `latched`, which is what makeover styles: `.chip.latched` is the pressed
954 + // depth a chip holds itself down with. This said `chip-latched`, a third
955 + // name for it, and a latched chip therefore looked exactly like an
956 + // unlatched one.
934 957 if latched {
935 - classes.push("chip-latched");
958 + classes.push("latched");
936 959 }
937 960
938 961 // A badge answers no click, so it is not a button however it is styled.
@@ -951,6 +974,7 @@
951 974 "</span>"
952 975 };
953 976 class_attr(&classes, opts, out);
977 + tone_attr(tone, out);
954 978
955 979 if interactive {
956 980 if latched {
@@ -693,7 +693,7 @@
693 693 Cell::new("SHA256:abc"),
694 694 Cell::new("fw13"),
695 695 Cell::acts([
696 - Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger),
696 + Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger)
697 697 ]),
698 698 ])],
699 699 });
@@ -717,10 +717,9 @@
717 717 let html = fragment(&Node::Table {
718 718 columns: vec![Column::new("Slug").width(layout::Width::Fill)],
719 719 rows: vec![
720 - Cells::new([Cell::new("my-app").act(Act::new(
721 - "Set slug",
722 - Action::post("/apps/3/slug"),
723 - ))])
720 + Cells::new([
721 + Cell::new("my-app").act(Act::new("Set slug", Action::post("/apps/3/slug")))
722 + ])
724 723 .activate(Action::get("/apps/3")),
725 724 ],
726 725 });
@@ -739,13 +738,68 @@
739 738 assert!(!plain.contains("hx-trigger"));
740 739 }
741 740
741 + #[test]
742 + fn every_class_this_renderer_emits_is_one_makeover_defines() {
743 + // The third gap the SSH-keys tab found. A described screen was emitting
744 + // `act`, `tone-danger` and `chip-latched`, none of which makeover has ever
745 + // defined, so a described control rendered as unstyled text next to a
746 + // hand-written one that did not.
747 + let html = fragment(&Node::list([Row::new("fw13").act(
748 + Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger),
749 + )]));
750 +
751 + assert!(html.contains("class=\"button\""), "{html}");
752 + assert!(html.contains("data-tone=\"danger\""), "{html}");
753 + assert!(!html.contains("\"act\""), "{html}");
754 + assert!(!html.contains("tone-danger"), "{html}");
755 +
756 + let chip = fragment(&Node::Token(
757 + Tag::chip("Open", Action::get("/tasks?open=1")).latched(true),
758 + ));
759 + assert!(chip.contains("latched"), "{chip}");
760 + assert!(!chip.contains("chip-latched"), "{chip}");
761 + }
762 +
763 + #[test]
764 + fn a_table_heading_drops_with_the_cells_below_it() {
765 + // The header is emitted here rather than by `cells_html`, so it is the one
766 + // place the two class lists could disagree. If they do, a narrow viewport
767 + // drops a column's cells and keeps its heading, and every heading past the
768 + // cut sits over the wrong values.
769 + let html = fragment(&Node::Table {
770 + columns: vec![
771 + Column::new("Name")
772 + .width(layout::Width::Fill)
773 + .priority(layout::Priority::Essential),
774 + Column::new("Used")
775 + .width(layout::Width::Content)
776 + .priority(layout::Priority::Optional),
777 + ],
778 + rows: vec![Cells::new(["deploy", "Aug 9"])],
779 + });
780 +
781 + for classes in [
782 + "col-Name cell-fill cell-keeps",
783 + "col-Used cell-content cell-drops-first",
784 + ] {
785 + assert_eq!(
786 + html.matches(classes).count(),
787 + 2,
788 + "the heading and its cell must carry the same classes: {html}"
789 + );
790 + }
791 + }
792 +
742 793 #[test]
743 794 fn a_cell_of_plain_text_is_still_a_string() {
744 795 // The `From<&str>` that keeps every value-only table unchanged. Without it
745 796 // the member would have cost every existing caller a rewrite for a feature
746 797 // it does not use.
747 798 let cells = Cells::new(["kick.wav", "2.1 MB"]);
748 - assert_eq!(cells.values, vec![Cell::new("kick.wav"), Cell::new("2.1 MB")]);
799 + assert_eq!(
800 + cells.values,
801 + vec![Cell::new("kick.wav"), Cell::new("2.1 MB")]
802 + );
749 803 assert!(cells.values.iter().all(|cell| cell.actions.is_empty()));
750 804 }
751 805