Skip to main content

max / makeover-webview

0.29.0: the vocabulary as a set, not only one name at a time 0.27.0 exported the naming functions, so a renderer can ask what a class is called. That is half the agreement. A drift check needs the other half: it cannot ask whether an app rule re-specifies something makeover already defines without the list, and this crate is the only place that knows it. `vocabulary()` is scraped from the sheet this crate generates rather than written out beside it. A hand-maintained copy is the defect being fixed one level up: it can disagree with the sheet, and the day it does, the checker reads the list and the browser reads the sheet. Two sets, because there are two questions. `vocabulary()` is what has a rule, which is what a drift check wants. `names()` is what can appear in markup, which is the first set plus the four part classes left unruled on purpose: only `.cell-value` takes a colour, so `.cell-tokens`, `.cell-actions` and `.cell-link` correctly have no rule, and a renderer test reading `vocabulary()` would fail on all three. ROW_PART_CLASSES and CELL_PART_CLASSES sit beside the matches they enumerate, since a `#[non_exhaustive]` enum cannot be walked from outside, and a test holds each list to its match.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 01:35 UTC
Commit: 1cfbe84f625971d3e0276a724859b56380406d28
Parent: fc9b695
4 files changed, +371 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.28.0"
3 + version = "0.29.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
M src/lib.rs +1
@@ -259,6 +259,7 @@
259 259 pub mod list;
260 260 pub mod meter;
261 261 pub mod placeholder;
262 + pub mod vocabulary;
262 263
263 264 use crate::list::{cell_part_class, part_class};
264 265 use makeover_geometry::{Density, SizeClass};
M src/list.rs +28
@@ -266,6 +266,34 @@
266 266 /// renderer writes it. quasi-webview wrote this list out a second time to do
267 267 /// that, which made the obligation in the paragraph above land on a function
268 268 /// its author would not think to grep.
269 + /// Every class [`part_class`] can return, including the fallback.
270 + ///
271 + /// Beside the match rather than derived from it, because a `match` over a
272 + /// `#[non_exhaustive]` enum cannot be enumerated from outside. It carries the
273 + /// same obligation the match does and a test below holds the two together, so
274 + /// a new arm added without a new entry fails rather than silently narrowing
275 + /// what a checker believes this crate can emit.
276 + pub const ROW_PART_CLASSES: &[&str] = &[
277 + "row-primary",
278 + "row-secondary",
279 + "row-meta",
280 + "row-actions",
281 + "row-tokens",
282 + "row-proportion",
283 + "row-part",
284 + ];
285 +
286 + /// Every class [`cell_part_class`] can return, including the fallback.
287 + ///
288 + /// See [`ROW_PART_CLASSES`] for why it is written out.
289 + pub const CELL_PART_CLASSES: &[&str] = &[
290 + "cell-value",
291 + "cell-tokens",
292 + "cell-actions",
293 + "cell-link",
294 + "cell-part",
295 + ];
296 +
269 297 #[must_use]
270 298 pub fn part_class(part: RowPart) -> &'static str {
271 299 match part {
@@ -1,0 +1,341 @@
1 + //! Every class this crate is responsible for, as a set rather than one name at
2 + //! a time.
3 + //!
4 + //! The naming functions ([`crate::class`], [`crate::option_class`],
5 + //! [`crate::list::part_class`], [`crate::list::cell_part_class`]) answer "what
6 + //! is this one thing called". That is half the agreement, and 0.27.0 shipped
7 + //! it. The other half is the set: a checker cannot ask "is this app rule
8 + //! re-specifying something makeover already defines" without the list, and this
9 + //! crate is the only place that knows it, because this crate writes the sheet.
10 + //!
11 + //! # Two sets, because there are two questions
12 + //!
13 + //! [`vocabulary`] is the classes the generated stylesheet writes a rule for.
14 + //! That is the set a drift check wants: an app rule for one of these is a
15 + //! restatement of a rule the app already gets, and unlayered app CSS beats
16 + //! `@layer makeover`, so the restatement silently wins.
17 + //!
18 + //! [`names`] is every class this crate can put in markup, which is the first
19 + //! set plus the ones it deliberately leaves unruled. `row-actions`,
20 + //! `cell-actions`, `cell-tokens` and `cell-link` have no rule on purpose: only
21 + //! `.cell-value` takes a colour, because a token carries its own tone and an
22 + //! action is a control rather than text. A class that sets no properties is a
23 + //! class that means "I thought about this", and this crate does not emit those.
24 + //! So a screen renderer legitimately emits names that [`vocabulary`] does not
25 + //! contain, and a test asking "is every class this renderer emits one makeover
26 + //! knows about" has to read [`names`] or it fails on four correct ones.
27 + //!
28 + //! # Why the first set is scraped and not listed
29 + //!
30 + //! A hand-maintained copy of the sheet's contents is the defect being fixed,
31 + //! one level up: it can disagree with the sheet, and the day it does, the
32 + //! checker reads the list and the browser reads the sheet. So [`vocabulary`]
33 + //! parses the CSS this crate generates. There is no second source to drift
34 + //! from, and a class added to an emitter enters the vocabulary in the same
35 + //! commit that adds it.
36 +
37 + use crate::list::{CELL_PART_CLASSES, ROW_PART_CLASSES};
38 + use crate::{Emit, option_class};
39 + use makeover_layout::Selector;
40 + use std::collections::BTreeSet;
41 +
42 + /// Every class name the generated stylesheet defines a rule for, prefixed the
43 + /// way `opts` prefixes them.
44 + ///
45 + /// Includes the state classes a caller never spells alone (`chosen`,
46 + /// `latched`). Those are deliberately unprefixed: they qualify a prefixed
47 + /// component (`.mo-tab.chosen`) rather than standing on their own, so a prefix
48 + /// moves the thing and not its state.
49 + #[must_use]
50 + pub fn vocabulary(opts: &Emit) -> BTreeSet<String> {
51 + classes_in_css(&crate::stylesheet(opts))
52 + }
53 +
54 + /// Every class this crate can put in markup or in a rule.
55 + ///
56 + /// [`vocabulary`] plus the part classes it deliberately leaves unruled. This is
57 + /// the set to check a renderer's emitted markup against: a class outside it is
58 + /// a name that renderer invented, which is how quasi-webview came to spell
59 + /// `tabs`, `segmented` and `option` and render every described selector flat.
60 + #[must_use]
61 + pub fn names(opts: &Emit) -> BTreeSet<String> {
62 + let mut all = vocabulary(opts);
63 + all.extend(
64 + ROW_PART_CLASSES
65 + .iter()
66 + .chain(CELL_PART_CLASSES)
67 + .map(|name| crate::class(name, opts)),
68 + );
69 + all.extend(
70 + [Selector::Tabs, Selector::Segmented, Selector::Toggle]
71 + .into_iter()
72 + .map(|s| crate::class(option_class(s), opts)),
73 + );
74 + all
75 + }
76 +
77 + /// The class names a stylesheet's selectors match.
78 + ///
79 + /// Public because the check this exists for reads an app's stylesheet too, and
80 + /// comparing what makeover defines against what the app defines is only
81 + /// meaningful if both sides were read the same way.
82 + ///
83 + /// Selector text only. A declaration value can hold a dot (`0.5rem`,
84 + /// `transition: .2s`) and none of those are classes, so the scan tracks whether
85 + /// it is inside a declaration block and ignores what it finds there. An at-rule
86 + /// block (`@layer`, `@media`, `@supports`) contains rules rather than
87 + /// declarations, which is why a depth counter alone is not enough: the stack
88 + /// records what kind of block each brace opened.
89 + #[must_use]
90 + pub fn classes_in_css(css: &str) -> BTreeSet<String> {
91 + let mut found = BTreeSet::new();
92 + // One entry per open brace: true when that block holds declarations rather
93 + // than nested rules.
94 + let mut blocks: Vec<bool> = Vec::new();
95 + // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
96 + // prelude, and a prelude starting with `@` opens an at-rule.
97 + let mut prelude = String::new();
98 +
99 + let mut chars = css.chars().peekable();
100 + while let Some(c) = chars.next() {
101 + match c {
102 + '/' if chars.peek() == Some(&'*') => {
103 + // Skip a comment whole. The banner at the top of the sheet is
104 + // prose about the cascade layer and would otherwise contribute
105 + // words that look like selectors.
106 + chars.next();
107 + let mut star = false;
108 + for c in chars.by_ref() {
109 + if star && c == '/' {
110 + break;
111 + }
112 + star = c == '*';
113 + }
114 + prelude.clear();
115 + }
116 + '"' | '\'' => {
117 + // A string is opaque. `content: "\2191"` is the sort caret, not
118 + // a selector, and a brace inside one would desync the stack.
119 + let quote = c;
120 + let mut escaped = false;
121 + for c in chars.by_ref() {
122 + if escaped {
123 + escaped = false;
124 + } else if c == '\\' {
125 + escaped = true;
126 + } else if c == quote {
127 + break;
128 + }
129 + }
130 + }
131 + '{' => {
132 + let declarations = !prelude.trim_start().starts_with('@');
133 + if declarations {
134 + found.extend(classes_in_selector(&prelude));
135 + }
136 + blocks.push(declarations);
137 + prelude.clear();
138 + }
139 + '}' => {
140 + blocks.pop();
141 + prelude.clear();
142 + }
143 + ';' => prelude.clear(),
144 + // Only accumulate where a selector can live. Inside a declaration
145 + // block the text is properties and values.
146 + _ if !blocks.last().copied().unwrap_or(false) => prelude.push(c),
147 + _ => {}
148 + }
149 + }
150 + found
151 + }
152 +
153 + /// The class names one selector matches on.
154 + fn classes_in_selector(selector: &str) -> Vec<String> {
155 + let chars: Vec<char> = selector.chars().collect();
156 + let mut names = Vec::new();
157 + let mut i = 0;
158 + while i < chars.len() {
159 + // A leading digit is a length (`.5rem`), never a class: CSS forbids an
160 + // identifier starting with one.
161 + if chars[i] == '.'
162 + && chars
163 + .get(i + 1)
164 + .is_some_and(|c| c.is_alphabetic() || *c == '_')
165 + {
166 + let start = i + 1;
167 + let mut end = start;
168 + while end < chars.len()
169 + && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
170 + {
171 + end += 1;
172 + }
173 + names.push(chars[start..end].iter().collect());
174 + i = end;
175 + } else {
176 + i += 1;
177 + }
178 + }
179 + names
180 + }
181 +
182 + #[cfg(test)]
183 + mod tests {
184 + use super::*;
185 + use crate::list::{cell_part_class, part_class};
186 + use makeover_layout::{CellPart, RowPart};
187 +
188 + #[test]
189 + fn the_scrape_finds_the_components_the_sheet_is_built_from() {
190 + let v = vocabulary(&Emit::default());
191 + assert!(
192 + v.len() > 20,
193 + "scraped {} classes, which reads as a parser failure rather than a small sheet",
194 + v.len()
195 + );
196 + for name in ["card", "tab", "table-heading", "cell-value", "chosen"] {
197 + assert!(
198 + v.contains(name),
199 + "the sheet defines .{name} and the scan missed it"
200 + );
201 + }
202 + }
203 +
204 + #[test]
205 + fn every_name_a_caller_can_ask_for_is_one_this_crate_admits_to() {
206 + // The two halves of the agreement, checked against each other. A naming
207 + // function returning a class outside `names` would put a class in the
208 + // markup that nothing downstream can recognise, which is the failure
209 + // quasi-webview shipped and phase 1 exists to make impossible.
210 + let opts = Emit::default();
211 + let all = names(&opts);
212 +
213 + for selector in [Selector::Tabs, Selector::Segmented, Selector::Toggle] {
214 + let name = option_class(selector);
215 + assert!(
216 + all.contains(name),
217 + "option_class({selector:?}) is .{name}, which nothing admits to"
218 + );
219 + }
220 + for part in [
221 + RowPart::Primary,
222 + RowPart::Secondary,
223 + RowPart::Meta,
224 + RowPart::Actions,
225 + RowPart::Tokens,
226 + RowPart::Proportion,
227 + ] {
228 + let name = part_class(part);
229 + assert!(
230 + all.contains(name),
231 + "part_class({part:?}) is .{name}, which nothing admits to"
232 + );
233 + }
234 + for part in [
235 + CellPart::Value,
236 + CellPart::Tokens,
237 + CellPart::Actions,
238 + CellPart::Link,
239 + ] {
240 + let name = cell_part_class(part);
241 + assert!(
242 + all.contains(name),
243 + "cell_part_class({part:?}) is .{name}, which nothing admits to"
244 + );
245 + }
246 + }
247 +
248 + #[test]
249 + fn the_part_lists_hold_every_arm_of_the_match_beside_them() {
250 + // ROW_PART_CLASSES and CELL_PART_CLASSES are written out because a
251 + // `#[non_exhaustive]` enum cannot be enumerated. This is the test that
252 + // stops them drifting from the matches they sit next to.
253 + for part in [
254 + RowPart::Primary,
255 + RowPart::Secondary,
256 + RowPart::Meta,
257 + RowPart::Actions,
258 + RowPart::Tokens,
259 + RowPart::Proportion,
260 + ] {
261 + assert!(
262 + ROW_PART_CLASSES.contains(&part_class(part)),
263 + "{part:?} is missing from ROW_PART_CLASSES"
264 + );
265 + }
266 + for part in [
267 + CellPart::Value,
268 + CellPart::Tokens,
269 + CellPart::Actions,
270 + CellPart::Link,
271 + ] {
272 + assert!(
273 + CELL_PART_CLASSES.contains(&cell_part_class(part)),
274 + "{part:?} is missing from CELL_PART_CLASSES"
275 + );
276 + }
277 + // The fallbacks, which are what an upstream addition lands on.
278 + assert!(ROW_PART_CLASSES.contains(&"row-part"));
279 + assert!(CELL_PART_CLASSES.contains(&"cell-part"));
280 + }
281 +
282 + #[test]
283 + fn a_prefix_moves_the_component_classes_and_leaves_the_states_qualifying_them() {
284 + let plain = vocabulary(&Emit::default());
285 + let prefixed = vocabulary(&Emit {
286 + class_prefix: "mo-",
287 + ..Emit::default()
288 + });
289 + assert_eq!(
290 + plain.len(),
291 + prefixed.len(),
292 + "a prefix changed how many classes exist"
293 + );
294 + // `chosen` and `latched` never stand alone: the sheet writes
295 + // `.mo-tab.chosen`, so the state stays bare while the thing moves.
296 + let states = ["chosen", "latched"];
297 + for name in &plain {
298 + let expected = if states.contains(&name.as_str()) {
299 + name.clone()
300 + } else {
301 + format!("mo-{name}")
302 + };
303 + assert!(
304 + prefixed.contains(&expected),
305 + ".{name} did not move to .{expected} under the prefix"
306 + );
307 + }
308 + }
309 +
310 + #[test]
311 + fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
312 + let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");
313 + assert_eq!(found, ["real".to_string()].into_iter().collect());
314 + }
315 +
316 + #[test]
317 + fn an_at_rule_does_not_hide_the_selectors_inside_it() {
318 + let found = classes_in_css(
319 + "@layer makeover { @media (min-width: 40rem) { .wide { color: red; } } }",
320 + );
321 + assert_eq!(found, ["wide".to_string()].into_iter().collect());
322 + }
323 +
324 + #[test]
325 + fn a_string_is_opaque_and_a_comment_contributes_nothing() {
326 + let found = classes_in_css("/* .notaclass */ .caret::after { content: \"} .alsonot\"; }");
327 + assert_eq!(found, ["caret".to_string()].into_iter().collect());
328 + }
329 +
330 + #[test]
331 + fn a_compound_selector_yields_every_class_it_names() {
332 + let found = classes_in_css(
333 + ".tab.chosen[aria-sort=\"ascending\"] > .label:not(.muted) { color: red; }",
334 + );
335 + let expected: BTreeSet<String> = ["tab", "chosen", "label", "muted"]
336 + .into_iter()
337 + .map(String::from)
338 + .collect();
339 + assert_eq!(found, expected);
340 + }
341 + }