Skip to main content

max / makeover-webview

0.30.0: which properties a class carries, not just which classes exist Running the class-name check against goingson found the grain was wrong. Nine classes are shared between its stylesheet and the generated one, and every shared name is deliberate: `.badge` sets shape in the app and colour in the generated sheet, and the app's own comment beside it reads "Fill, edge and text colour come from the generated .badge in layout.css. Do not add background, border or box-shadow here." A check on names would have demanded deleting that. A shared property is the thing that actually goes wrong, because app CSS is unlayered and takes the property off the design system silently. So declarations_by_class reports what each class carries, and the comment above becomes something a build can enforce. One reader now answers both questions: classes_in_css is expressed in terms of the rule walk rather than repeating it, which is what makes reading makeover's sheet and an app's sheet the same operation. A string contributes its quotes and not its contents, since url("a;b:c") would otherwise read as two declarations.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 01:43 UTC
Commit: 90b22eceb289e2e2e938cb28f5e1de540452c52d
Parent: 1cfbe84
2 files changed, +144 insertions, -16 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.29.0"
3 + version = "0.30.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 +143 -15
@@ -37,7 +37,7 @@
37 37 use crate::list::{CELL_PART_CLASSES, ROW_PART_CLASSES};
38 38 use crate::{Emit, option_class};
39 39 use makeover_layout::Selector;
40 - use std::collections::BTreeSet;
40 + use std::collections::{BTreeMap, BTreeSet};
41 41
42 42 /// Every class name the generated stylesheet defines a rule for, prefixed the
43 43 /// way `opts` prefixes them.
@@ -86,23 +86,84 @@
86 86 /// block (`@layer`, `@media`, `@supports`) contains rules rather than
87 87 /// declarations, which is why a depth counter alone is not enough: the stack
88 88 /// records what kind of block each brace opened.
89 + /// Which properties a stylesheet sets on each class it names.
90 + ///
91 + /// The grain a drift check actually wants. A class name in common is not by
92 + /// itself a divergence: goingson's `.badge` sets shape and the generated
93 + /// `.badge` sets fill and edge, and the app's own comment says "do not add
94 + /// background, border or box-shadow here". That arrangement is settled and
95 + /// correct, so a check that flagged the shared name would demand deleting it.
96 + /// A shared *property* is the thing that goes wrong, because app CSS is
97 + /// unlayered and takes the property from the design system silently.
98 + ///
99 + /// A property appearing under more than one selector arm collapses into one
100 + /// entry. That loses a real distinction -- the sort caret's reserved gap is
101 + /// `content` on the unsorted arm and the generated caret is `content` on the
102 + /// sorted one, which is a deliberate pairing rather than a clash -- so a
103 + /// consumer of this needs a way to say a pair was reviewed. Deciding that here
104 + /// would need a selector matcher, and a check that guesses wrong about
105 + /// specificity fails correct builds.
106 + #[must_use]
107 + pub fn declarations_by_class(css: &str) -> BTreeMap<String, BTreeSet<String>> {
108 + let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
109 + for (selector, body) in rules(css) {
110 + let classes = classes_in_selector(&selector);
111 + if classes.is_empty() {
112 + continue;
113 + }
114 + let properties = properties_in_body(&body);
115 + for class in classes {
116 + out.entry(class).or_default().extend(properties.clone());
117 + }
118 + }
119 + out
120 + }
121 +
122 + /// The property names a declaration block sets.
123 + fn properties_in_body(body: &str) -> BTreeSet<String> {
124 + body.split(';')
125 + .filter_map(|decl| decl.split_once(':'))
126 + .map(|(name, _)| name.trim().to_string())
127 + .filter(|name| !name.is_empty() && !name.contains(['{', '}']))
128 + .collect()
129 + }
130 +
89 131 #[must_use]
90 132 pub fn classes_in_css(css: &str) -> BTreeSet<String> {
91 - let mut found = BTreeSet::new();
133 + rules(css)
134 + .into_iter()
135 + .flat_map(|(selector, _)| classes_in_selector(&selector))
136 + .collect()
137 + }
138 +
139 + /// `(selector, declaration block)` for every rule in a stylesheet.
140 + ///
141 + /// One reader for both sides. Comparing what makeover defines against what an
142 + /// app defines is only meaningful if the two were read the same way, which is
143 + /// why this is the only place either question is answered from.
144 + ///
145 + /// A comment is skipped whole: the banner at the top of the generated sheet is
146 + /// prose about the cascade layer and would otherwise contribute words that look
147 + /// like selectors. A string is opaque, because `content: "\2191"` is the sort
148 + /// caret rather than a selector and a brace inside one would desync the stack.
149 + /// An at-rule block (`@layer`, `@media`, `@supports`) holds rules rather than
150 + /// declarations, so a depth counter alone is not enough and the stack records
151 + /// what kind of block each brace opened.
152 + fn rules(css: &str) -> Vec<(String, String)> {
153 + let mut out = Vec::new();
92 154 // One entry per open brace: true when that block holds declarations rather
93 155 // than nested rules.
94 156 let mut blocks: Vec<bool> = Vec::new();
95 157 // Text since the last `{`, `}` or `;`. What precedes a `{` is that block's
96 158 // prelude, and a prelude starting with `@` opens an at-rule.
97 159 let mut prelude = String::new();
160 + // The selector of each open declaration block, and the body so far.
161 + let mut open: Vec<(String, String)> = Vec::new();
98 162
99 163 let mut chars = css.chars().peekable();
100 164 while let Some(c) = chars.next() {
101 165 match c {
102 166 '/' 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 167 chars.next();
107 168 let mut star = false;
108 169 for c in chars.by_ref() {
@@ -114,10 +175,15 @@
114 175 prelude.clear();
115 176 }
116 177 '"' | '\'' => {
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 178 let quote = c;
120 179 let mut escaped = false;
180 + // Keep the quotes in the body: a value is not a property name,
181 + // and dropping them would join two declarations into one.
182 + if blocks.last().copied().unwrap_or(false) {
183 + if let Some((_, body)) = open.last_mut() {
184 + body.push(quote);
185 + }
186 + }
121 187 for c in chars.by_ref() {
122 188 if escaped {
123 189 escaped = false;
@@ -127,27 +193,45 @@
127 193 break;
128 194 }
129 195 }
196 + // The closing quote only. A value holding `;` or `:` would
197 + // otherwise read as two declarations, and `url("a;b:c")` is a
198 + // real thing an app writes.
199 + if blocks.last().copied().unwrap_or(false) {
200 + if let Some((_, body)) = open.last_mut() {
201 + body.push(quote);
202 + }
203 + }
130 204 }
131 205 '{' => {
132 206 let declarations = !prelude.trim_start().starts_with('@');
133 207 if declarations {
134 - found.extend(classes_in_selector(&prelude));
208 + open.push((prelude.clone(), String::new()));
135 209 }
136 210 blocks.push(declarations);
137 211 prelude.clear();
138 212 }
139 213 '}' => {
140 - blocks.pop();
214 + if blocks.pop().unwrap_or(false) {
215 + if let Some(rule) = open.pop() {
216 + out.push(rule);
217 + }
218 + }
141 219 prelude.clear();
142 220 }
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 - _ => {}
221 + _ => {
222 + if blocks.last().copied().unwrap_or(false) {
223 + if let Some((_, body)) = open.last_mut() {
224 + body.push(c);
225 + }
226 + } else if c == ';' {
227 + prelude.clear();
228 + } else {
229 + prelude.push(c);
230 + }
231 + }
148 232 }
149 233 }
150 - found
234 + out
151 235 }
152 236
153 237 /// The class names one selector matches on.
@@ -307,6 +391,50 @@
307 391 }
308 392 }
309 393
394 + #[test]
395 + fn the_properties_a_class_carries_are_read_per_class() {
396 + let css = ".badge { padding: 1px; font-weight: 600; }\n .badge[data-color] { border: 1px solid red; }\n @media (min-width: 40rem) { .badge { padding: 2px; } }\n";
397 + let by_class = declarations_by_class(css);
398 + let badge = by_class.get("badge").expect("badge is named");
399 + // Every arm collapses into one entry, including the one inside the
400 + // media block: they are all the same class carrying the same property.
401 + assert!(badge.contains("padding"));
402 + assert!(badge.contains("font-weight"));
403 + assert!(badge.contains("border"));
404 + assert_eq!(badge.len(), 3);
405 + }
406 +
407 + #[test]
408 + fn a_value_holding_a_colon_or_a_semicolon_is_not_read_as_a_property() {
409 + let css = ".x { background: url(\"a;b:c\"); color: red; }";
410 + let by_class = declarations_by_class(css);
411 + let x = by_class.get("x").expect("x is named");
412 + assert_eq!(
413 + *x,
414 + ["background".to_string(), "color".to_string()]
415 + .into_iter()
416 + .collect::<BTreeSet<_>>()
417 + );
418 + }
419 +
420 + #[test]
421 + fn the_generated_sheet_sets_fill_on_a_badge_and_not_its_shape() {
422 + // The fact goingson's stylesheet states in prose next to its own
423 + // `.badge`: "Fill, edge and text colour come from the generated .badge
424 + // in layout.css... Do not add background, border or box-shadow here."
425 + // A property-grain reader is what turns that comment into a check.
426 + let by_class = declarations_by_class(&crate::stylesheet(&Emit::default()));
427 + let badge = by_class.get("badge").expect("the sheet defines .badge");
428 + // Token::Badge is Depth::Flat, so a badge carries no bevel and no
429 + // fill: what the generated sheet gives it is the text colour, and
430 + // everything about its shape is the app's.
431 + assert!(badge.contains("color"), "got {badge:?}");
432 + assert!(
433 + !badge.contains("padding"),
434 + "shape is the app's, got {badge:?}"
435 + );
436 + }
437 +
310 438 #[test]
311 439 fn a_declaration_value_holding_a_dot_is_not_read_as_a_class() {
312 440 let found = classes_in_css(".real { transition: .2s ease; margin: 0.5rem; }");