Skip to main content

max / makeover-webview

0.50.0: draw a facet, and give a markdown field a textarea Task 34950ade's renderer half, plus f8ad0b32's. facet.rs is the fourth phase-B emitter. It is the one whose markup an app is not keeping, and phase A's rule bends here for a reason: that rule works because the markup already existed, and a facet panel's did not -- MNW's discover page writes a tick box AND a chevron per filter row because a tag's selection and its browse position were separate state, and Selecting::Subtree exists so they stop being. There is nothing to keep. A value's identifier leaves in data-facet-value, which is the hook an app wires its route onto, exactly as list.rs writes data-column and lets the app decide what pressing a heading calls. The exclude affordance is a visible button in a subtree facet and in no other mode, because excluding a value from a flat facet is the same fact as not picking it. aria-pressed rather than aria-selected: the values are toggles over a set, and an inherited value is pressed in fact though nobody pressed it, which is what Standing::in_force is for. The indent is --facet-depth times one geometry step, and --facet-reach reserves the gutter from the deepest value before anything is drawn. That keeps the tree one flat list in the DOM, so the same description can be drawn as a breadcrumb or a column of panes without the markup disagreeing, and it is "first paint is final paint" applied to a tree. FieldKind::Rich takes the textarea arm, keyed on FieldKind::multiline rather than on the member, and says what the value is in data-format. An attribute and not a class: what the value IS is not a styling hook, and an enhancement looking for editors to upgrade needs a selector that survives Emit's class prefixing. Without the mark a described editor is a plain box and MNW's four hand-written section editors have nothing to convert onto.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-18 20:29 UTC
Signed with PGP, not checked
Commit: 40d31ed385a533b478f1e6107129c30238b3fce4
Parent: e2ff9df
4 files changed, +496 insertions, -4 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.49.0"
3 + version = "0.50.0"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
@@ -17,7 +17,7 @@
17 17 # patch satisfy the requirement and still fail to compile. That happened once
18 18 # with `form::radio_html` calling `FieldKind::Radio`, and makeover-build is where
19 19 # it surfaced, one release later.
20 - makeover-layout = "0.29.1"
20 + makeover-layout = "0.30.0"
21 21 # The capability axis. `makeover-touch` decides whether a hover rule should be
22 22 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
23 23 # answers are owned elsewhere and neither is re-derived here.
M src/form.rs +11 -2
@@ -213,7 +213,7 @@
213 213 // Select and Textarea are not inputs at all; they never reach here.
214 214 // Radio is one, but it is emitted once per option by `radio_html` and
215 215 // so does not reach here either.
216 - FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
216 + FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text",
217 217 // A kind added to the description since this renderer was built. Text
218 218 // accepts any value the others would, so it degrades rather than
219 219 // dropping the field.
@@ -469,10 +469,19 @@
469 469 };
470 470
471 471 match field.kind {
472 - FieldKind::Textarea => {
472 + // Both multi-line kinds are a `<textarea>`, and the markdown one says so
473 + // in an attribute rather than in a class: what the value *is* is not a
474 + // styling hook, and a progressive enhancement looking for editors to
475 + // upgrade needs a selector that survives `Emit`'s class prefixing.
476 + // Without the mark, a described editor is a plain box and the four
477 + // hand-written MNW editors have nothing to convert onto.
478 + kind if kind.multiline() => {
473 479 out.push_str("<textarea class=\"");
474 480 push_class(out, "field", opts);
475 481 out.push('"');
482 + if matches!(kind, FieldKind::Rich) {
483 + out.push_str(" data-value=\"markdown\"");
484 + }
476 485 push_control_attributes(out, field, &id, field.name);
477 486 placeholder(out);
478 487 out.push('>');
M src/lib.rs +9
@@ -42,6 +42,13 @@
42 42 //! function, where the apps need four and have to choose correctly at every
43 43 //! call site.
44 44 //!
45 + //! [`facet`] renders `makeover_layout::Facet`: a dimension a set is narrowed by,
46 + //! and the one phase-B emitter whose markup an app is not keeping, because the
47 + //! markup it replaces was two mechanisms rather than one. A tag's selection and
48 + //! a tag's browse position were separate state on MNW's discover page, which is
49 + //! why every filter row there carries a tick box *and* a chevron; one gesture
50 + //! doing both is what lets the second one go.
51 + //!
45 52 //! [`list`] is the other half: column tracks, the narrowing rules, and the cell
46 53 //! containers a row is made of. It stops at the cell boundary and does not
47 54 //! render what goes inside one, on the reasoning in that module. So phase B is
@@ -274,6 +281,7 @@
274 281
275 282 #![forbid(unsafe_code)]
276 283
284 + pub mod facet;
277 285 pub mod figure;
278 286 pub mod form;
279 287 pub mod list;
@@ -1635,6 +1643,7 @@
1635 1643 css.push_str(&track_rules(opts));
1636 1644 css.push_str(&state_rules(opts));
1637 1645 css.push_str(&table_rules(opts));
1646 + css.push_str(&facet::facet_rules(opts));
1638 1647 css
1639 1648 }
1640 1649
A src/facet.rs +474
@@ -1,0 +1,474 @@
1 + //! A dimension a set is narrowed by, rendered as a list of values.
2 + //!
3 + //! The fourth phase-B emitter, beside [`form`](crate::form),
4 + //! [`list`](crate::list) and [`meter`](crate::meter). Same split as those: this
5 + //! owns the structure of the panel and the app owns the routes. A value's
6 + //! identifier leaves in `data-facet-value`, which is the hook an app wires its
7 + //! own request onto, exactly as [`list`](crate::list) writes `data-column` and
8 + //! lets the app decide what pressing a heading calls.
9 + //!
10 + //! # Why this is markup and not only CSS
11 + //!
12 + //! Phase A's rule is that an app keeps its markup and gains the classes, and
13 + //! that rule works because the markup already existed. Here it mostly does not:
14 + //! a facet panel is the shape MNW's discover page reached by writing a tick box
15 + //! and a chevron per row because filtering and browsing were two mechanisms, and
16 + //! the whole point of [`makeover_layout::Selecting::Subtree`] is that they stop
17 + //! being two. There is nothing to keep.
18 + //!
19 + //! # The one thing drawn that no flat control has
20 + //!
21 + //! An exclude affordance beside each value, in a subtree facet only. It is a
22 + //! visible control rather than a modifier or a long press, and that was ruled
23 + //! rather than chosen here: a gesture a terminal cannot express is a gesture
24 + //! half the renderers leave out, and an affordance nothing teaches is one users
25 + //! do not find. The glyph is this renderer's pick, and it takes the standing
26 + //! preference for the heavier, simpler mark.
27 + //!
28 + //! # What the depth does and does not do
29 + //!
30 + //! `--facet-depth` carries the tree level as a number, and the indent rule
31 + //! multiplies it by one geometry step. That keeps the whole tree one flat list
32 + //! in the DOM rather than nested lists, which is what lets a renderer draw the
33 + //! same description as a breadcrumb or a column of panes without the markup
34 + //! disagreeing. It is not a size: the number is the level, and the step is
35 + //! `makeover-geometry`'s.
36 +
37 + use crate::form::escape_into;
38 + use crate::{Emit, class, push_class};
39 + use makeover_layout::{Depth, Facet, FacetValue, Selecting, Standing};
40 + use std::fmt::Write as _;
41 +
42 + /// The classes this module can put in markup.
43 + ///
44 + /// [`crate::list::ROW_PART_CLASSES`]' obligation, and it exists for the same
45 + /// reason: every class here is also ruled by [`facet_rules`], so the vocabulary
46 + /// seal picks them up from the generated sheet, and this list is what a test
47 + /// checks that against.
48 + pub const FACET_CLASSES: &[&str] = &[
49 + "facet",
50 + "facet-name",
51 + "facet-values",
52 + "facet-value",
53 + "facet-take",
54 + "facet-count",
55 + "facet-prune",
56 + ];
57 +
58 + /// The name a selection mode goes by in `data-selecting`.
59 + ///
60 + /// An attribute rather than a class, for `data-selector`'s reason on a selector
61 + /// group: the mode changes what the panel *means*, not how one value is
62 + /// painted, and a class there would read as the styling hook the value's class
63 + /// actually is.
64 + #[must_use]
65 + pub const fn selecting_name(mode: Selecting) -> &'static str {
66 + match mode {
67 + Selecting::OneOf => "one-of",
68 + Selecting::AnyOf => "any-of",
69 + Selecting::Range => "range",
70 + Selecting::Text => "text",
71 + Selecting::Subtree => "subtree",
72 + // A mode added to the description since this renderer was built.
73 + // `Selecting` is `#[non_exhaustive]`, and an unknown mode reads as the
74 + // one that offers no values and prunes nothing: drawing a value list
75 + // for a mode whose values mean something else is the worse mistake.
76 + _ => "unknown",
77 + }
78 + }
79 +
80 + /// The name a standing goes by in `data-standing`.
81 + #[must_use]
82 + pub const fn standing_name(standing: Standing) -> &'static str {
83 + match standing {
84 + Standing::Open => "open",
85 + Standing::Taken => "taken",
86 + Standing::Inherited => "inherited",
87 + Standing::Pruned => "pruned",
88 + // Unknown reads as open, which is the state that claims nothing about
89 + // the set.
90 + _ => "open",
91 + }
92 + }
93 +
94 + /// A facet as a labelled list of values.
95 + ///
96 + /// ```
97 + /// use makeover_layout::{Facet, FacetValue, Selecting, Standing};
98 + /// use makeover_webview::{Emit, facet::facet_html};
99 + ///
100 + /// let values = [
101 + /// FacetValue::new("music", "Music")
102 + /// .standing(Standing::Taken)
103 + /// .counted(128)
104 + /// .at(0, true),
105 + /// FacetValue::new("music/synths", "Synths").at(1, false),
106 + /// ];
107 + /// let facet = Facet::new("Tag", Selecting::Subtree, &values);
108 + /// let html = facet_html(&facet, &Emit::default());
109 + ///
110 + /// assert!(html.contains(r#"data-selecting="subtree""#));
111 + /// assert!(html.contains(r#"data-facet-value="music/synths""#));
112 + /// // A subtree is the one mode that offers a way to prune a branch out.
113 + /// assert!(html.contains("facet-prune"));
114 + /// ```
115 + ///
116 + /// A [`Selecting::Text`] or [`Selecting::Range`] facet lists nothing, so what
117 + /// comes back is the panel and its name with an empty list inside it. That is
118 + /// deliberate rather than an empty string: the app puts its own box in the
119 + /// panel, and the panel is what gives the box the group label and the shared
120 + /// geometry.
121 + #[must_use]
122 + pub fn facet_html(facet: &Facet<'_>, opts: &Emit) -> String {
123 + let mut html = String::new();
124 + facet_html_into(facet, opts, &mut html);
125 + html
126 + }
127 +
128 + /// A facet, written into a buffer the caller already has.
129 + ///
130 + /// [`facet_html`]'s streaming form, byte-identical to it.
131 + pub fn facet_html_into(facet: &Facet<'_>, opts: &Emit, out: &mut String) {
132 + out.push_str("<div class=\"");
133 + push_class(out, "facet", opts);
134 + out.push_str("\" role=\"group\" data-selecting=\"");
135 + out.push_str(selecting_name(facet.mode));
136 + // The gutter an indenting renderer reserves before it draws anything, so
137 + // the panel does not widen as deeper values arrive. "First paint is final
138 + // paint" applied to a tree.
139 + let _ = write!(out, "\" style=\"--facet-reach: {}\">", facet.reach());
140 +
141 + out.push_str("<p class=\"");
142 + push_class(out, "facet-name", opts);
143 + out.push_str("\">");
144 + escape_into(facet.name, out);
145 + out.push_str("</p>");
146 +
147 + out.push_str("<ul class=\"");
148 + push_class(out, "facet-values", opts);
149 + out.push_str("\">");
150 + if facet.mode.offers_values() {
151 + for value in facet.values {
152 + value_html_into(facet, value, opts, out);
153 + }
154 + }
155 + out.push_str("</ul></div>");
156 + }
157 +
158 + fn value_html_into(facet: &Facet<'_>, value: &FacetValue<'_>, opts: &Emit, out: &mut String) {
159 + out.push_str("<li class=\"");
160 + push_class(out, "facet-value", opts);
161 + out.push_str("\" data-standing=\"");
162 + out.push_str(standing_name(value.standing));
163 + let _ = write!(out, "\" style=\"--facet-depth: {}\">", value.depth);
164 +
165 + out.push_str("<button type=\"button\" class=\"");
166 + push_class(out, "facet-take", opts);
167 + out.push_str("\" data-facet-value=\"");
168 + escape_into(value.value, out);
169 + // `aria-pressed` and not `aria-selected`: the values are toggles over a set
170 + // rather than options in a listbox, and an inherited value is pressed in
171 + // fact even though nobody pressed it. That is `Standing::in_force`, which
172 + // exists so a renderer does not have to know which of the two it has.
173 + out.push_str("\" aria-pressed=\"");
174 + out.push_str(if value.standing.in_force() {
175 + "true\""
176 + } else {
177 + "false\""
178 + });
179 + // A branch that opens says so, so a reader is told there is more before
180 + // pressing rather than after.
181 + if value.branching {
182 + out.push_str(" aria-expanded=\"");
183 + out.push_str(if value.standing.in_force() {
184 + "true\""
185 + } else {
186 + "false\""
187 + });
188 + }
189 + out.push('>');
190 + escape_into(value.label, out);
191 +
192 + // Absent rather than zero when it was not measured, which is the
193 + // description's own position: a written zero reads as "none of them".
194 + if let Some(count) = value.count {
195 + out.push_str("<span class=\"");
196 + push_class(out, "facet-count", opts);
197 + let _ = write!(out, "\">{count}</span>");
198 + }
199 + out.push_str("</button>");
200 +
201 + if facet.mode.prunes() {
202 + out.push_str("<button type=\"button\" class=\"");
203 + push_class(out, "facet-prune", opts);
204 + out.push_str("\" data-facet-value=\"");
205 + escape_into(value.value, out);
206 + out.push_str("\" aria-pressed=\"");
207 + out.push_str(if value.standing == Standing::Pruned {
208 + "true\""
209 + } else {
210 + "false\""
211 + });
212 + // The accessible name is built here rather than described, for
213 + // `meter_text`'s reason: a tooltip wants a sentence and a terminal
214 + // wants a glyph, and a description shipping either would choose for
215 + // both.
216 + out.push_str(" aria-label=\"Exclude ");
217 + escape_into(value.label, out);
218 + // The heavier, simpler mark. It is the glyph and not a class, because a
219 + // renderer that swaps it is not changing what the control means.
220 + out.push_str("\">\u{2715}</button>");
221 + }
222 +
223 + out.push_str("</li>");
224 + }
225 +
226 + /// The rules for a facet panel.
227 + ///
228 + /// Depth comes from the description: a value at rest sits as
229 + /// [`Depth::Flat`] and a taken one is held in, which is
230 + /// [`makeover_layout::Selector::chosen`]'s shape for a segment and is the same
231 + /// sentence — this one is picked, so it is pressed. Nothing here states a
232 + /// colour or a size; the indent is a count multiplied by a geometry step, and
233 + /// the step is the one variable this crate is allowed to read.
234 + pub(crate) fn facet_rules(opts: &Emit) -> String {
235 + let mut css = String::new();
236 + let panel = class("facet", opts);
237 + let name = class("facet-name", opts);
238 + let values = class("facet-values", opts);
239 + let value = class("facet-value", opts);
240 + let take = class("facet-take", opts);
241 + let count = class("facet-count", opts);
242 + let prune = class("facet-prune", opts);
243 +
244 + // The name of the dimension. A caption, and captions are legitimately
245 + // muted: it was never going to answer a press.
246 + let _ = writeln!(css, ".{name} {{\n color: var(--content-muted);\n}}");
247 +
248 + // The list undoes the bullet a browser adds, for `row_rules`' reason: a
249 + // described set of tags is not a bulleted list and rendered as one because
250 + // nothing said otherwise.
251 + let _ = writeln!(
252 + css,
253 + ".{values} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}"
254 + );
255 +
256 + // The indent is the level times one step. `--facet-depth` is written per
257 + // value and `--facet-reach` per panel; the panel one reserves the gutter so
258 + // nothing moves as deeper values arrive.
259 + let _ = writeln!(
260 + css,
261 + ".{value} {{\n padding-inline-start: calc(var(--facet-depth, 0) * var(--space-tight, 0.5rem));\n}}"
262 + );
263 +
264 + let _ = writeln!(
265 + css,
266 + ".{panel} {{\n min-inline-size: calc(var(--facet-reach, 0) * var(--space-tight, 0.5rem));\n}}"
267 + );
268 +
269 + // The value's own control. Flat at rest, held in when it is in force, and
270 + // that is the segmented control's sentence rather than a new one.
271 + css.push_str(&crate::depth_rule(&take, Depth::Flat));
272 + css.push_str(&crate::interactive_rules(&take, Depth::Flat, opts));
273 + css.push_str(&crate::depth_rule(
274 + &format!("{take}[aria-pressed=\"true\"]"),
275 + Depth::Well,
276 + ));
277 +
278 + // A pruned branch reads one step back and stays live: pressing it takes the
279 + // prune off, so it may not wear `content-muted`. `Standing::intent` is
280 + // where that is decided.
281 + let _ = writeln!(
282 + css,
283 + ".{value}[data-standing=\"pruned\"] .{take} {{\n color: var(--{});\n}}",
284 + Standing::Pruned.intent()
285 + );
286 +
287 + // Inherited is in force and was not chosen. It reads as the thing itself,
288 + // like a taken value, and the difference is carried by the attribute for
289 + // whoever wants it rather than by a colour claiming something.
290 + let _ = writeln!(css, ".{count} {{\n color: var(--content-muted);\n}}");
291 +
292 + css.push_str(&crate::depth_rule(&prune, Depth::Flat));
293 + css.push_str(&crate::interactive_rules(&prune, Depth::Flat, opts));
294 + css.push_str(&crate::depth_rule(
295 + &format!("{prune}[aria-pressed=\"true\"]"),
296 + Depth::Well,
297 + ));
298 +
299 + css
300 + }
301 +
302 + #[cfg(test)]
303 + mod tests {
304 + use super::*;
305 +
306 + fn tag_values() -> [FacetValue<'static>; 3] {
307 + [
308 + FacetValue::new("music", "Music")
309 + .standing(Standing::Taken)
310 + .counted(128)
311 + .at(0, true),
312 + FacetValue::new("music/synths", "Synths")
313 + .standing(Standing::Inherited)
314 + .at(1, false),
315 + FacetValue::new("music/drums", "Drums")
316 + .standing(Standing::Pruned)
317 + .at(1, false),
318 + ]
319 + }
320 +
321 + #[test]
322 + fn a_streamed_facet_is_the_facet_the_other_form_returns() {
323 + let opts = Emit {
324 + class_prefix: "mk-",
325 + ..Emit::default()
326 + };
327 + let values = tag_values();
328 + for facet in [
329 + Facet::new("Tag", Selecting::Subtree, &values),
330 + Facet::new("Type", Selecting::AnyOf, &values),
331 + Facet::new("Search", Selecting::Text, &[]),
332 + ] {
333 + let mut streamed = String::new();
334 + facet_html_into(&facet, &opts, &mut streamed);
335 + assert_eq!(streamed, facet_html(&facet, &opts));
336 + }
337 + }
338 +
339 + #[test]
340 + fn only_a_subtree_draws_an_exclude_affordance() {
341 + // The one control a flat facet has no use for: excluding a value there
342 + // is the same fact as not picking it.
343 + let values = tag_values();
344 + let subtree = facet_html(
345 + &Facet::new("Tag", Selecting::Subtree, &values),
346 + &Emit::default(),
347 + );
348 + assert!(subtree.contains("facet-prune"));
349 + assert!(subtree.contains(r#"aria-label="Exclude Drums""#));
350 +
351 + let flat = facet_html(
352 + &Facet::new("Type", Selecting::AnyOf, &values),
353 + &Emit::default(),
354 + );
355 + assert!(!flat.contains("facet-prune"));
356 + }
357 +
358 + #[test]
359 + fn an_inherited_value_reads_as_pressed_without_having_been_pressed() {
360 + // The distinction `Standing` has four members for. A renderer given a
361 + // bool marks every descendant of a taken branch or marks none, and both
362 + // are wrong on screen.
363 + let values = tag_values();
364 + let html = facet_html(
365 + &Facet::new("Tag", Selecting::Subtree, &values),
366 + &Emit::default(),
367 + );
368 + let synths = html
369 + .split("<li")
370 + .find(|chunk| chunk.contains("music/synths"))
371 + .expect("the inherited value");
372 + assert!(synths.contains(r#"data-standing="inherited""#));
373 + assert!(synths.contains(r#"aria-pressed="true""#));
374 +
375 + let drums = html
376 + .split("<li")
377 + .find(|chunk| chunk.contains("music/drums"))
378 + .expect("the pruned value");
379 + // Pruned is a decision and it is not in force, so the take control is
380 + // not pressed and the prune control is.
381 + assert!(drums.contains(r#"data-standing="pruned""#));
382 + assert!(drums.contains(r#"aria-pressed="false""#));
383 + assert!(drums.contains(r#"aria-label="Exclude Drums""#));
384 + }
385 +
386 + #[test]
387 + fn a_mode_that_lists_nothing_still_renders_its_panel() {
388 + // The app puts its own box in; the panel is what gives it the group
389 + // label and the shared geometry.
390 + let html = facet_html(
391 + &Facet::new("Search", Selecting::Text, &[]),
392 + &Emit::default(),
393 + );
394 + assert!(html.contains("facet-name"));
395 + assert!(html.contains(r#"data-selecting="text""#));
396 + assert!(!html.contains("facet-take"));
397 + }
398 +
399 + #[test]
400 + fn an_unmeasured_count_emits_no_number_at_all() {
401 + // A written zero reads as "none of them", which is a different claim
402 + // from "not counted".
403 + let values = [FacetValue::of("Ambient")];
404 + let html = facet_html(
405 + &Facet::new("Tag", Selecting::AnyOf, &values),
406 + &Emit::default(),
407 + );
408 + assert!(!html.contains("facet-count"));
409 +
410 + let counted = [FacetValue::of("Ambient").counted(0)];
411 + let html = facet_html(
412 + &Facet::new("Tag", Selecting::AnyOf, &counted),
413 + &Emit::default(),
414 + );
415 + assert!(html.contains(">0</span>"));
416 + }
417 +
418 + #[test]
419 + fn the_gutter_is_reserved_from_the_deepest_value_before_anything_is_drawn() {
420 + // "First paint is final paint" applied to a tree: a gutter widened as
421 + // deeper values arrive is the reflow that rule forbids.
422 + let values = tag_values();
423 + let html = facet_html(
424 + &Facet::new("Tag", Selecting::Subtree, &values),
425 + &Emit::default(),
426 + );
427 + assert!(html.contains("--facet-reach: 1"));
428 + assert!(html.contains("--facet-depth: 0"));
429 + assert!(html.contains("--facet-depth: 1"));
430 + }
431 +
432 + #[test]
433 + fn labels_and_identifiers_are_escaped_like_every_other_string() {
434 + // Both arrive from the app, and a tag path is user-supplied on a system
435 + // where a user names their own tags.
436 + let values = [FacetValue::new("a&b", "A & B")];
437 + let html = facet_html(
438 + &Facet::new("T<ag>", Selecting::AnyOf, &values),
439 + &Emit::default(),
440 + );
441 + assert!(html.contains("A &amp; B"));
442 + assert!(html.contains(r#"data-facet-value="a&amp;b""#));
443 + assert!(html.contains("T&lt;ag&gt;"));
444 + assert!(!html.contains("<ag>"));
445 + }
446 +
447 + #[test]
448 + fn every_class_this_module_emits_is_one_the_stylesheet_rules() {
449 + // `ROW_PART_CLASSES`' obligation: a class this crate writes and the
450 + // sheet does not rule is invisible to the dead-vocabulary seal.
451 + let names = crate::vocabulary::names(&Emit::default());
452 + for name in FACET_CLASSES {
453 + assert!(
454 + names.contains(&crate::class(name, &Emit::default())),
455 + "{name} is not in the vocabulary"
456 + );
457 + }
458 + }
459 +
460 + #[test]
461 + fn the_prefix_reaches_every_class_in_the_markup() {
462 + // A prefixed build claims its own names, and the emitted CSS selects
463 + // descendants: miss one and the rule stops matching.
464 + let opts = Emit {
465 + class_prefix: "mo-",
466 + ..Emit::default()
467 + };
468 + let values = tag_values();
469 + let html = facet_html(&Facet::new("Tag", Selecting::Subtree, &values), &opts);
470 + for name in FACET_CLASSES {
471 + assert!(html.contains(&format!("mo-{name}")), "{name} is unprefixed");
472 + }
473 + }
474 + }