Skip to main content

max / makeover-webview

0.58.0: read the element rules, and read revert-layer as a handoff Two gaps in what check_vocabulary can see, both found fixing the MNW server's lost button tone. declarations_by_class keys rules by the classes in their selectors, so a rule with no class in it is invisible to it. `button { color }` is that, and it beats the generated `.button[data-tone]` on every described act in a consumer. declarations_by_element reads the other half: bare compound arms only, since a scoped rule reaches one region and whether it lands on a described component depends on where that component renders. ELEMENT_CLASSES declares which classes each element can carry. CSS cannot say it and the renderer can, and a test scrapes this crate's own emitters so the map cannot fall behind them. Both readers now carry specificity, because a remedy has to outrank the rule it remedies: `.field` does not beat `input[type="text"]`, and a handoff written as the weaker of the two only looks written. properties_in_body reads the value now. A declaration of exactly `revert-layer` takes nothing -- it is a later layer handing the property back -- so declarations_by_class drops it and deferrals_by_class keeps it. Counting it made every handoff read as the override it remedied.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 16:51 UTC
Signed with PGP, not checked
Commit: bca43ee4eafdb4ffa13b70617e350751287f2827
Parent: 056b8ce
2 files changed, +449 insertions, -16 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.57.0"
3 + version = "0.58.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/vocabulary.rs +448 -15
@@ -76,18 +76,6 @@
76 76 all
77 77 }
78 78
79 - /// The class names a stylesheet's selectors match.
80 - ///
81 - /// Public because the check this exists for reads an app's stylesheet too, and
82 - /// comparing what makeover defines against what the app defines is only
83 - /// meaningful if both sides were read the same way.
84 - ///
85 - /// Selector text only. A declaration value can hold a dot (`0.5rem`,
86 - /// `transition: .2s`) and none of those are classes, so the scan tracks whether
87 - /// it is inside a declaration block and ignores what it finds there. An at-rule
88 - /// block (`@layer`, `@media`, `@supports`) contains rules rather than
89 - /// declarations, which is why a depth counter alone is not enough: the stack
90 - /// records what kind of block each brace opened.
91 79 /// Which properties a stylesheet sets on each class it names.
92 80 ///
93 81 /// The grain a drift check actually wants. A class name in common is not by
@@ -105,15 +93,43 @@
105 93 /// consumer of this needs a way to say a pair was reviewed. Deciding that here
106 94 /// would need a selector matcher, and a check that guesses wrong about
107 95 /// specificity fails correct builds.
96 + ///
97 + /// A declaration whose value is exactly `revert-layer` is not one of them. It
98 + /// takes nothing by construction: it is a later layer handing the property back
99 + /// to the one below, which is the opposite of the thing this reader is looking
100 + /// for. Counting it made every handoff in a consumer's sheet look like an
101 + /// override, and the allowlist entry written to silence one went on permitting
102 + /// a real override on the same pair afterwards. [`deferrals_by_class`] is where
103 + /// those declarations go instead.
108 104 #[must_use]
109 105 pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
106 + by_class(css, |value| !is_handoff(value))
107 + }
108 +
109 + /// Which properties a stylesheet hands back to the layer below, per class.
110 + ///
111 + /// The other half of [`declarations_by_class`]. A `revert-layer` says "whatever
112 + /// the design system set here, keep it", so a checker reading a consumer's
113 + /// sheet wants it as evidence that a clash was already remedied rather than as
114 + /// a clash of its own.
115 + #[must_use]
116 + pub fn deferrals_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
117 + by_class(css, is_handoff)
118 + }
119 +
120 + /// [`declarations_by_class`] and [`deferrals_by_class`], which differ only in
121 + /// which declarations they keep.
122 + fn by_class(css: &str, keep: impl Fn(&str) -> bool) -> BTreeMap<String, BTreeSet<String>> {
110 123 let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
111 124 for (selector, body) in rules(css) {
112 125 let classes = classes_in_selector(&selector);
113 126 if classes.is_empty() {
114 127 continue;
115 128 }
116 - let properties = properties_in_body(&body);
129 + let properties = properties_in_body(&body, &keep);
130 + if properties.is_empty() {
131 + continue;
132 + }
117 133 for class in classes {
118 134 out.entry(class).or_default().extend(properties.clone());
119 135 }
@@ -121,10 +137,192 @@
121 137 out
122 138 }
123 139
124 - /// The property names a declaration block sets.
125 - fn properties_in_body(body: &str) -> BTreeSet<String> {
140 + /// Which properties a stylesheet sets on each bare element it names.
141 + ///
142 + /// The blind spot [`declarations_by_class`] has by construction: it keys rules
143 + /// by the classes in their selectors, so a rule carrying no class at all is
144 + /// invisible to it. `button { color: var(--content) }` is exactly that, and it
145 + /// sets the same property the generated `.button` does on every described act
146 + /// in the app -- including the tone of a destructive one, which is how a delete
147 + /// came to look like an ordinary button for months with the check reporting
148 + /// nothing.
149 + ///
150 + /// Only a selector arm that is one bare compound counts: `button`,
151 + /// `button:hover`, `input[type="text"]`. A scoped arm (`.page button`) reaches
152 + /// the elements inside one region rather than every one of them, so whether it
153 + /// lands on a described act depends on where that act is rendered, and a check
154 + /// that guessed would fail correct builds. The certain case is the one this
155 + /// reads.
156 + ///
157 + /// Pair the result against [`classes_for_element`] to ask the question a
158 + /// checker wants: does this element rule take a property the design system sets
159 + /// on a class that element can carry.
160 + ///
161 + /// The answer carries the strongest arm each property was set on, because the
162 + /// app's own remedy has to outrank the rule it remedies. `.field` does not beat
163 + /// `input[type="text"]`: both are the app's, both are in the same layer, and
164 + /// the attribute makes the element rule the more specific of the two. A check
165 + /// reading only "the app mentions this pair somewhere" waves that straight
166 + /// through, which is the shape of every handoff that looked written and was
167 + /// not.
168 + #[must_use]
169 + pub fn declarations_by_element(css: &str) -> BTreeMap<String, BTreeMap<String, Specificity>> {
170 + let mut out: BTreeMap<String, BTreeMap<String, Specificity>> = BTreeMap::new();
171 + for (selector, body) in rules(css) {
172 + let properties = properties_in_body(&body, |value| !is_handoff(value));
173 + if properties.is_empty() {
174 + continue;
175 + }
176 + for arm in selector.split(',') {
177 + let Some(element) = bare_element(arm) else {
178 + continue;
179 + };
180 + let rank = specificity(arm);
181 + let entry = out.entry(element).or_default();
182 + for property in &properties {
183 + let strongest = entry.entry(property.clone()).or_default();
184 + *strongest = (*strongest).max(rank);
185 + }
186 + }
187 + }
188 + out
189 + }
190 +
191 + /// What a stylesheet says about each class, and how strongly.
192 + ///
193 + /// Every property the sheet names on a class, whether it takes it or hands it
194 + /// back, keyed by the strongest arm that names it. The question it answers is
195 + /// not "does this collide" -- [`declarations_by_class`] is that -- but "has the
196 + /// app spoken for this pair, in a rule that wins where it has to".
197 + #[must_use]
198 + pub fn mentions_by_class(css: &str) -> BTreeMap<String, BTreeMap<String, Specificity>> {
199 + let mut out: BTreeMap<String, BTreeMap<String, Specificity>> = BTreeMap::new();
200 + for (selector, body) in rules(css) {
201 + let properties = properties_in_body(&body, |_| true);
202 + if properties.is_empty() {
203 + continue;
204 + }
205 + for arm in selector.split(',') {
206 + let classes = classes_in_selector(arm);
207 + if classes.is_empty() {
208 + continue;
209 + }
210 + let rank = specificity(arm);
211 + for class in classes {
212 + let entry = out.entry(class).or_default();
213 + for property in &properties {
214 + let strongest = entry.entry(property.clone()).or_default();
215 + *strongest = (*strongest).max(rank);
216 + }
217 + }
218 + }
219 + }
220 + out
221 + }
222 +
223 + /// How CSS ranks one selector: ids, then classes, then elements.
224 + ///
225 + /// Ordered the way the cascade orders it, so the tuple comparison is the
226 + /// cascade's comparison. It settles a contest between two rules in the same
227 + /// layer, which is the only contest it is used for here: a layer beats
228 + /// specificity outright, so nothing in the app's sheet has to be compared
229 + /// against the generated one this way.
230 + pub type Specificity = (usize, usize, usize);
231 +
232 + /// The specificity of one selector arm.
233 + ///
234 + /// A functional pseudo-class counts as one class and its argument is not read.
235 + /// CSS says `:not(.a.b)` takes the specificity of its strongest argument, so
236 + /// this undercounts a compound inside one -- which puts the error on the side
237 + /// of reporting a remedy as too weak rather than accepting one that is.
238 + #[must_use]
239 + pub fn specificity(selector: &str) -> Specificity {
240 + let chars: Vec<char> = selector.chars().collect();
241 + let (mut ids, mut classes, mut elements) = (0, 0, 0);
242 + let mut i = 0;
243 + while i < chars.len() {
244 + match chars[i] {
245 + '#' => {
246 + ids += 1;
247 + i = skip_name(&chars, i + 1);
248 + }
249 + '.' => {
250 + classes += 1;
251 + i = skip_name(&chars, i + 1);
252 + }
253 + ':' => {
254 + // `::before` is an element, `:hover` is a class.
255 + if chars.get(i + 1) == Some(&':') {
256 + elements += 1;
257 + i = skip_name(&chars, i + 2);
258 + } else {
259 + classes += 1;
260 + i = skip_name(&chars, i + 1);
261 + }
262 + if chars.get(i) == Some(&'(') {
263 + i = skip_group(&chars, i);
264 + }
265 + }
266 + '[' => {
267 + classes += 1;
268 + i = skip_group(&chars, i);
269 + }
270 + c if c.is_ascii_alphabetic() => {
271 + elements += 1;
272 + i = skip_name(&chars, i);
273 + }
274 + // A combinator, whitespace, or the universal selector, none of
275 + // which count for anything.
276 + _ => i += 1,
277 + }
278 + }
279 + (ids, classes, elements)
280 + }
281 +
282 + /// Past the identifier starting at `from`.
283 + fn skip_name(chars: &[char], from: usize) -> usize {
284 + let mut i = from;
285 + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
286 + i += 1;
287 + }
288 + i
289 + }
290 +
291 + /// Past the bracketed or parenthesised group opening at `from`, nesting and
292 + /// all.
293 + fn skip_group(chars: &[char], from: usize) -> usize {
294 + let mut depth = 0usize;
295 + let mut i = from;
296 + while i < chars.len() {
297 + match chars[i] {
298 + '[' | '(' => depth += 1,
299 + ']' | ')' => {
300 + depth -= 1;
301 + if depth == 0 {
302 + return i + 1;
303 + }
304 + }
305 + _ => {}
306 + }
307 + i += 1;
308 + }
309 + i
310 + }
311 +
312 + /// A value that hands the property back rather than taking it.
313 + ///
314 + /// Bare only. `revert-layer !important` in a later layer inverts layer order
315 + /// and takes the property from every layer below, which is the opposite
316 + /// declaration wearing the same word.
317 + fn is_handoff(value: &str) -> bool {
318 + value.trim() == "revert-layer"
319 + }
320 +
321 + /// The property names a declaration block sets, keeping the ones `keep` admits.
322 + fn properties_in_body(body: &str, keep: impl Fn(&str) -> bool) -> BTreeSet<String> {
126 323 body.split(';')
127 324 .filter_map(|decl| decl.split_once(':'))
325 + .filter(|(_, value)| keep(value))
128 326 .map(|(name, _)| name.trim().to_string())
129 327 .filter(|name| !name.is_empty() && !name.contains(['{', '}']))
130 328 .collect()
@@ -236,6 +434,150 @@
236 434 out
237 435 }
238 436
437 + /// Which generated classes each element can plausibly carry.
438 + ///
439 + /// The half of the element check that CSS cannot answer. A stylesheet says
440 + /// `button { color: ... }` and `.chip { color: ... }` and nothing in either
441 + /// text says a chip is rendered as a `<button>`; the renderer knows that, and
442 + /// this crate is the renderer. So the pairing is declared here rather than
443 + /// inferred, and [`declarations_by_element`] supplies the other half.
444 + ///
445 + /// Read it as "may carry", not "does carry". A pairing that never occurs in a
446 + /// given app costs a check that finds nothing; a pairing left out is a defect
447 + /// that ships, which is the trade this list is written on the generous side
448 + /// of.
449 + ///
450 + /// `div` and `span` are deliberately absent. Nearly every container class in
451 + /// the vocabulary sits on one of them, so the pairing would be the whole
452 + /// vocabulary against one rule and would say nothing about which class was
453 + /// meant. An app writing a bare `div { }` rule has a wider problem than this
454 + /// check, and the classes it would clobber are containers rather than the
455 + /// controls whose tone and bevel carry meaning.
456 + pub const ELEMENT_CLASSES: &[(&str, &[&str])] = &[
457 + // The controls. `a` and `button` are interchangeable in markup for most of
458 + // these -- a link that posts is a button, an act that navigates is an
459 + // anchor -- which is why the two lists overlap as much as they do.
460 + (
461 + "a",
462 + &[
463 + "link",
464 + "button",
465 + "tab",
466 + "chip",
467 + "badge",
468 + "card",
469 + "row-activate",
470 + "figure-act",
471 + "chrome-place",
472 + ],
473 + ),
474 + (
475 + "button",
476 + &[
477 + "button",
478 + "chip",
479 + "segment",
480 + "toggle",
481 + "tab",
482 + "link",
483 + "badge",
484 + "card",
485 + "facet-take",
486 + "facet-prune",
487 + "chip-remove",
488 + "row-activate",
489 + ],
490 + ),
491 + // A disclosure. quasi-webview renders an ask as `<details>` with a
492 + // `<summary>` that is styled as an act.
493 + ("details", &["ask"]),
494 + ("summary", &["button", "ask-open", "ask-body"]),
495 + // The form controls. `.field` is the well every one of them sits in.
496 + ("input", &["field", "toggle", "row-select"]),
497 + ("select", &["field"]),
498 + ("textarea", &["field"]),
499 + (
500 + "label",
501 + &[
502 + "form-label",
503 + "form-checkbox-label",
504 + "form-radio-label",
505 + "toggle",
506 + ],
507 + ),
508 + ("form", &["form"]),
509 + ("progress", &["progress"]),
510 + // Text and lists.
511 + ("p", &["text", "facet-name", "placeholder-text"]),
512 + ("ul", &["list", "facet-values"]),
513 + ("ol", &["list"]),
514 + ("li", &["facet-value"]),
515 + // A table written in HTML rather than described. quasi-webview renders a
516 + // described table as divs carrying the same classes, so both spellings of
517 + // the same table answer to the same rules and both are worth checking.
518 + ("table", &["table"]),
519 + ("thead", &["table-head"]),
520 + ("tr", &["table-row"]),
521 + ("td", &["cell", "cell-value", "cell-content"]),
522 + ("th", &["table-heading"]),
523 + // A figure, likewise: the described picture is divs, the hand-written one
524 + // is the HTML element that means the same thing.
525 + ("figure", &["picture", "figure"]),
526 + ("img", &["picture-img"]),
527 + ("figcaption", &["picture-caption", "figure-caption"]),
528 + ("nav", &["chrome-nav"]),
529 + ];
530 +
531 + /// The generated classes `element` can carry, prefixed the way `opts` prefixes
532 + /// them.
533 + ///
534 + /// Empty for an element the design system never renders onto, which is the
535 + /// answer for most of them: a rule on one of those cannot collide with a
536 + /// generated class because no generated class is ever on it.
537 + #[must_use]
538 + pub fn classes_for_element(element: &str, opts: &Emit) -> BTreeSet<String> {
539 + ELEMENT_CLASSES
540 + .iter()
541 + .find(|(name, _)| *name == element)
542 + .map(|(_, classes)| classes.iter().map(|c| crate::class(c, opts)).collect())
543 + .unwrap_or_default()
544 + }
545 +
546 + /// The element name of one bare compound arm, if that is what it is.
547 + fn bare_element(arm: &str) -> Option<String> {
548 + // An attribute value or a `:not()` argument can hold anything, including
549 + // the spaces and dots this then rejects on. Neither changes which element
550 + // the arm styles, so both go before the test rather than into it.
551 + let mut flat = String::with_capacity(arm.len());
552 + let mut depth = 0usize;
553 + for c in arm.chars() {
554 + match c {
555 + '[' | '(' => depth += 1,
556 + ']' | ')' => depth = depth.saturating_sub(1),
557 + _ if depth == 0 => flat.push(c),
558 + _ => {}
559 + }
560 + }
561 + let flat = flat.trim();
562 + // A descendant, a child, a class, an id or a universal: not this.
563 + if flat.is_empty() || flat.contains(['.', '#', '>', '+', '~', '*']) {
564 + return None;
565 + }
566 + if flat.chars().any(char::is_whitespace) {
567 + return None;
568 + }
569 + let name: String = flat
570 + .chars()
571 + .take_while(|c| c.is_alphanumeric() || *c == '-')
572 + .collect();
573 + // A pseudo-element on nothing (`::selection`) or a pseudo-class on nothing
574 + // (`:root`) names no element.
575 + if !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
576 + return None;
577 + }
578 + Some(name.to_ascii_lowercase())
579 + }
580 +
239 581 /// The class names one selector matches on.
240 582 fn classes_in_selector(selector: &str) -> Vec<String> {
241 583 let chars: Vec<char> = selector.chars().collect();
@@ -395,6 +737,238 @@
395 737 }
396 738 }
397 739
740 + #[test]
741 + fn a_handoff_is_not_an_override() {
742 + // The defect this split fixes. `revert-layer` in a later layer gives
743 + // the property back to the design system, so counting it as a taking
744 + // made every remedy in a consumer's sheet read as the thing it
745 + // remedied -- and the allowlist entry written to silence one went on
746 + // permitting a real override on the same pair for good.
747 + let css = ".button { background: revert-layer; color: red; }";
748 + let taken = declarations_by_class(css);
749 + let given = deferrals_by_class(css);
750 + assert_eq!(
751 + taken.get("button"),
752 + Some(&["color".to_string()].into_iter().collect())
753 + );
754 + assert_eq!(
755 + given.get("button"),
756 + Some(&["background".to_string()].into_iter().collect())
757 + );
758 + }
759 +
760 + #[test]
761 + fn an_important_handoff_is_an_override() {
762 + // `revert-layer !important` in a later layer inverts layer order and
763 + // takes the property from every layer below it. Same word, opposite
764 + // declaration, and the one shape of it this reader must not wave
765 + // through.
766 + let css = ".button { background: revert-layer !important; }";
767 + assert_eq!(
768 + declarations_by_class(css).get("button"),
769 + Some(&["background".to_string()].into_iter().collect())
770 + );
771 + assert!(!deferrals_by_class(css).contains_key("button"));
772 + }
773 +
774 + #[test]
775 + fn a_class_that_only_hands_properties_back_is_not_in_the_taking_set() {
776 + // An empty entry would read as "this class collides on nothing", which
777 + // is true, and as "this class is in the map", which is what a caller
778 + // iterating the map would act on.
779 + let by_class = declarations_by_class(".field { background: revert-layer; }");
780 + assert!(!by_class.contains_key("field"), "got {by_class:?}");
781 + }
782 +
783 + #[test]
784 + fn an_element_rule_is_read_where_a_class_reader_sees_nothing() {
785 + let css = "button { color: red; background: blue; }";
786 + assert!(declarations_by_class(css).is_empty());
787 + let by_element = declarations_by_element(css);
788 + let button = by_element.get("button").expect("button is named");
789 + assert_eq!(
790 + button.keys().cloned().collect::<Vec<_>>(),
791 + ["background", "color"]
792 + );
793 + // One element, nothing else: (0, 0, 1).
794 + assert_eq!(button["color"], (0, 0, 1));
795 + }
796 +
797 + #[test]
798 + fn the_strongest_arm_is_the_one_reported() {
799 + // A remedy has to outrank the rule it remedies, so a reader that kept
800 + // the weakest arm would call a losing handoff sufficient.
801 + let css = "input { color: red; }\ninput[type=\"text\"]:focus { color: blue; }\n";
802 + assert_eq!(declarations_by_element(css)["input"]["color"], (0, 2, 1));
803 + }
804 +
805 + #[test]
806 + fn a_selector_is_ranked_the_way_the_cascade_ranks_it() {
807 + for (selector, expected) in [
808 + ("button", (0, 0, 1)),
809 + ("*", (0, 0, 0)),
810 + (".field", (0, 1, 0)),
811 + ("input.field", (0, 1, 1)),
812 + ("input[type=\"text\"]", (0, 1, 1)),
813 + ("button:hover", (0, 1, 1)),
814 + ("button::before", (0, 0, 2)),
815 + ("#main .card > button:focus-visible", (1, 2, 1)),
816 + (".chip.latched[aria-pressed=\"true\"]", (0, 3, 0)),
817 + ("button:not(.link)", (0, 1, 1)),
818 + ] {
819 + assert_eq!(specificity(selector), expected, "{selector}");
820 + }
821 + }
822 +
823 + #[test]
824 + fn what_a_class_is_spoken_for_by_counts_a_handoff_as_speech() {
825 + // A handoff takes nothing, so `declarations_by_class` is right to drop
826 + // it -- and it is still the app saying what happens to that property on
827 + // that class, which is what this reader is for.
828 + let css = ".field { background: revert-layer; }\ninput.field:focus { color: red; }\n";
829 + let mentions = mentions_by_class(css);
830 + assert_eq!(mentions["field"]["background"], (0, 1, 0));
Lines truncated