Skip to main content

max / quasi

Emit the selector classes makeover styles, and check the whole vocabulary A described selector came out flat. `select_html` spelled the classes `tabs`, `segmented` and `option`, and makeover styles `.tab`, `.segment` and `.toggle` with `.chosen` for the picked one, so none of the depth, focus or chosen-state rules ever matched. `toggle` did match, on the wrapping div rather than on the buttons, so a toggle group took the bevel meant for its options. The names now come from `makeover_webview::option_class`, along with `class` and the two part-class mappings this crate had copies of. The group keeps one name and says which kind it is in `data-selector`, an attribute for the reason `data-tone` is one. `every_class_this_renderer_emits_is_one_makeover_defines` asserted its name for three specific past bugs and would not have caught a fourth. It now renders every node variant and every region kind, pulls the classes out of the markup, and partitions them against the generated stylesheet: BY_DESIGN for containers and arrangements makeover deliberately says nothing about, MODIFIERS for names that sit beside a styled class, GAPS for components that reach the markup with no rule anywhere. GAPS has twelve entries and each is work rather than a decision -- makeover's own form and placeholder emitters, the narrowing classes nothing generates CSS for, and Notice.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 00:58 UTC
Signed with PGP, not checked
Commit: e89b8840de90c7f0b21619b2a4189aa3afdc6e05
Parent: abe62c6
6 files changed, +379 insertions, -33 deletions
M Cargo.lock +2 -2
@@ -1936,9 +1936,9 @@
1936 1936
1937 1937 [[package]]
1938 1938 name = "makeover-webview"
1939 - version = "0.26.0"
1939 + version = "0.27.0"
1940 1940 source = "registry+https://github.com/rust-lang/crates.io-index"
1941 - checksum = "104d377d660eabeace98de31fab5dfb63f6ccbf679a68c2e761cde29f7f79062"
1941 + checksum = "4fc9a4c9e84745dcbd4a009ac4fd4d12af611e6ddeb8258d7b517423b5143aff"
1942 1942 dependencies = [
1943 1943 "makeover-geometry",
1944 1944 "makeover-layout 0.15.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.15.0"
19 - makeover-webview = "0.26.0"
19 + makeover-webview = "0.27.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 #
@@ -149,7 +149,7 @@
149 149 .open(&screen.title, Some(&screen.discovery), &mut out);
150 150
151 151 out.push_str("<main class=\"");
152 - out.push_str(&node::class(
152 + out.push_str(&makeover_webview::class(
153 153 Self::arrangement_class(screen.arrangement),
154 154 &self.emit,
155 155 ));
@@ -161,7 +161,7 @@
161 161 // stylesheet's answer.
162 162 if !screen.notices.is_empty() {
163 163 out.push_str("<div class=\"");
164 - out.push_str(&node::class("notices", &self.emit));
164 + out.push_str(&makeover_webview::class("notices", &self.emit));
165 165 out.push_str("\">");
166 166 for notice in &screen.notices {
167 167 node::node_html(
@@ -31,23 +31,26 @@
31 31 // `Tone::token` is the trait method, and `data-tone` is spelled from it rather
32 32 // than from a match here, so a tone added upstream cannot be named two ways.
33 33 use makeover_layout::Intent as _;
34 - use makeover_webview::Emit;
35 34 use makeover_webview::figure::figure_html;
36 35 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
36 + // `class`, `option_class` and the two part-class mappings below are makeover's,
37 + // not copies of it. They were copies until makeover-webview 0.27.0 made them
38 + // public: the prefix helper was byte-identical, and the row and cell part names
39 + // were a second spelling of a list whose own doc comment carries an obligation
40 + // to be grepped on upgrade. A second spelling is a second place to forget, and
41 + // the selector names had already drifted.
42 + use makeover_webview::{Emit, class, option_class};
37 43 // `Cell` is a name both crates use: makeover's is the emitted table cell, ours
38 44 // is the described one. Aliased rather than qualified at the call site, so the
39 45 // two never read as the same type.
40 - use makeover_webview::list::{Cell as Emitted, cells_html, column_classes};
46 + use makeover_webview::list::{
47 + Cell as Emitted, cell_part_class, cells_html, column_classes, part_class,
48 + };
41 49 use makeover_webview::meter::meter_html;
42 50 use makeover_webview::placeholder::placeholder_html;
43 51 use quasi_router::screen::{Act, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
44 52 use quasi_router::{Action, Method, Params};
45 53
46 - /// The class-name prefix, applied through [`Emit::class_prefix`].
47 - pub(crate) fn class(name: &str, opts: &Emit) -> String {
48 - format!("{}{name}", opts.class_prefix)
49 - }
50 -
51 54 /// Write a `class="..."` attribute, prefixed.
52 55 fn class_attr(names: &[&str], opts: &Emit, out: &mut String) {
53 56 out.push_str(" class=\"");
@@ -546,7 +549,7 @@
546 549 }
547 550 None => {
548 551 out.push_str("<span");
549 - class_attr(&["row-primary"], opts, out);
552 + class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
550 553 out.push('>');
551 554 out.push_str(&escape(&row.primary));
552 555 out.push_str("</span>");
@@ -555,7 +558,7 @@
555 558
556 559 if let Some(secondary) = &row.secondary {
557 560 out.push_str("<span");
558 - class_attr(&["row-secondary"], opts, out);
561 + class_attr(&[part_class(layout::RowPart::Secondary)], opts, out);
559 562 out.push('>');
560 563 out.push_str(&row_prose_html(secondary));
561 564 out.push_str("</span>");
@@ -563,7 +566,7 @@
563 566
564 567 if let Some(meta) = &row.meta {
565 568 out.push_str("<span");
566 - class_attr(&["row-meta"], opts, out);
569 + class_attr(&[part_class(layout::RowPart::Meta)], opts, out);
567 570 out.push('>');
568 571 out.push_str(&escape(meta));
569 572 out.push_str("</span>");
@@ -592,7 +595,7 @@
592 595 // its tones are makeover-webview's, unchanged, same as everywhere else.
593 596 if let Some(meter) = &row.meter {
594 597 out.push_str("<span");
595 - class_attr(&["row-proportion"], opts, out);
598 + class_attr(&[part_class(layout::RowPart::Proportion)], opts, out);
596 599 out.push('>');
597 600 out.push_str(&meter_html(&meter.as_layout(), opts));
598 601 out.push_str("</span>");
@@ -604,7 +607,7 @@
604 607 // `intent` falls off in the same order and never rises again.
605 608 if !row.tokens.is_empty() {
606 609 out.push_str("<span");
607 - class_attr(&["row-tokens"], opts, out);
610 + class_attr(&[part_class(layout::RowPart::Tokens)], opts, out);
608 611 out.push('>');
609 612 for tag in &row.tokens {
610 613 tag_html(tag, morphs, opts, out);
@@ -614,7 +617,7 @@
614 617
615 618 if !row.actions.is_empty() {
616 619 out.push_str("<span");
617 - class_attr(&["row-actions"], opts, out);
620 + class_attr(&[part_class(layout::RowPart::Actions)], opts, out);
618 621 out.push('>');
619 622 for act in &row.actions {
620 623 act_html(act, morphs, opts, out);
@@ -701,7 +704,11 @@
701 704 // in the generated stylesheet. It was `cell-activate`, a
702 705 // name this crate invented while the shared vocabulary had
703 706 // none, and a class with no rule behind it.
704 - class_attr(&["cell-link"], opts, &mut linked);
707 + class_attr(
708 + &[cell_part_class(layout::CellPart::Link)],
709 + opts,
710 + &mut linked,
711 + );
705 712 linked.push_str(" data-act");
706 713 action_attrs(action, Fires::Click, None, morphs, &mut linked);
707 714 linked.push('>');
@@ -714,7 +721,11 @@
714 721 // nothing.
715 722 None if mixes && !cell.value.is_empty() => {
716 723 let mut valued = String::from("<span");
717 - class_attr(&["cell-value"], opts, &mut valued);
724 + class_attr(
725 + &[cell_part_class(layout::CellPart::Value)],
726 + opts,
727 + &mut valued,
728 + );
718 729 valued.push('>');
719 730 valued.push_str(&escape(&cell.value));
720 731 valued.push_str("</span>");
@@ -727,7 +738,11 @@
727 738 // then what has a standing of its own, then what can be done to it.
728 739 if !cell.tokens.is_empty() {
729 740 inner.push_str("<span");
730 - class_attr(&["cell-tokens"], opts, &mut inner);
741 + class_attr(
742 + &[cell_part_class(layout::CellPart::Tokens)],
743 + opts,
744 + &mut inner,
745 + );
731 746 inner.push('>');
732 747 for tag in &cell.tokens {
733 748 tag_html(tag, morphs, opts, &mut inner);
@@ -740,7 +755,11 @@
740 755 // row's class besides: `cell-actions` is the table's own, and
741 756 // it carries no colour so a button here is not painted as text.
742 757 inner.push_str("<span");
743 - class_attr(&["cell-actions"], opts, &mut inner);
758 + class_attr(
759 + &[cell_part_class(layout::CellPart::Actions)],
760 + opts,
761 + &mut inner,
762 + );
744 763 inner.push('>');
745 764 for act in &cell.actions {
746 765 act_html(act, morphs, opts, &mut inner);
@@ -1132,6 +1151,23 @@
1132 1151 }
1133 1152
1134 1153 /// A control that picks between things.
1154 + ///
1155 + /// # Which element carries the kind
1156 + ///
1157 + /// The option does, because that is what makeover styles: `selector_rules`
1158 + /// writes the depth, the focus ring and the chosen state for `.tab`,
1159 + /// `.segment` and `.toggle`, and each of those is the thing that gets picked
1160 + /// rather than the thing holding them. This emitted `tabs` / `segmented` /
1161 + /// `option` and put `toggle` on the wrapping div, so every described selector
1162 + /// came out flat, and a toggle group took the bevel meant for its buttons. The
1163 + /// fourth instance of the gap the SSH-keys tab found three of, and the reason
1164 + /// `every_class_this_renderer_emits_is_one_makeover_defines` now enumerates
1165 + /// instead of remembering.
1166 + ///
1167 + /// The group keeps one name, `selector`, and says which kind it is in
1168 + /// `data-selector`. An attribute for the same reason `data-tone` is one: the
1169 + /// group is a spacing question, spacing is `makeover-geometry`'s, and a class
1170 + /// there would read as the styling hook the option's class actually is.
1135 1171 fn select_html(
1136 1172 kind: layout::Selector,
1137 1173 options: &[(quasi_router::screen::Choice, Option<Action>)],
@@ -1141,14 +1177,11 @@
1141 1177 opts: &Emit,
1142 1178 out: &mut String,
1143 1179 ) {
1144 - let group = match kind {
1145 - layout::Selector::Segmented => "segmented",
1146 - layout::Selector::Toggle => "toggle",
1147 - layout::Selector::Tabs => "tabs",
1148 - };
1149 -
1150 1180 out.push_str("<div");
1151 - class_attr(&[group], opts, out);
1181 + class_attr(&["selector"], opts, out);
1182 + out.push_str(" data-selector=\"");
1183 + out.push_str(option_class(kind));
1184 + out.push('"');
1152 1185 // Tabs are navigation between panes, which is a tablist. The other two pick
1153 1186 // a value and are a group of buttons.
1154 1187 if matches!(kind, layout::Selector::Tabs) {
@@ -1160,9 +1193,10 @@
1160 1193
1161 1194 for (option, own) in options {
1162 1195 let picked = chosen.is_some_and(|value| value == option.value);
1163 - let mut classes = vec!["option"];
1196 + // `chosen` is makeover's name for this, the way `latched` is on a chip.
1197 + let mut classes = vec![option_class(kind)];
1164 1198 if picked {
1165 - classes.push("option-chosen");
1199 + classes.push("chosen");
1166 1200 }
1167 1201
1168 1202 // An option naming its own route is a link when that route is a read,
@@ -1000,7 +1000,7 @@
1000 1000 }
1001 1001
1002 1002 #[test]
1003 - fn every_class_this_renderer_emits_is_one_makeover_defines() {
1003 + fn the_ssh_keys_tab_gaps_stay_shut() {
1004 1004 // The third gap the SSH-keys tab found. A described screen was emitting
1005 1005 // `act`, `tone-danger` and `chip-latched`, none of which makeover has ever
1006 1006 // defined, so a described control rendered as unstyled text next to a
@@ -1969,3 +1969,315 @@
1969 1969 assert!(html.contains("&quot;hi&quot;"), "{html}");
1970 1970 assert!(html.contains("&amp;"), "{html}");
1971 1971 }
1972 +
1973 + /// A screen exercising every [`Node`] variant and every [`RegionKind`].
1974 + ///
1975 + /// Written out rather than derived, for the reason `makeover-webview`'s own
1976 + /// `part_class` is written out: both enums are `#[non_exhaustive]`-shaped in
1977 + /// practice and there is nothing to iterate. A variant added upstream and not
1978 + /// added here emits classes this file never sees, which is the one way the
1979 + /// check below can be quietly weakened. Grep this function when adding a node.
1980 + fn every_kind_of_screen() -> Vec<String> {
1981 + let mut htmls = Vec::new();
1982 +
1983 + for kind in [
1984 + RegionKind::Band,
1985 + RegionKind::Sidebar,
1986 + RegionKind::Pane,
1987 + RegionKind::Split,
1988 + RegionKind::TabGroup,
1989 + RegionKind::Modal,
1990 + ] {
1991 + htmls.push(render(
1992 + &Screen::list_detail("Everything", false)
1993 + .saying(Node::banner(layout::Tone::Warning, "heads up"))
1994 + .saying(Node::toast(layout::Tone::Success, "saved"))
1995 + .with(Slot::new("region", kind).with(Node::text("in a region"))),
1996 + ));
1997 + }
1998 + htmls.push(render(&Screen::sidebar_content("Everything").with(
1999 + Slot::bespoke("bespoke", "map").with(Node::text("beside a fill")),
2000 + )));
2001 + htmls.push(render(&Screen::list_detail("Tabbed", true)));
2002 +
2003 + let acts = || Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger);
2004 + for node in [
2005 + Node::page("A page"),
2006 + Node::section("A section"),
2007 + Node::text("plain"),
2008 + Node::rich("**bold** and a [link](https://example.com)"),
2009 + Node::act("Save", Action::post("/save")),
2010 + Node::Token(Tag::badge("Paid").tone(layout::Tone::Success)),
2011 + Node::Token(Tag::chip("Open", Action::get("/tasks?open=1")).latched(true)),
2012 + Node::banner(layout::Tone::Danger, "it broke"),
2013 + Node::toast(layout::Tone::Info, "it saved"),
2014 + Node::empty("nothing here").offering(acts()),
2015 + Node::failed("it broke").offering(acts()),
2016 + Node::field(Field::new(layout::FieldKind::Text, "name", "Name").required()),
2017 + Node::field(Field::new(layout::FieldKind::Secret, "pw", "Password").error("too short")),
2018 + Node::field(
2019 + Field::new(layout::FieldKind::Checkbox, "live", "Live").changes(Action::post("/live")),
2020 + ),
2021 + Node::field(Field::select(
2022 + "size",
2023 + "Size",
2024 + vec![Choice::plain("small"), Choice::new("l", "large")],
2025 + )),
2026 + Node::field(Field::radio(
2027 + "mode",
2028 + "Mode",
2029 + vec![Choice::plain("one"), Choice::plain("two")],
2030 + )),
2031 + Node::Form {
2032 + action: Action::post("/new"),
2033 + submit: "Create".into(),
2034 + fields: vec![Field::new(layout::FieldKind::Text, "title", "Title")],
2035 + },
2036 + Node::list([Row::new("fw13")
2037 + .secondary("a second line")
2038 + .meta("2 days ago")
2039 + .token(Tag::badge("Active"))
2040 + .meter(Meter::new(3, 10))
2041 + .activate(Action::get("/keys/7"))
2042 + .act(acts())])
2043 + .and_more(Rest::more(Action::get("/keys?page=2")).remaining(40)),
2044 + Node::list([Row::new("astra").toggling(true, Action::post("/keys/8/pin"))]),
2045 + Node::Table {
2046 + columns: vec![
2047 + Column::new("Name")
2048 + .width(layout::Width::Fill)
2049 + .priority(layout::Priority::Essential),
2050 + Column::new("Status")
2051 + .width(layout::Width::Content)
2052 + .priority(layout::Priority::Optional),
2053 + ],
2054 + rows: vec![
2055 + Cells::new([
2056 + Cell::new("deploy"),
2057 + Cell::tag(Tag::badge("Paid").tone(layout::Tone::Success)),
2058 + ])
2059 + .activate(Action::get("/runs/1")),
2060 + Cells::new([Cell::new("build"), Cell::acts([acts()])]),
2061 + ],
2062 + },
2063 + Node::Meter(Meter::new(7, 10).label("7 of 10")),
2064 + Node::stats([Figure::new("$12.00", "Revenue").change("+3%")]),
2065 + ] {
2066 + htmls.push(fragment(&node));
2067 + }
2068 +
2069 + for kind in [
2070 + layout::Selector::Tabs,
2071 + layout::Selector::Segmented,
2072 + layout::Selector::Toggle,
2073 + ] {
2074 + htmls.push(fragment(&Node::Select {
2075 + kind,
2076 + options: vec![
2077 + (Choice::plain("open"), Some(Action::get("/tasks?open=1"))),
2078 + (Choice::new("done", "Done"), None),
2079 + ],
2080 + chosen: Some("open".into()),
2081 + action: Some(Action::get("/tasks")),
2082 + }));
2083 + }
2084 +
2085 + htmls
2086 + }
2087 +
2088 + /// Every class name the markup carries, from `class="a b c"` attributes.
2089 + ///
2090 + /// Column identity classes are dropped. `col-Name` is `column_classes`'s own
2091 + /// output and names the column rather than the vocabulary, so it is data and
2092 + /// there is nothing for a stylesheet to define.
2093 + fn emitted_classes(htmls: &[String]) -> std::collections::BTreeSet<String> {
2094 + let mut names = std::collections::BTreeSet::new();
2095 + for html in htmls {
2096 + let mut rest = html.as_str();
2097 + while let Some(at) = rest.find("class=\"") {
2098 + rest = &rest[at + 7..];
2099 + let end = rest.find('"').expect("the attribute closes");
2100 + for name in rest[..end].split_whitespace() {
2101 + if !name.starts_with("col-") {
2102 + names.insert(name.to_string());
2103 + }
2104 + }
2105 + rest = &rest[end..];
2106 + }
2107 + }
2108 + names
2109 + }
2110 +
2111 + /// Classes this renderer emits that no rule in the generated stylesheet names,
2112 + /// because there is nothing for makeover to say about them.
2113 + ///
2114 + /// Arrangements and regions are placement, and placement is spacing:
2115 + /// `makeover-geometry`'s question, answered per app in `styles.css`. The rest
2116 + /// are containers holding things makeover styles one by one, or elements that
2117 + /// are already an element before they are a class.
2118 + ///
2119 + /// This list is the boundary written down, not a todo. A *component* joining it
2120 + /// is the bug, and the test is what refuses one.
2121 + const BY_DESIGN: &[&str] = &[
2122 + // Arrangements, from `Webview::arrangement_class`.
2123 + "list-detail",
2124 + "list-detail-tabbed",
2125 + "sidebar-content",
2126 + // Regions, from `region_class`.
2127 + "band",
2128 + "bespoke",
2129 + "modal",
2130 + "pane",
2131 + "region",
2132 + "sidebar",
2133 + "split",
2134 + "tabgroup",
2135 + // Containers. Each holds things that carry their own styled classes.
2136 + "figures",
2137 + "form",
2138 + "notices",
2139 + "rest",
2140 + "row",
2141 + "selector",
2142 + // Typography. makeover sets no type scale, so a heading and a paragraph
2143 + // are an `h2` and a `p` before they are anything this crate named.
2144 + "heading",
2145 + "rich",
2146 + "text",
2147 + ];
2148 +
2149 + /// Classes that name *which* of something, on an element already styled by the
2150 + /// class beside them.
2151 + ///
2152 + /// `class="button act-submit"` takes its whole appearance from `button`. The
2153 + /// second name exists so an app can reach the submit button of a form without
2154 + /// reaching every button, and a rule for it upstream would be makeover deciding
2155 + /// that a submit button looks different, which is the app's call.
2156 + ///
2157 + /// The row parts are makeover's own `part_class` output. `row_rules` writes a
2158 + /// colour for `Primary`, `Secondary` and `Meta` and deliberately none for these
2159 + /// three: actions carry controls, and tokens and proportions each carry their
2160 + /// own tone, so a colour on the container would fight what is inside it.
2161 + const MODIFIERS: &[&str] = &[
2162 + "act-submit",
2163 + "rest-more",
2164 + "row-actions",
2165 + "row-activate",
2166 + "row-proportion",
2167 + "row-select",
2168 + "row-selected",
2169 + "row-tokens",
2170 + "cell-actions",
2171 + "cell-tokens",
2172 + "field-writes",
2173 + ];
2174 +
2175 + /// Classes that reach the markup with no rule anywhere, which is a gap rather
2176 + /// than a decision.
2177 + ///
2178 + /// Every one is a component: something makeover-layout names, that a described
2179 + /// screen produces, that arrives unstyled. This is the same failure the
2180 + /// SSH-keys tab found, one layer up — the name is not invented here, it is
2181 + /// correct and nothing defines it.
2182 + ///
2183 + /// Three separate causes, none of them fixable in this crate alone:
2184 + ///
2185 + /// - `form-*`, `has-error`, `visible` and `placeholder-action` are emitted by
2186 + /// `makeover_webview::form` and `::placeholder` and styled by no rule that
2187 + /// crate's own `stylesheet` writes. A field's anatomy is makeover's from end
2188 + /// to end, so both halves are over there.
2189 + /// - `cell-fill` and `cell-keeps` come off `column_classes`, and the rules that
2190 + /// make them mean anything come off `list::narrowing_css`, which needs the
2191 + /// columns and is therefore per-table. Nothing calls it here, so a described
2192 + /// table has no column tracks and no narrowing: every column is content-width
2193 + /// and none of them ever drops. goingson generates its `tables.css` from it in
2194 + /// its own `build.rs`, which is the shape a described table cannot use,
2195 + /// because its columns are known at render time and not at build time.
2196 + /// - `banner` and `toast` are `makeover_layout::Notice`, which the description
2197 + /// layer names and `component_rules` has no section for.
2198 + ///
2199 + /// Shrinking this list is the work. Growing it needs a reason written here.
2200 + const GAPS: &[&str] = &[
2201 + "form-checkbox-label",
2202 + "form-error",
2203 + "form-group",
2204 + "form-label",
2205 + "form-radio-group",
2206 + "form-radio-label",
2207 + "has-error",
2208 + "visible",
2209 + "placeholder-action",
2210 + "cell-fill",
2211 + "cell-keeps",
2212 + "banner",
2213 + "toast",
2214 + ];
2215 +
2216 + #[test]
2217 + fn every_class_this_renderer_emits_is_one_makeover_defines() {
2218 + // The invariant the SSH-keys tab found three counterexamples to, checked
2219 + // by enumeration rather than by remembering the three. `act`, `tone-danger`
2220 + // and `chip-latched` were each a name this renderer made up, and each one
2221 + // rendered a described control as unstyled text beside a hand-written one
2222 + // that had a rule. A fourth is a matter of time without this.
2223 + let css = makeover_webview::stylesheet(&Emit::default());
2224 + let emitted = emitted_classes(&every_kind_of_screen());
2225 +
2226 + // Whole-name matching. `.row` is in the stylesheet and `.row-primary`
2227 + // starts with it, so a substring search would call every misspelling styled.
2228 + let styled = |name: &str| {
2229 + css.match_indices(&format!(".{name}")).any(|(at, found)| {
2230 + css[at + found.len()..]
2231 + .chars()
2232 + .next()
2233 + .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-' && c != '_')
2234 + })
2235 + };
2236 +
2237 + let unstyled: Vec<&str> = emitted
2238 + .iter()
2239 + .map(String::as_str)
2240 + .filter(|name| !styled(name))
2241 + .collect();
2242 +
2243 + let mut accounted: Vec<&str> = [BY_DESIGN, MODIFIERS, GAPS].concat();
2244 + accounted.sort_unstable();
2245 + assert_eq!(
2246 + accounted.len(),
2247 + accounted
2248 + .iter()
2249 + .collect::<std::collections::BTreeSet<_>>()
2250 + .len(),
2251 + "a name is in two of the three lists, which means two answers to one \
2252 + question"
2253 + );
2254 +
2255 + assert_eq!(
2256 + unstyled, accounted,
2257 + "a class this renderer emits has no rule and no entry above. If \
2258 + makeover spells it differently, use makeover's spelling -- that is the \
2259 + whole of the SSH-keys bug. Otherwise put it in BY_DESIGN, MODIFIERS or \
2260 + GAPS with the reason, and note that GAPS is work rather than a \
2261 + decision."
2262 + );
2263 + }
2264 +
2265 + #[test]
2266 + fn a_class_prefix_reaches_the_markup_the_way_it_reaches_the_stylesheet() {
2267 + // The prefix is one setting shared by two emitters, and the failure is
2268 + // silent in the same way: a prefixed app whose renderer forgot the prefix
2269 + // on one element gets a stylesheet that matches everything except that
2270 + // element.
2271 + let emit = Emit {
2272 + class_prefix: "mk-",
2273 + ..Emit::default()
2274 + };
2275 + let html = Webview::new()
2276 + .with_emit(emit)
2277 + .fragment(&Node::list([Row::new("fw13").token(Tag::badge("Active"))]));
2278 +
2279 + assert!(html.contains("class=\"mk-row\""), "{html}");
2280 + assert!(html.contains("mk-row-primary"), "{html}");
2281 + assert!(html.contains("mk-badge"), "{html}");
2282 + assert!(!html.contains("\"row\""), "{html}");
2283 + }
@@ -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.20.1"
42 + makeover-build = "0.20.3"