Skip to main content

max / quasi

Every class this renderer emits is makeover's or declared as its own makeover-webview 0.27.0 exists because this crate spelled `tabs`, `segmented` and `option` itself while makeover's rules key off `tab`, `segment` and `toggle`. Every described selector rendered flat: no depth, no focus ring, no chosen state. The same shape emitted `tone-info` and friends as classes when makeover keys tone off `data-tone`. Both sides are strings, so no type could have caught either, and both were found by reading. The test reads the literals out of the source rather than out of rendered HTML. Rendering only covers what a test author remembered to describe, and the failure being guarded against is a name nobody thought about. Class names reach the output through two functions, so those literals are the whole surface. RENDERER_OWN is the declared exception list: behavioural hooks the transport binds to, and containers makeover has no word for. Three tests hold it honest -- a literal outside it fails, an entry makeover also defines fails, and an entry nothing emits fails, because a licence nobody uses is where the next stray name lands and reads as company. Verified by reintroducing `tabs` at a real call site and watching it fail.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 01:40 UTC
Commit: d00af07861483e6c2801fae4f54da3d0b2947ddf
Parent: 5bdf71d
3 files changed, +215 insertions, -3 deletions
M Cargo.lock +2 -2
@@ -1930,9 +1930,9 @@
1930 1930
1931 1931 [[package]]
1932 1932 name = "makeover-webview"
1933 - version = "0.28.0"
1933 + version = "0.29.0"
1934 1934 source = "registry+https://github.com/rust-lang/crates.io-index"
1935 - checksum = "d2402af6249ca10f0a7ddbad2b5eb81725676ffca84093cb86bc1cadfa5b2272"
1935 + checksum = "9cb34bfeeabd42c93a5d1ef6c4375b9193adb8e2ada41dc2a9118c795c020cfc"
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.16.0"
19 - makeover-webview = "0.28.0"
19 + makeover-webview = "0.29.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 #
@@ -1,0 +1,212 @@
1 + //! Every class this renderer emits is either one makeover defines or one this
2 + //! renderer declares as its own, and nothing in between.
3 + //!
4 + //! # Why this test exists
5 + //!
6 + //! makeover-webview 0.27.0 exists because this crate spelled `tabs`, `segmented`
7 + //! and `option` itself while makeover's rules key off `tab`, `segment` and
8 + //! `toggle`. Every described selector rendered flat: no depth, no focus ring, no
9 + //! chosen state. The CSS stayed valid, the markup stayed valid, and the two
10 + //! simply did not meet. Nothing failed, so it shipped, and it was found by
11 + //! reading rather than by building.
12 + //!
13 + //! The same shape produced `tone-info` / `tone-success` / `tone-warning` /
14 + //! `tone-danger` as classes when makeover keys tone off `data-tone`, so every
15 + //! toned thing arrived with a class no stylesheet in the tree had heard of.
16 + //!
17 + //! Both were a name invented in this crate that makeover already answered for.
18 + //! Neither could be caught by a type, because both sides are strings.
19 + //!
20 + //! # How it reads
21 + //!
22 + //! Class names reach the output through exactly two places -- `class_attr`,
23 + //! which writes the attribute, and `makeover_webview::class`, which prefixes one
24 + //! name -- so the literals handed to those two are the whole surface. This test
25 + //! reads them out of the source rather than out of rendered HTML: rendering
26 + //! covers what the test author remembered to describe, and the failure being
27 + //! guarded against is a name nobody thought about.
28 + //!
29 + //! A literal that is neither makeover's nor declared below fails. Adding one to
30 + //! [`RENDERER_OWN`] is the deliberate act the 0.27.0 defect skipped.
31 +
32 + use std::collections::BTreeSet;
33 +
34 + /// Classes this renderer owns, with makeover answering for none of them.
35 + ///
36 + /// Two kinds, and both are legitimately not makeover's:
37 + ///
38 + /// - **Behavioural hooks.** `row-select`, `row-activate`, `table-sort`,
39 + /// `chip-remove` and `field-writes` are what the transport binds to. They name
40 + /// what an element *does* on this host, which is a webview concern rather than
41 + /// a description one, and makeover deliberately names no behaviour.
42 + /// - **Structure this renderer assembles.** `region`, `selector`, `figures`,
43 + /// `form`, `rest` and the rest name containers makeover's vocabulary has no
44 + /// word for because nothing else needs one: makeover styles the things inside
45 + /// them.
46 + ///
47 + /// Anything added here should be one of those two. A name that describes how a
48 + /// thing *looks* belongs in makeover, and putting it here is how the drift this
49 + /// test exists to catch would come back wearing a licence.
50 + const RENDERER_OWN: &[&str] = &[
51 + "act-submit",
52 + "chip-remove",
53 + "field-writes",
54 + "figure-act",
55 + "figures",
56 + "form",
57 + "heading",
58 + "notices",
59 + "region",
60 + "rest",
61 + "rest-more",
62 + "rich",
63 + "row-activate",
64 + "row-menu",
65 + "row-select",
66 + "selector",
67 + "table-sort",
68 + "text",
69 + ];
70 +
71 + /// The source files that can name a class.
72 + const SOURCES: &[(&str, &str)] = &[
73 + ("src/node.rs", include_str!("../src/node.rs")),
74 + ("src/lib.rs", include_str!("../src/lib.rs")),
75 + ("src/shell.rs", include_str!("../src/shell.rs")),
76 + ];
77 +
78 + #[test]
79 + fn every_class_this_renderer_emits_is_makeovers_or_declared_as_its_own() {
80 + let opts = makeover_webview::Emit::default();
81 + let makeover = makeover_webview::vocabulary::names(&opts);
82 + let mut stray: Vec<String> = Vec::new();
83 +
84 + for (name, src) in SOURCES {
85 + for (line, literal) in class_literals(src) {
86 + if makeover.contains(&literal) || RENDERER_OWN.contains(&literal.as_str()) {
87 + continue;
88 + }
89 + stray.push(format!(" {name}:{line} \"{literal}\""));
90 + }
91 + }
92 +
93 + assert!(
94 + stray.is_empty(),
95 + "{} class name(s) are neither makeover's nor declared in RENDERER_OWN:\n{}\n\n\
96 + If makeover already answers for this thing, call its naming function \
97 + (`class`, `option_class`, `part_class`, `cell_part_class`) instead of \
98 + spelling the name here -- that is the 0.27.0 defect, where `tabs` and \
99 + `segmented` rendered flat because makeover's rules say `tab` and \
100 + `segment`. If it is genuinely this renderer's, a behavioural hook or a \
101 + container makeover has no word for, add it to RENDERER_OWN and say which.",
102 + stray.len(),
103 + stray.join("\n")
104 + );
105 + }
106 +
107 + #[test]
108 + fn nothing_this_renderer_claims_as_its_own_is_something_makeover_already_names() {
109 + // The other direction. A name in both lists means two crates believe they
110 + // own the same class, and the app gets whichever rule wins the cascade.
111 + let opts = makeover_webview::Emit::default();
112 + let makeover = makeover_webview::vocabulary::names(&opts);
113 + let overlap: Vec<&&str> = RENDERER_OWN
114 + .iter()
115 + .filter(|name| makeover.contains(**name))
116 + .collect();
117 + assert!(
118 + overlap.is_empty(),
119 + "makeover defines {overlap:?}, so this renderer must not claim to own it. \
120 + Delete the entry from RENDERER_OWN; the emitted name is already correct."
121 + );
122 + }
123 +
124 + #[test]
125 + fn renderer_own_carries_nothing_that_stopped_being_emitted() {
126 + // A declared exception that no longer corresponds to anything is a licence
127 + // nobody is using, and the next stray name lands next to it and reads as
128 + // company.
129 + let emitted: BTreeSet<String> = SOURCES
130 + .iter()
131 + .flat_map(|(_, src)| class_literals(src).into_iter().map(|(_, l)| l))
132 + .collect();
133 + let dead: Vec<&&str> = RENDERER_OWN
134 + .iter()
135 + .filter(|name| !emitted.contains(**name))
136 + .collect();
137 + assert!(
138 + dead.is_empty(),
139 + "RENDERER_OWN declares {dead:?}, which nothing emits any more. Delete them."
140 + );
141 + }
142 +
143 + #[test]
144 + fn the_reader_finds_both_call_shapes_and_ignores_prose() {
145 + let src = r#"
146 + // class_attr(&["not-a-real-one"]) in a comment
147 + class_attr(&["alpha"], opts, out);
148 + class_attr(&["beta", "gamma"], opts, out);
149 + out.push_str(&escape(&class("delta", opts)));
150 + class_attr(&[part_class(layout::RowPart::Primary)], opts, out);
151 + "#;
152 + let found: BTreeSet<String> = class_literals(src).into_iter().map(|(_, l)| l).collect();
153 + let expected: BTreeSet<String> = ["alpha", "beta", "gamma", "delta"]
154 + .into_iter()
155 + .map(String::from)
156 + .collect();
157 + // `part_class(...)` is makeover answering, not a literal, so it is not here.
158 + assert_eq!(found, expected);
159 + }
160 +
161 + /// `(line, class name)` for every literal handed to `class_attr` or `class`.
162 + ///
163 + /// A call whose argument is a naming function rather than a literal contributes
164 + /// nothing, which is the point: that call is makeover answering, and this test
165 + /// is only interested in the names this crate spells itself.
166 + fn class_literals(src: &str) -> Vec<(usize, String)> {
167 + let mut out = Vec::new();
168 + for (i, line) in src.lines().enumerate() {
169 + let code = line.trim_start();
170 + // A comment can hold an example, and an example is not an emission.
171 + if code.starts_with("//") {
172 + continue;
173 + }
174 + for (call, open) in [("class_attr(&[", ']'), ("class(", ')')] {
175 + let mut at = 0;
176 + while let Some(found) = line[at..].find(call) {
177 + let start = at + found + call.len();
178 + // `option_class(`, `part_class(` and `cell_part_class(` all end
179 + // in `class(` and are makeover answering rather than a literal.
180 + let is_suffix = call == "class("
181 + && line[..at + found]
182 + .chars()
183 + .next_back()
184 + .is_some_and(|c| c.is_alphanumeric() || c == '_');
185 + at = start;
186 + if is_suffix {
187 + continue;
188 + }
189 + let Some(end) = line[start..].find(open) else {
190 + continue;
191 + };
192 + for literal in string_literals(&line[start..start + end]) {
193 + out.push((i + 1, literal));
194 + }
195 + }
196 + }
197 + }
198 + out
199 + }
200 +
201 + /// The contents of every double-quoted literal in a fragment of Rust.
202 + fn string_literals(fragment: &str) -> Vec<String> {
203 + let mut out = Vec::new();
204 + let mut rest = fragment;
205 + while let Some(open) = rest.find('"') {
206 + rest = &rest[open + 1..];
207 + let Some(close) = rest.find('"') else { break };
208 + out.push(rest[..close].to_string());
209 + rest = &rest[close + 1..];
210 + }
211 + out
212 + }