diff --git a/crates/op-html/src/css/cascade.rs b/crates/op-html/src/css/cascade.rs
new file mode 100644
index 000000000..dd6b59b63
--- /dev/null
+++ b/crates/op-html/src/css/cascade.rs
@@ -0,0 +1,317 @@
+use std::collections::{BTreeMap, BTreeSet};
+
+use crate::css::declarations::{parse_declarations, Declaration};
+use crate::css::selectors::{matches, parse_selector_list, specificity, Selector};
+use crate::dom::DomElement;
+use crate::length::{parse_length, CssLength, LengthCtx};
+
+pub const UA_STYLESHEET: &str = "\
+body{font-size:16px;color:#111111}\
+h1{font-size:32px;font-weight:700;margin:21px 0}\
+h2{font-size:24px;font-weight:700;margin:20px 0}\
+h3{font-size:19px;font-weight:700;margin:18px 0}\
+h4{font-size:16px;font-weight:700;margin:21px 0}\
+h5{font-size:13px;font-weight:700;margin:22px 0}\
+h6{font-size:11px;font-weight:700;margin:24px 0}\
+p{margin:16px 0}\
+ul,ol{margin:16px 0;padding:0 0 0 40px}\
+b,strong{font-weight:700}\
+i,em{font-style:italic}\
+u{text-decoration:underline}\
+s,del,strike{text-decoration:line-through}\
+a{color:#0066cc;text-decoration:underline}\
+code,pre{font-family:monospace}\
+hr{margin:8px 0}";
+
+#[derive(Clone, Debug)]
+pub struct StyleRule {
+ pub selector: Selector,
+ pub declarations: Vec,
+ pub order: usize,
+}
+
+#[derive(Clone, Debug)]
+pub struct ComputedStyle {
+ pub props: BTreeMap,
+ pub font_size: f64,
+}
+
+impl ComputedStyle {
+ pub fn get(&self, name: &str) -> Option<&str> {
+ self.props.get(name).map(String::as_str)
+ }
+}
+
+pub fn parse_stylesheet(css: &str, first_order: usize) -> (Vec, Vec) {
+ let css = strip_comments(css);
+ let mut rules = Vec::new();
+ let mut warnings = Vec::new();
+ let mut warned_at_rules = BTreeSet::new();
+ let mut index = 0;
+ let mut order = first_order;
+ while index < css.len() {
+ index = skip_whitespace(&css, index);
+ if index >= css.len() {
+ break;
+ }
+ if css.as_bytes()[index] == b'@' {
+ let name_end = css[index + 1..]
+ .find(|ch: char| ch.is_whitespace() || ch == '{' || ch == ';')
+ .map_or(css.len(), |offset| index + 1 + offset);
+ let name = css[index + 1..name_end].to_ascii_lowercase();
+ if warned_at_rules.insert(name.clone()) {
+ warnings.push(at_rule_warning(&name));
+ }
+ let next_brace = css[index..].find('{').map(|offset| index + offset);
+ let next_semicolon = css[index..].find(';').map(|offset| index + offset);
+ index = match (next_brace, next_semicolon) {
+ (Some(brace), Some(semicolon)) if semicolon < brace => semicolon + 1,
+ (Some(brace), _) => matching_brace(&css, brace).map_or(css.len(), |end| end + 1),
+ (_, Some(semicolon)) => semicolon + 1,
+ _ => css.len(),
+ };
+ continue;
+ }
+ let Some(open_offset) = css[index..].find('{') else {
+ break;
+ };
+ let open = index + open_offset;
+ let Some(close) = matching_brace(&css, open) else {
+ break;
+ };
+ let selectors = parse_selector_list(css[index..open].trim());
+ let declarations = parse_declarations(&css[open + 1..close]);
+ for selector in selectors {
+ rules.push(StyleRule {
+ selector,
+ declarations: declarations.clone(),
+ order,
+ });
+ order = order.saturating_add(1);
+ }
+ index = close + 1;
+ }
+ (rules, warnings)
+}
+
+fn strip_comments(css: &str) -> String {
+ let mut stripped = String::with_capacity(css.len());
+ let mut rest = css;
+ while let Some(start) = rest.find("/*") {
+ stripped.push_str(&rest[..start]);
+ let after_start = &rest[start + 2..];
+ if let Some(end) = after_start.find("*/") {
+ rest = &after_start[end + 2..];
+ } else {
+ rest = "";
+ }
+ }
+ stripped.push_str(rest);
+ stripped
+}
+
+fn skip_whitespace(css: &str, mut index: usize) -> usize {
+ while index < css.len() && css.as_bytes()[index].is_ascii_whitespace() {
+ index += 1;
+ }
+ index
+}
+
+fn matching_brace(css: &str, open: usize) -> Option {
+ let mut depth = 0u32;
+ let mut quote = None;
+ for (offset, ch) in css[open..].char_indices() {
+ if let Some(active) = quote {
+ if ch == active {
+ quote = None;
+ }
+ continue;
+ }
+ match ch {
+ '\'' | '"' => quote = Some(ch),
+ '{' => depth += 1,
+ '}' => {
+ depth = depth.saturating_sub(1);
+ if depth == 0 {
+ return Some(open + offset);
+ }
+ }
+ _ => {}
+ }
+ }
+ None
+}
+
+fn at_rule_warning(name: &str) -> String {
+ if name == "media" {
+ "@media rules ignored (import viewport applies)".to_string()
+ } else {
+ format!("@{name} rules ignored")
+ }
+}
+
+pub fn compute_style(
+ path: &[&DomElement],
+ rules: &[StyleRule],
+ parent: Option<&ComputedStyle>,
+ root_font_size: f64,
+) -> ComputedStyle {
+ let mut matching_rules: Vec<_> = rules
+ .iter()
+ .filter(|rule| matches(&rule.selector, path))
+ .map(|rule| (specificity(&rule.selector), rule.order, rule))
+ .collect();
+ matching_rules.sort_by_key(|(specificity, order, _)| (*specificity, *order));
+
+ let inline = path
+ .last()
+ .and_then(|element| element.attr("style"))
+ .map(parse_declarations)
+ .unwrap_or_default();
+ let mut props = BTreeMap::new();
+ apply_rule_declarations(&mut props, &matching_rules, false);
+ apply_declarations(&mut props, &inline, false);
+ apply_rule_declarations(&mut props, &matching_rules, true);
+ apply_declarations(&mut props, &inline, true);
+
+ let declared_font_size = props.get("font-size").cloned();
+ if let Some(parent) = parent {
+ for &property in INHERITED_PROPERTIES {
+ if !props.contains_key(property) {
+ if let Some(value) = parent.props.get(property) {
+ props.insert(property.to_string(), value.clone());
+ }
+ }
+ }
+ }
+ let inherited_size = parent.map_or(root_font_size, |style| style.font_size);
+ let font_size = declared_font_size
+ .as_deref()
+ .and_then(|value| resolve_font_size(value, inherited_size, root_font_size))
+ .unwrap_or(inherited_size);
+ ComputedStyle { props, font_size }
+}
+
+const INHERITED_PROPERTIES: &[&str] = &[
+ "color",
+ "font-family",
+ "font-size",
+ "font-weight",
+ "font-style",
+ "line-height",
+ "letter-spacing",
+ "text-align",
+];
+
+fn apply_rule_declarations(
+ props: &mut BTreeMap,
+ rules: &[((u32, u32, u32), usize, &StyleRule)],
+ important: bool,
+) {
+ for (_, _, rule) in rules {
+ apply_declarations(props, &rule.declarations, important);
+ }
+}
+
+fn apply_declarations(
+ props: &mut BTreeMap,
+ declarations: &[Declaration],
+ important: bool,
+) {
+ for declaration in declarations
+ .iter()
+ .filter(|declaration| declaration.important == important)
+ {
+ props.insert(declaration.name.clone(), declaration.value.clone());
+ }
+}
+
+fn resolve_font_size(value: &str, parent_size: f64, root_size: f64) -> Option {
+ let context = LengthCtx {
+ font_size: parent_size,
+ root_font_size: root_size,
+ viewport_w: 0.0,
+ viewport_h: 0.0,
+ };
+ match parse_length(value, &context)? {
+ CssLength::Px(value) => Some(value),
+ CssLength::Percent(percent) => Some(parent_size * percent / 100.0),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dom::DomElement;
+
+ fn el(tag: &str, class: &str, style: &str) -> DomElement {
+ let mut attrs = Vec::new();
+ if !class.is_empty() {
+ attrs.push(("class".into(), class.into()));
+ }
+ if !style.is_empty() {
+ attrs.push(("style".into(), style.into()));
+ }
+ DomElement {
+ tag: tag.into(),
+ attrs,
+ children: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn specificity_and_order_win() {
+ let (rules, _) = parse_stylesheet(
+ "p { color: #111111 } .hot { color: #ff0000 } p { margin-top: 4px }",
+ 100,
+ );
+ let paragraph = el("p", "hot", "");
+ let computed = compute_style(&[¶graph], &rules, None, 16.0);
+ assert_eq!(computed.get("color"), Some("#ff0000"));
+ assert_eq!(computed.get("margin-top"), Some("4px"));
+ }
+
+ #[test]
+ fn inline_beats_rules_but_important_beats_inline() {
+ let (rules, _) = parse_stylesheet(".a { color: #00ff00 !important }", 100);
+ let div = el("div", "a", "color: #0000ff");
+ let computed = compute_style(&[&div], &rules, None, 16.0);
+ assert_eq!(computed.get("color"), Some("#00ff00"));
+ }
+
+ #[test]
+ fn inheritance_and_font_size_units() {
+ let (rules, _) = parse_stylesheet("div { color: #333333; font-size: 20px }", 100);
+ let parent_element = el("div", "", "");
+ let parent = compute_style(&[&parent_element], &rules, None, 16.0);
+ assert_eq!(parent.font_size, 20.0);
+ let child_element = el("span", "", "font-size: 1.5em");
+ let child = compute_style(
+ &[&parent_element, &child_element],
+ &rules,
+ Some(&parent),
+ 16.0,
+ );
+ assert_eq!(child.get("color"), Some("#333333"));
+ assert_eq!(child.font_size, 30.0);
+ }
+
+ #[test]
+ fn at_rules_skipped_with_warning() {
+ let (rules, warnings) = parse_stylesheet(
+ "@media (max-width:600px){ p{color:red} } p{color:#222222}",
+ 0,
+ );
+ assert_eq!(rules.len(), 1);
+ assert_eq!(warnings.len(), 1);
+ }
+
+ #[test]
+ fn ua_defaults_apply() {
+ let (rules, _) = parse_stylesheet(UA_STYLESHEET, 0);
+ let heading = el("h1", "", "");
+ let computed = compute_style(&[&heading], &rules, None, 16.0);
+ assert_eq!(computed.font_size, 32.0);
+ assert_eq!(computed.get("font-weight"), Some("700"));
+ }
+}
diff --git a/crates/op-html/src/css/declarations.rs b/crates/op-html/src/css/declarations.rs
new file mode 100644
index 000000000..3701dfd72
--- /dev/null
+++ b/crates/op-html/src/css/declarations.rs
@@ -0,0 +1,317 @@
+use crate::color::parse_css_color;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Declaration {
+ pub name: String,
+ pub value: String,
+ pub important: bool,
+}
+
+pub fn parse_declarations(block: &str) -> Vec {
+ let mut declarations = Vec::new();
+ for statement in split_top_level(block, ';') {
+ let Some((name, raw_value)) = statement.split_once(':') else {
+ continue;
+ };
+ let name = name.trim().to_ascii_lowercase();
+ let (value, important) = strip_important(raw_value);
+ if name.is_empty() || value.is_empty() {
+ continue;
+ }
+ match name.as_str() {
+ "margin" | "padding" => expand_box(&mut declarations, &name, value, important),
+ "border" => expand_border(&mut declarations, None, value, important),
+ "border-top" | "border-right" | "border-bottom" | "border-left" => expand_border(
+ &mut declarations,
+ name.strip_prefix("border-"),
+ value,
+ important,
+ ),
+ "background" => expand_background(&mut declarations, value, important),
+ "font" => expand_font(&mut declarations, value, important),
+ "gap" => {
+ if let Some(first) = value.split_whitespace().next() {
+ push(&mut declarations, "gap", first, important);
+ }
+ }
+ "flex" => {
+ if let Some(first) = value.split_whitespace().next() {
+ if first.parse::().is_ok() {
+ push(&mut declarations, "flex-grow", first, important);
+ }
+ }
+ }
+ "text-decoration" => {
+ if value.split_whitespace().any(|part| part == "underline") {
+ push(
+ &mut declarations,
+ "text-decoration-line",
+ "underline",
+ important,
+ );
+ }
+ if value.split_whitespace().any(|part| part == "line-through") {
+ push(
+ &mut declarations,
+ "text-decoration-line",
+ "line-through",
+ important,
+ );
+ }
+ }
+ _ => push(&mut declarations, &name, value, important),
+ }
+ }
+ declarations
+}
+
+fn split_top_level(input: &str, delimiter: char) -> Vec<&str> {
+ let mut parts = Vec::new();
+ let mut start = 0;
+ let mut depth = 0u32;
+ let mut quote = None;
+ for (index, ch) in input.char_indices() {
+ if let Some(active) = quote {
+ if ch == active {
+ quote = None;
+ }
+ continue;
+ }
+ match ch {
+ '\'' | '"' => quote = Some(ch),
+ '(' => depth += 1,
+ ')' => depth = depth.saturating_sub(1),
+ _ if ch == delimiter && depth == 0 => {
+ parts.push(input[start..index].trim());
+ start = index + ch.len_utf8();
+ }
+ _ => {}
+ }
+ }
+ parts.push(input[start..].trim());
+ parts
+}
+
+fn strip_important(value: &str) -> (&str, bool) {
+ let trimmed = value.trim();
+ let lower = trimmed.to_ascii_lowercase();
+ if lower.ends_with("!important") {
+ let cutoff = trimmed.len() - "!important".len();
+ (trimmed[..cutoff].trim_end(), true)
+ } else {
+ (trimmed, false)
+ }
+}
+
+fn push(out: &mut Vec, name: &str, value: &str, important: bool) {
+ out.push(Declaration {
+ name: name.to_string(),
+ value: value.trim().to_string(),
+ important,
+ });
+}
+
+fn expand_box(out: &mut Vec, name: &str, value: &str, important: bool) {
+ let values: Vec<_> = value.split_whitespace().collect();
+ let sides = match values.as_slice() {
+ [all] => [*all, *all, *all, *all],
+ [vertical, horizontal] => [*vertical, *horizontal, *vertical, *horizontal],
+ [top, horizontal, bottom] => [*top, *horizontal, *bottom, *horizontal],
+ [top, right, bottom, left] => [*top, *right, *bottom, *left],
+ _ => return,
+ };
+ for (side, value) in ["top", "right", "bottom", "left"].into_iter().zip(sides) {
+ push(out, &format!("{name}-{side}"), value, important);
+ }
+}
+
+fn expand_border(out: &mut Vec, side: Option<&str>, value: &str, important: bool) {
+ let mut width = None;
+ let mut style = None;
+ let mut color = None;
+ for token in value.split_whitespace() {
+ if parse_css_color(token).is_some() {
+ color = Some(token);
+ } else if is_border_width(token) {
+ width = Some(token);
+ } else if matches!(
+ token,
+ "none"
+ | "hidden"
+ | "dotted"
+ | "dashed"
+ | "solid"
+ | "double"
+ | "groove"
+ | "ridge"
+ | "inset"
+ | "outset"
+ ) {
+ style = Some(token);
+ }
+ }
+ let prefix = side.map_or_else(|| "border".to_string(), |side| format!("border-{side}"));
+ if let Some(width) = width {
+ push(out, &format!("{prefix}-width"), width, important);
+ }
+ if side.is_none() {
+ if let Some(style) = style {
+ push(out, "border-style", style, important);
+ }
+ }
+ if let Some(color) = color {
+ push(out, &format!("{prefix}-color"), color, important);
+ }
+}
+
+fn is_border_width(token: &str) -> bool {
+ matches!(token, "thin" | "medium" | "thick" | "0")
+ || ["px", "em", "rem", "pt"].iter().any(|unit| {
+ token
+ .strip_suffix(unit)
+ .is_some_and(|n| n.parse::().is_ok())
+ })
+}
+
+fn expand_background(out: &mut Vec, value: &str, important: bool) {
+ if value.contains("-gradient(") {
+ push(out, "background-image", value, important);
+ return;
+ }
+ if let Some(image) = extract_function(value, "url(") {
+ push(out, "background-image", image, important);
+ }
+ if parse_css_color(value).is_some() {
+ push(out, "background-color", value, important);
+ return;
+ }
+ if let Some(color) = value
+ .split_whitespace()
+ .find(|token| parse_css_color(token).is_some())
+ {
+ push(out, "background-color", color, important);
+ }
+}
+
+fn extract_function<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
+ let start = value.find(prefix)?;
+ let mut depth = 0u32;
+ for (offset, ch) in value[start..].char_indices() {
+ match ch {
+ '(' => depth += 1,
+ ')' => {
+ depth = depth.saturating_sub(1);
+ if depth == 0 {
+ return Some(&value[start..start + offset + 1]);
+ }
+ }
+ _ => {}
+ }
+ }
+ None
+}
+
+fn expand_font(out: &mut Vec, value: &str, important: bool) {
+ let tokens: Vec<_> = value.split_whitespace().collect();
+ let Some(size_index) = tokens.iter().position(|token| is_font_size_token(token)) else {
+ return;
+ };
+ for token in &tokens[..size_index] {
+ if *token == "italic" || *token == "oblique" {
+ push(out, "font-style", token, important);
+ } else if *token == "bold"
+ || token
+ .parse::()
+ .is_ok_and(|weight| (100..=900).contains(&weight))
+ {
+ push(out, "font-weight", token, important);
+ }
+ }
+ let (size, line_height) = tokens[size_index]
+ .split_once('/')
+ .map_or((tokens[size_index], None), |(size, height)| {
+ (size, Some(height))
+ });
+ push(out, "font-size", size, important);
+ if let Some(line_height) = line_height {
+ push(out, "line-height", line_height, important);
+ }
+ if size_index + 1 < tokens.len() {
+ push(
+ out,
+ "font-family",
+ &tokens[size_index + 1..].join(" "),
+ important,
+ );
+ }
+}
+
+fn is_font_size_token(token: &str) -> bool {
+ let size = token.split('/').next().unwrap_or(token);
+ ["px", "em", "rem", "pt", "%"].iter().any(|unit| {
+ size.strip_suffix(unit)
+ .is_some_and(|n| n.parse::().is_ok())
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn get<'a>(declarations: &'a [Declaration], name: &str) -> Option<&'a str> {
+ declarations
+ .iter()
+ .rev()
+ .find(|declaration| declaration.name == name)
+ .map(|declaration| declaration.value.as_str())
+ }
+
+ #[test]
+ fn margin_shorthand_expands() {
+ let declarations = parse_declarations("margin: 10px 20px");
+ assert_eq!(get(&declarations, "margin-top"), Some("10px"));
+ assert_eq!(get(&declarations, "margin-right"), Some("20px"));
+ assert_eq!(get(&declarations, "margin-bottom"), Some("10px"));
+ assert_eq!(get(&declarations, "margin-left"), Some("20px"));
+ }
+
+ #[test]
+ fn border_and_background() {
+ let declarations =
+ parse_declarations("border: 1px solid #000; background: #fff url(x.png)");
+ assert_eq!(get(&declarations, "border-width"), Some("1px"));
+ assert_eq!(get(&declarations, "border-color"), Some("#000"));
+ assert_eq!(get(&declarations, "background-image"), Some("url(x.png)"));
+ assert_eq!(get(&declarations, "background-color"), Some("#fff"));
+ }
+
+ #[test]
+ fn important_flag_and_case() {
+ let declarations = parse_declarations("COLOR: red !important");
+ assert_eq!(declarations[0].name, "color");
+ assert_eq!(declarations[0].value, "red");
+ assert!(declarations[0].important);
+ }
+
+ #[test]
+ fn gradient_goes_to_background_image() {
+ let declarations = parse_declarations("background: linear-gradient(90deg, #000, #fff)");
+ assert_eq!(
+ get(&declarations, "background-image"),
+ Some("linear-gradient(90deg, #000, #fff)")
+ );
+ assert_eq!(get(&declarations, "background-color"), None);
+ }
+
+ #[test]
+ fn flex_and_font() {
+ let declarations =
+ parse_declarations("flex: 1; font: italic 700 18px/1.4 Inter, sans-serif");
+ assert_eq!(get(&declarations, "flex-grow"), Some("1"));
+ assert_eq!(get(&declarations, "font-style"), Some("italic"));
+ assert_eq!(get(&declarations, "font-weight"), Some("700"));
+ assert_eq!(get(&declarations, "font-size"), Some("18px"));
+ assert_eq!(get(&declarations, "line-height"), Some("1.4"));
+ assert_eq!(get(&declarations, "font-family"), Some("Inter, sans-serif"));
+ }
+}
diff --git a/crates/op-html/src/css/mod.rs b/crates/op-html/src/css/mod.rs
new file mode 100644
index 000000000..c714953fe
--- /dev/null
+++ b/crates/op-html/src/css/mod.rs
@@ -0,0 +1,3 @@
+pub mod cascade;
+pub mod declarations;
+pub mod selectors;
diff --git a/crates/op-html/src/css/selectors.rs b/crates/op-html/src/css/selectors.rs
new file mode 100644
index 000000000..f0c660241
--- /dev/null
+++ b/crates/op-html/src/css/selectors.rs
@@ -0,0 +1,182 @@
+use crate::dom::DomElement;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct CompoundSelector {
+ pub tag: Option,
+ pub id: Option,
+ pub classes: Vec,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Selector {
+ pub compounds: Vec,
+}
+
+pub fn parse_selector_list(input: &str) -> Vec {
+ input
+ .split(',')
+ .filter_map(|candidate| {
+ let candidate = candidate.trim();
+ if candidate.is_empty()
+ || candidate
+ .chars()
+ .any(|ch| matches!(ch, '>' | '+' | '~' | '[' | ':' | '*'))
+ {
+ return None;
+ }
+ let compounds: Option> =
+ candidate.split_whitespace().map(parse_compound).collect();
+ let compounds = compounds?;
+ (!compounds.is_empty()).then_some(Selector { compounds })
+ })
+ .collect()
+}
+
+fn parse_compound(input: &str) -> Option {
+ let bytes = input.as_bytes();
+ let mut index = 0;
+ let mut tag = None;
+ let mut id = None;
+ let mut classes = Vec::new();
+ if !matches!(bytes.first(), Some(b'.' | b'#')) {
+ let end = input.find(['.', '#']).unwrap_or(input.len());
+ if end == 0 {
+ return None;
+ }
+ tag = Some(input[..end].to_ascii_lowercase());
+ index = end;
+ }
+ while index < input.len() {
+ let marker = bytes[index];
+ if marker != b'.' && marker != b'#' {
+ return None;
+ }
+ let start = index + 1;
+ let relative_end = input[start..]
+ .find(['.', '#'])
+ .unwrap_or(input.len() - start);
+ let end = start + relative_end;
+ if start == end {
+ return None;
+ }
+ let token = input[start..end].to_string();
+ if marker == b'#' {
+ if id.replace(token).is_some() {
+ return None;
+ }
+ } else {
+ classes.push(token);
+ }
+ index = end;
+ }
+ Some(CompoundSelector { tag, id, classes })
+}
+
+pub fn specificity(selector: &Selector) -> (u32, u32, u32) {
+ selector
+ .compounds
+ .iter()
+ .fold((0, 0, 0), |(ids, classes, tags), compound| {
+ (
+ ids + u32::from(compound.id.is_some()),
+ classes + compound.classes.len() as u32,
+ tags + u32::from(compound.tag.is_some()),
+ )
+ })
+}
+
+pub fn matches(selector: &Selector, path: &[&DomElement]) -> bool {
+ let Some((target, ancestors)) = path.split_last() else {
+ return false;
+ };
+ let Some((target_selector, earlier_selectors)) = selector.compounds.split_last() else {
+ return false;
+ };
+ if !compound_matches(target_selector, target) {
+ return false;
+ }
+ let mut ancestor_end = ancestors.len();
+ for compound in earlier_selectors.iter().rev() {
+ let Some(index) = ancestors[..ancestor_end]
+ .iter()
+ .rposition(|element| compound_matches(compound, element))
+ else {
+ return false;
+ };
+ ancestor_end = index;
+ }
+ true
+}
+
+fn compound_matches(selector: &CompoundSelector, element: &DomElement) -> bool {
+ if selector
+ .tag
+ .as_deref()
+ .is_some_and(|tag| tag != element.tag)
+ {
+ return false;
+ }
+ if selector
+ .id
+ .as_deref()
+ .is_some_and(|id| Some(id) != element.id())
+ {
+ return false;
+ }
+ let element_classes = element.classes();
+ selector
+ .classes
+ .iter()
+ .all(|class| element_classes.contains(&class.as_str()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::dom::DomElement;
+
+ fn el(tag: &str, class: &str, id: &str) -> DomElement {
+ let mut attrs = Vec::new();
+ if !class.is_empty() {
+ attrs.push(("class".into(), class.into()));
+ }
+ if !id.is_empty() {
+ attrs.push(("id".into(), id.into()));
+ }
+ DomElement {
+ tag: tag.into(),
+ attrs,
+ children: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn parses_and_scores() {
+ let selectors = parse_selector_list("div.card, #hero .title, p");
+ assert_eq!(selectors.len(), 3);
+ assert_eq!(specificity(&selectors[0]), (0, 1, 1));
+ assert_eq!(specificity(&selectors[1]), (1, 1, 0));
+ assert_eq!(specificity(&selectors[2]), (0, 0, 1));
+ }
+
+ #[test]
+ fn unsupported_selectors_are_dropped() {
+ let selectors = parse_selector_list("a:hover, div > p, .ok");
+ assert_eq!(selectors.len(), 1);
+ assert_eq!(selectors[0].compounds[0].classes, vec!["ok".to_string()]);
+ }
+
+ #[test]
+ fn descendant_matching() {
+ let hero = el("section", "", "hero");
+ let mid = el("div", "card", "");
+ let title = el("h2", "title", "");
+ let path: Vec<&DomElement> = vec![&hero, &mid, &title];
+ let selector = &parse_selector_list("#hero .title")[0];
+ assert!(matches(selector, &path));
+ let selector = &parse_selector_list("#hero .card .title")[0];
+ assert!(matches(selector, &path));
+ let selector = &parse_selector_list("#other .title")[0];
+ assert!(!matches(selector, &path));
+ }
+}