max / makeover-webview
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+449 insertions,
-16 deletions
| @@ -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 |
| @@ -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