Skip to main content

orinium_browser/engine/layouter/
css_resolver.rs

1//! A CSS resolver that handles selector matching and value resolution.
2
3use super::builder::resolve_css_len;
4use crate::engine::animation::types::KeyframeStop as RuntimeKeyframeStop;
5use crate::engine::animation::types::KeyframesRule as RuntimeKeyframesRule;
6use crate::engine::css::parser::{AtQuery, ComplexSelector, CssNode, CssNodeType, RangeOperator};
7use crate::engine::css::values::{CssIdent, CssValue};
8use crate::engine::layouter::types::ColorScheme;
9
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13pub(super) type Properties = HashMap<String, ResolvedDeclaration>;
14
15struct Declaration {
16    name: String,
17    value: CssValue,
18    important: bool,
19}
20
21/// Origin of a declaration in the CSS cascade.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub enum StyleOrigin {
24    /// Browser-provided default styling.
25    UserAgent,
26    /// Styling supplied by the loaded document.
27    Author,
28}
29
30/// The originating pseudo-element of a declaration, if any.
31///
32/// Declarations from a `::before`/`::after` rule block carry the tag here and
33/// their selector is stored *without* the pseudo-element, so normal subject
34/// matching applies to the originating element.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
36pub enum PseudoElement {
37    #[default]
38    None,
39    Before,
40    After,
41}
42
43impl PseudoElement {
44    /// Maps a parsed pseudo-element name (`before`, `after`, …) to its kind.
45    /// Legacy single-colon names are normalized by the parser.
46    pub fn from_name(name: &str) -> Option<Self> {
47        match name.to_ascii_lowercase().as_str() {
48            "before" => Some(Self::Before),
49            "after" => Some(Self::After),
50            _ => None,
51        }
52    }
53}
54
55/// A single CSS declaration after selector resolution and value processing.
56///
57/// `ResolvedDeclaration` represents one property-value pair that has been
58/// fully associated with a selector and enriched with all information
59/// required for CSS cascade resolution.
60///
61/// This structure is produced after:
62/// - Parsing selectors
63/// - Resolving `var()` using custom properties
64/// - Computing selector specificity
65///
66/// During the cascade phase, multiple `ResolvedDeclaration`s with the same
67/// property name may compete. The winner is determined by comparing:
68///
69/// 1. `important` declarations
70/// 2. cascade `origin`
71/// 3. `specificity` (higher specificity wins)
72/// 4. `order` (later declarations win)
73#[derive(Debug, Clone)]
74pub struct ResolvedDeclaration {
75    pub selector: Arc<ComplexSelector>,
76    /// The `::before`/`::after` pseudo-element this declaration styles, if any.
77    pub pseudo: PseudoElement,
78    pub name: String,
79    pub value: CssValue,
80    pub specificity: (u32, u32, u32),
81    pub order: usize,
82    pub important: bool,
83    pub origin: StyleOrigin,
84    /// Nested `@media`/`@supports` conditions which must all match before this
85    /// declaration applies. Shared across declarations of the same rule block.
86    pub media_queries: Arc<Vec<AtQuery>>,
87}
88
89pub type ResolvedStyles = Vec<ResolvedDeclaration>;
90
91/// Registry of `@keyframes` rules keyed by animation name.
92pub type KeyframesRegistry = HashMap<String, RuntimeKeyframesRule>;
93
94/// Result of resolving a stylesheet: cascaded declarations plus keyframes.
95#[derive(Debug, Clone, Default)]
96pub struct StylesheetResolution {
97    pub declarations: ResolvedStyles,
98    pub keyframes: KeyframesRegistry,
99}
100
101impl StylesheetResolution {
102    pub fn append(&mut self, mut other: StylesheetResolution) {
103        append_resolved_styles(
104            &mut self.declarations,
105            std::mem::take(&mut other.declarations),
106        );
107        // Later keyframes with the same name override earlier ones (last wins),
108        // matching common author expectations for MVP.
109        for (name, rule) in other.keyframes {
110            self.keyframes.insert(name, rule);
111        }
112    }
113}
114
115/// Indexed collection of resolved CSS declarations partitioned by selector subject.
116///
117/// Instead of matching every declaration against every DOM element
118/// `O(N_DOM * M_CSS)`, declarations are grouped by *selector* and
119/// indexed by their subject selector (rightmost part):
120/// - ID selector (`#id`)
121/// - Class selectors (`.class`)
122/// - Tag name selector (`div`, `p`, etc.)
123/// - Attribute selectors (`[hidden]`)
124/// - Universal / pseudo-class-only selectors
125///
126/// Declarations produced by one rule block share an identical selector, so
127/// grouped matching evaluates each selector at most once per element instead of
128/// once per declaration.
129#[derive(Debug, Clone, Default)]
130pub struct RuleSet {
131    declarations: Vec<ResolvedDeclaration>,
132    /// Declarations sharing a structurally identical selector, so a selector is
133    /// matched once per element for the whole group.
134    groups: Vec<SelectorGroup>,
135    id_rules: HashMap<String, Vec<usize>>,
136    class_rules: HashMap<String, Vec<usize>>,
137    tag_rules: HashMap<String, Vec<usize>>,
138    attribute_rules: HashMap<String, Vec<usize>>,
139    universal_rules: Vec<usize>,
140}
141
142/// Declarations whose selectors are structurally identical. Matching
143/// `selector` once is sufficient to cascade every declaration in `decls`;
144/// `decls` holds indices into [`RuleSet::declarations`].
145#[derive(Debug, Clone)]
146pub struct SelectorGroup {
147    pub selector: Arc<ComplexSelector>,
148    pub decls: Vec<usize>,
149}
150
151impl RuleSet {
152    /// Builds a `RuleSet` holding a copy of a list of `ResolvedDeclaration`s, pre-filtering by `MediaEnvironment`.
153    pub fn from_declarations(
154        declarations: &[ResolvedDeclaration],
155        media_env: &MediaEnvironment,
156    ) -> Self {
157        // First pass: media-filter and group declarations by selector. All
158        // declarations of one rule block share an identical selector, so each
159        // unique selector is matched once per element for all its declarations.
160        let mut groups = Vec::<SelectorGroup>::new();
161        let mut group_by_selector: HashMap<Arc<ComplexSelector>, usize> = HashMap::new();
162        for (idx, decl) in declarations.iter().enumerate() {
163            // Media query evaluation done ONCE per declaration during RuleSet construction!
164            if !decl.matches_media(media_env) {
165                continue;
166            }
167            match group_by_selector.get(&decl.selector) {
168                Some(&group_idx) => groups[group_idx].decls.push(idx),
169                None => {
170                    let group_idx = groups.len();
171                    groups.push(SelectorGroup {
172                        selector: Arc::clone(&decl.selector),
173                        decls: vec![idx],
174                    });
175                    group_by_selector.insert(Arc::clone(&decl.selector), group_idx);
176                }
177            }
178        }
179
180        let mut id_rules: HashMap<String, Vec<usize>> = HashMap::new();
181        let mut class_rules: HashMap<String, Vec<usize>> = HashMap::new();
182        let mut tag_rules: HashMap<String, Vec<usize>> = HashMap::new();
183        let mut attribute_rules: HashMap<String, Vec<usize>> = HashMap::new();
184        let mut universal_rules = Vec::new();
185
186        for (group_idx, group) in groups.iter().enumerate() {
187            // ComplexSelector parts are stored right-to-left: parts[0] is the subject selector.
188            if let Some(subject_part) = group.selector.parts.first() {
189                let sel = &subject_part.selector;
190                if let Some(id) = &sel.id {
191                    id_rules.entry(id.clone()).or_default().push(group_idx);
192                } else if let Some(first_class) = sel.classes.first() {
193                    // Index by the first class to prevent duplicate indexing
194                    class_rules
195                        .entry(first_class.clone())
196                        .or_default()
197                        .push(group_idx);
198                } else if let Some(tag) = &sel.tag {
199                    tag_rules.entry(tag.clone()).or_default().push(group_idx);
200                } else if let Some(first_attr) = sel.attributes.first() {
201                    attribute_rules
202                        .entry(first_attr.name.clone())
203                        .or_default()
204                        .push(group_idx);
205                } else {
206                    universal_rules.push(group_idx);
207                }
208            } else {
209                universal_rules.push(group_idx);
210            }
211        }
212
213        Self {
214            declarations: declarations.to_vec(),
215            groups,
216            id_rules,
217            class_rules,
218            tag_rules,
219            attribute_rules,
220            universal_rules,
221        }
222    }
223
224    /// Returns an iterator over selector candidates that might match the given
225    /// element, each unique selector at most once per element.
226    pub fn query_candidates(
227        &self,
228        element: &crate::engine::css::matcher::ElementInfo,
229    ) -> impl Iterator<Item = &SelectorGroup> {
230        let universal = self.universal_rules.iter().map(|&g| &self.groups[g]);
231
232        let id = element
233            .id
234            .as_ref()
235            .and_then(|id_str| self.id_rules.get(id_str.as_ref()))
236            .into_iter()
237            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
238
239        let tag = self
240            .tag_rules
241            .get(element.tag_name.as_ref())
242            .into_iter()
243            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
244
245        let classes = element
246            .classes
247            .iter()
248            .filter_map(|class_str| self.class_rules.get(class_str.as_ref()))
249            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
250
251        let attributes = element
252            .attributes
253            .iter()
254            .filter_map(|(name, _)| self.attribute_rules.get(name.as_ref()))
255            .flat_map(|indices| indices.iter().map(|&g| &self.groups[g]));
256
257        universal
258            .chain(id)
259            .chain(tag)
260            .chain(classes)
261            .chain(attributes)
262    }
263
264    pub fn declarations(&self) -> &[ResolvedDeclaration] {
265        &self.declarations
266    }
267}
268
269impl ResolvedDeclaration {
270    /// Returns whether this declaration wins over another matching declaration.
271    pub fn outranks(&self, other: &Self) -> bool {
272        (self.important, self.origin, self.specificity, self.order)
273            > (
274                other.important,
275                other.origin,
276                other.specificity,
277                other.order,
278            )
279    }
280
281    /// Returns whether every enclosing `@media` rule matches `environment`.
282    pub fn matches_media(&self, environment: &MediaEnvironment) -> bool {
283        self.media_queries
284            .iter()
285            .all(|query| MediaEvaluator::evaluate(query, environment))
286    }
287}
288
289/// Values used to evaluate media queries for the current page.
290#[derive(Debug, Clone, Copy, PartialEq)]
291pub struct MediaEnvironment {
292    /// Width of the page viewport in CSS pixels.
293    pub viewport_width: f32,
294    /// Height of the page viewport in CSS pixels.
295    pub viewport_height: f32,
296    /// Operating-system color preference used by `prefers-color-scheme`.
297    pub color_scheme: ColorScheme,
298}
299
300impl MediaEnvironment {
301    pub fn new(viewport: (f32, f32), color_scheme: ColorScheme) -> Self {
302        Self {
303            viewport_width: viewport.0,
304            viewport_height: viewport.1,
305            color_scheme,
306        }
307    }
308
309    /// The viewport this environment was built for, as `(width, height)`.
310    pub fn viewport(&self) -> (f32, f32) {
311        (self.viewport_width, self.viewport_height)
312    }
313}
314
315/// Evaluates a parsed media query (as produced by
316/// [`crate::engine::css::parser::Parser::parse_media_query`]) against the
317/// current page environment, mirroring how `@media` rules are filtered.
318pub fn evaluate_media_query(query: &AtQuery, environment: &MediaEnvironment) -> bool {
319    MediaEvaluator::evaluate(query, environment)
320}
321
322/// Keeps only declarations whose enclosing media queries currently match.
323pub fn filter_media<'a>(
324    styles: &'a ResolvedStyles,
325    environment: &'a MediaEnvironment,
326) -> impl Iterator<Item = &'a ResolvedDeclaration> {
327    styles
328        .iter()
329        .filter(move |declaration| declaration.matches_media(environment))
330}
331
332/// Appends resolved declarations while preserving source order across stylesheets.
333pub fn append_resolved_styles(target: &mut ResolvedStyles, mut incoming: ResolvedStyles) {
334    let next_order = target
335        .iter()
336        .map(|declaration| declaration.order)
337        .max()
338        .map_or(0, |order| order + 1);
339    for declaration in &mut incoming {
340        declaration.order += next_order;
341    }
342    target.extend(incoming);
343}
344
345/// Resolves a `style` attribute's declarations into (name, value, important)
346/// triples, applying `var()` and `!important` handling like a rule block.
347///
348/// Inline styles participate in the cascade as author-origin declarations with
349/// the highest specificity, so callers should apply them after stylesheet
350/// declarations for the same element.
351pub fn resolve_inline_style(style_attr: &str) -> Vec<(String, CssValue, bool)> {
352    let mut parser = crate::engine::css::parser::Parser::new(style_attr);
353    let Ok(nodes) = parser.parse_declarations() else {
354        return Vec::new();
355    };
356
357    let declarations = DeclarationResolver::collect(&nodes);
358    let mut custom_properties = Properties::new();
359    for declaration in &declarations {
360        if declaration.name.starts_with("--") {
361            set_inline_custom_property(
362                &mut custom_properties,
363                declaration.name.clone(),
364                declaration.value.clone(),
365                declaration.important,
366            );
367        }
368    }
369
370    declarations
371        .into_iter()
372        .map(|declaration| {
373            let value = if declaration.name.starts_with("--") {
374                declaration.value
375            } else {
376                DeclarationResolver::resolve_var(
377                    &declaration.value,
378                    &custom_properties,
379                    &mut HashSet::new(),
380                )
381                .unwrap_or(declaration.value)
382            };
383            (declaration.name, value, declaration.important)
384        })
385        .collect()
386}
387
388pub fn resolve_inline_value(value: &str) -> Option<CssValue> {
389    let mut tokenizer = crate::engine::css::tokenizer::Tokenizer::new(value);
390    let mut tokens = Vec::new();
391
392    loop {
393        let token = tokenizer.next_token();
394        if token == crate::engine::css::tokenizer::Token::EOF {
395            break;
396        }
397
398        tokens.push(token);
399    }
400
401    let Ok(value) = crate::engine::css::parser::Parser::parse_tokens_to_css_value(tokens) else {
402        return None;
403    };
404
405    Some(value)
406}
407
408/// Adds an inline custom property to an element's inherited property map.
409/// Inline author declarations outrank stylesheet declarations unless the
410/// stylesheet winner is `!important` and the inline declaration is not.
411pub(super) fn set_inline_custom_property(
412    properties: &mut Properties,
413    name: String,
414    value: CssValue,
415    important: bool,
416) {
417    if properties
418        .get(&name)
419        .is_some_and(|current| current.important && !important)
420    {
421        return;
422    }
423
424    properties.insert(
425        name.clone(),
426        ResolvedDeclaration {
427            selector: Arc::new(ComplexSelector { parts: Vec::new() }),
428            pseudo: PseudoElement::None,
429            name,
430            value,
431            specificity: (u32::MAX, u32::MAX, u32::MAX),
432            order: usize::MAX,
433            important,
434            origin: StyleOrigin::Author,
435            media_queries: Arc::new(Vec::new()),
436        },
437    );
438}
439
440// ============================================================
441//  CssResolver — tree walk + rule resolution
442// ============================================================
443
444pub struct CssResolver;
445
446impl CssResolver {
447    /// Resolves an author stylesheet into declarations used by layout.
448    pub fn resolve(stylesheet: &CssNode) -> ResolvedStyles {
449        Self::resolve_stylesheet(stylesheet).declarations
450    }
451
452    /// Resolves a stylesheet using the supplied cascade origin.
453    pub fn resolve_with_origin(stylesheet: &CssNode, origin: StyleOrigin) -> ResolvedStyles {
454        Self::resolve_stylesheet_with_origin(stylesheet, origin).declarations
455    }
456
457    /// Resolves an author stylesheet into declarations and keyframes.
458    pub fn resolve_stylesheet(stylesheet: &CssNode) -> StylesheetResolution {
459        Self::resolve_stylesheet_with_origin(stylesheet, StyleOrigin::Author)
460    }
461
462    /// Resolves a stylesheet using the supplied cascade origin.
463    pub fn resolve_stylesheet_with_origin(
464        stylesheet: &CssNode,
465        origin: StyleOrigin,
466    ) -> StylesheetResolution {
467        let mut styles = Vec::new();
468        let mut keyframes = KeyframesRegistry::new();
469        let mut order = 0;
470        Self::walk(
471            stylesheet,
472            &mut styles,
473            &mut keyframes,
474            &mut order,
475            origin,
476            &mut Vec::new(),
477            &[],
478        );
479        StylesheetResolution {
480            declarations: styles,
481            keyframes,
482        }
483    }
484
485    /// Parses declarations from an HTML `style` attribute.
486    ///
487    /// Inline declarations use author origin but outrank selector-based author
488    /// rules of the same importance. Author `!important` declarations still
489    /// outrank normal inline declarations, as required by the cascade.
490    pub fn resolve_inline_style(style: &str) -> ResolvedStyles {
491        let source = format!("* {{ {style} }}");
492        let Ok(stylesheet) = crate::engine::css::parser::Parser::new(&source).parse() else {
493            return Vec::new();
494        };
495        let mut declarations = Self::resolve_with_origin(&stylesheet, StyleOrigin::Author);
496        for declaration in &mut declarations {
497            declaration.specificity = (u32::MAX, u32::MAX, u32::MAX);
498            declaration.order = usize::MAX;
499        }
500        declarations
501    }
502
503    fn walk(
504        node: &CssNode,
505        styles: &mut ResolvedStyles,
506        keyframes: &mut KeyframesRegistry,
507        order: &mut usize,
508        origin: StyleOrigin,
509        media_queries: &mut Vec<AtQuery>,
510        parent_selectors: &[ComplexSelector],
511    ) {
512        match node.node() {
513            CssNodeType::Keyframes { name, stops } => {
514                if !name.is_empty() {
515                    let runtime_stops = Self::convert_keyframe_stops(stops);
516                    keyframes.insert(
517                        name.clone(),
518                        RuntimeKeyframesRule {
519                            name: name.clone(),
520                            stops: runtime_stops,
521                        },
522                    );
523                }
524                return;
525            }
526            CssNodeType::AtRule { name, params } => {
527                if name.eq_ignore_ascii_case("supports") && !SupportsEvaluator::evaluate(params) {
528                    return;
529                }
530
531                let is_media = name.eq_ignore_ascii_case("media");
532                if is_media {
533                    media_queries.push(params.clone());
534                }
535                for child in node.children() {
536                    Self::walk(
537                        child,
538                        styles,
539                        keyframes,
540                        order,
541                        origin,
542                        media_queries,
543                        parent_selectors,
544                    );
545                }
546                if is_media {
547                    media_queries.pop();
548                }
549                return;
550            }
551            _ => {}
552        }
553
554        let resolved_selectors =
555            Self::resolve_rule(node, styles, order, origin, media_queries, parent_selectors);
556
557        for child in node.children() {
558            Self::walk(
559                child,
560                styles,
561                keyframes,
562                order,
563                origin,
564                media_queries,
565                &resolved_selectors,
566            );
567        }
568    }
569
570    fn convert_keyframe_stops(
571        stops: &[crate::engine::css::parser::KeyframeStop],
572    ) -> Vec<RuntimeKeyframeStop> {
573        let mut out = Vec::new();
574        for stop in stops {
575            let declarations: Vec<(String, CssValue)> = stop
576                .declarations
577                .iter()
578                .filter(|(_, _, important)| !*important)
579                .map(|(name, value, _)| (name.clone(), value.clone()))
580                .collect();
581            for selector in &stop.selectors {
582                if let Some(offset) = Self::keyframe_selector_offset(&selector.0) {
583                    out.push(RuntimeKeyframeStop {
584                        offset,
585                        declarations: declarations.clone(),
586                    });
587                }
588            }
589        }
590        out.sort_by(|a, b| {
591            a.offset
592                .partial_cmp(&b.offset)
593                .unwrap_or(std::cmp::Ordering::Equal)
594        });
595        out
596    }
597
598    fn keyframe_selector_offset(value: &CssValue) -> Option<f32> {
599        match value {
600            CssValue::Keyword(k) if k.eq_ignore_ascii_case("from") => Some(0.0),
601            CssValue::Keyword(k) if k.eq_ignore_ascii_case("to") => Some(1.0),
602            CssValue::Length(v, crate::engine::css::values::Unit::Percent) => {
603                Some((*v / 100.0).clamp(0.0, 1.0))
604            }
605            CssValue::Number(v) => {
606                // tolerate bare 0/1
607                if *v <= 1.0 {
608                    Some(v.clamp(0.0, 1.0))
609                } else {
610                    Some((v / 100.0).clamp(0.0, 1.0))
611                }
612            }
613            _ => None,
614        }
615    }
616
617    fn resolve_rule(
618        node: &CssNode,
619        styles: &mut ResolvedStyles,
620        order: &mut usize,
621        origin: StyleOrigin,
622        media_queries: &[AtQuery],
623        parent_selectors: &[ComplexSelector],
624    ) -> Vec<ComplexSelector> {
625        let CssNodeType::Rule { selectors } = node.node() else {
626            return vec![];
627        };
628
629        let resolved_selectors: Vec<ComplexSelector> = selectors
630            .iter()
631            .flat_map(|child| {
632                if parent_selectors.is_empty() {
633                    vec![child.clone()]
634                } else {
635                    parent_selectors
636                        .iter()
637                        .map(|parent| parent.nest(child))
638                        .collect::<Vec<_>>()
639                }
640            })
641            .collect();
642
643        let declarations = DeclarationResolver::collect(node.children());
644
645        // `::before`/`::after` declarations are kept with the pseudo-element
646        // stripped from the selector and tagged on the declaration, so the
647        // builder can synthesize the generated box with normal subject
648        // matching against the originating element.
649        for selector in resolved_selectors.iter() {
650            let subject_pseudo = selector
651                .parts
652                .first()
653                .and_then(|part| part.selector.pseudo_element.as_deref())
654                .and_then(PseudoElement::from_name)
655                // Only the subject selector's pseudo-element matters; a
656                // pseudo-element on an ancestor part would make the rule match
657                // nothing real, so treat it as unsupported and skip it.
658                .filter(|_| {
659                    selector.parts[1..]
660                        .iter()
661                        .all(|part| part.selector.pseudo_element.is_none())
662                });
663            let selector_for_push = if subject_pseudo.is_some() {
664                let mut selector = selector.clone();
665                for part in &mut selector.parts {
666                    part.selector.pseudo_element = None;
667                }
668                selector
669            } else {
670                selector.clone()
671            };
672            Self::push_resolved(
673                &selector_for_push,
674                subject_pseudo.unwrap_or(PseudoElement::None),
675                &declarations,
676                styles,
677                order,
678                origin,
679                media_queries,
680            );
681        }
682
683        resolved_selectors
684    }
685
686    fn push_resolved(
687        selector: &ComplexSelector,
688        pseudo: PseudoElement,
689        declarations: &[Declaration],
690        styles: &mut ResolvedStyles,
691        order: &mut usize,
692        origin: StyleOrigin,
693        media_queries: &[AtQuery],
694    ) {
695        let specificity = selector.specificity();
696        let selector = Arc::new(selector.clone());
697        let media_queries = Arc::new(media_queries.to_vec());
698
699        for decl in declarations {
700            styles.push(ResolvedDeclaration {
701                selector: Arc::clone(&selector),
702                pseudo,
703                name: decl.name.clone(),
704                value: decl.value.clone(),
705                specificity,
706                order: *order,
707                important: decl.important,
708                origin,
709                media_queries: Arc::clone(&media_queries),
710            });
711            *order += 1;
712        }
713    }
714}
715
716struct MediaEvaluator;
717
718impl MediaEvaluator {
719    fn evaluate(query: &AtQuery, environment: &MediaEnvironment) -> bool {
720        match query {
721            AtQuery::Group(items) => Self::evaluate_group(items, environment),
722            item => Self::evaluate_clause(std::slice::from_ref(item), environment),
723        }
724    }
725
726    fn evaluate_clause(items: &[AtQuery], environment: &MediaEnvironment) -> bool {
727        if items.is_empty() {
728            return false;
729        }
730        let negate = matches!(items.first(), Some(AtQuery::Keyword(keyword)) if keyword.eq_ignore_ascii_case("not"));
731        let mut saw_operand = false;
732        let matches = items
733            .iter()
734            .skip(usize::from(negate))
735            .all(|item| match item {
736                AtQuery::Keyword(keyword)
737                    if keyword.eq_ignore_ascii_case("and")
738                        || keyword.eq_ignore_ascii_case("only") =>
739                {
740                    true
741                }
742                AtQuery::Keyword(keyword)
743                    if keyword.eq_ignore_ascii_case("all")
744                        || keyword.eq_ignore_ascii_case("screen") =>
745                {
746                    saw_operand = true;
747                    true
748                }
749                AtQuery::Keyword(keyword) if keyword.eq_ignore_ascii_case("print") => {
750                    saw_operand = true;
751                    false
752                }
753                AtQuery::Keyword(_) => {
754                    saw_operand = true;
755                    false
756                }
757                AtQuery::Condition { name, value } => {
758                    saw_operand = true;
759                    Self::evaluate_condition(name, value, environment)
760                }
761                AtQuery::Range { left, name, right } => {
762                    saw_operand = true;
763                    Self::evaluate_range(left.as_ref(), name, right.as_ref(), environment)
764                }
765                AtQuery::Group(group) => {
766                    saw_operand = true;
767                    Self::evaluate_group(group, environment)
768                }
769            });
770        if !saw_operand {
771            return false;
772        }
773        if negate { !matches } else { matches }
774    }
775
776    fn evaluate_group(items: &[AtQuery], environment: &MediaEnvironment) -> bool {
777        items
778            .split(|item| matches!(item, AtQuery::Keyword(keyword) if keyword == ","))
779            .any(|clause| Self::evaluate_clause(clause, environment))
780    }
781
782    fn evaluate_range(
783        left: Option<&(CssValue, RangeOperator)>,
784        name: &str,
785        right: Option<&(RangeOperator, CssValue)>,
786        environment: &MediaEnvironment,
787    ) -> bool {
788        let name = name.to_ascii_lowercase();
789
790        let actual = match name.as_str() {
791            "width" => environment.viewport_width,
792            "height" => environment.viewport_height,
793            _ => return false,
794        };
795
796        if let Some((value, operator)) = left {
797            let Some(expected) = Self::length_px(value, environment) else {
798                return false;
799            };
800
801            if !Self::compare_range(expected, *operator, actual) {
802                return false;
803            }
804        }
805
806        if let Some((operator, value)) = right {
807            let Some(expected) = Self::length_px(value, environment) else {
808                return false;
809            };
810
811            if !Self::compare_range(actual, *operator, expected) {
812                return false;
813            }
814        }
815
816        true
817    }
818
819    fn compare_range(actual: f32, operator: RangeOperator, expected: f32) -> bool {
820        match operator {
821            RangeOperator::Less => actual < expected,
822            RangeOperator::LessEqual => actual <= expected,
823            RangeOperator::Equal => (actual - expected).abs() <= f32::EPSILON,
824            RangeOperator::GreaterEqual => actual >= expected,
825            RangeOperator::Greater => actual > expected,
826        }
827    }
828
829    fn evaluate_condition(name: &str, value: &CssValue, environment: &MediaEnvironment) -> bool {
830        let name = name.to_ascii_lowercase();
831        match name.as_str() {
832            "width" | "min-width" | "max-width" => {
833                Self::compare_length(&name, value, environment.viewport_width, environment)
834            }
835            "height" | "min-height" | "max-height" => {
836                Self::compare_length(&name, value, environment.viewport_height, environment)
837            }
838            "orientation" => match value {
839                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("portrait") => {
840                    environment.viewport_height >= environment.viewport_width
841                }
842                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("landscape") => {
843                    environment.viewport_width > environment.viewport_height
844                }
845                _ => false,
846            },
847            "prefers-color-scheme" => match value {
848                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("dark") => {
849                    environment.color_scheme == ColorScheme::Dark
850                }
851                CssValue::Keyword(keyword) if keyword.eq_ignore_ascii_case("light") => {
852                    environment.color_scheme == ColorScheme::Light
853                }
854                _ => false,
855            },
856            _ => false,
857        }
858    }
859
860    fn compare_length(
861        name: &str,
862        value: &CssValue,
863        actual: f32,
864        environment: &MediaEnvironment,
865    ) -> bool {
866        let Some(expected) = Self::length_px(value, environment) else {
867            return false;
868        };
869        if name.starts_with("min-") {
870            actual >= expected
871        } else if name.starts_with("max-") {
872            actual <= expected
873        } else {
874            (actual - expected).abs() <= f32::EPSILON
875        }
876    }
877
878    fn length_px(value: &CssValue, environment: &MediaEnvironment) -> Option<f32> {
879        let text_flow = crate::engine::layouter::types::TextFlowStyle::default();
880        let len = resolve_css_len("media", std::slice::from_ref(value), &text_flow)?;
881        len.resolve_with(
882            None,
883            environment.viewport_width,
884            environment.viewport_height,
885        )
886    }
887}
888
889// ============================================================
890//  SupportsEvaluator — `@supports` condition evaluation
891// ============================================================
892
893struct SupportsEvaluator;
894
895impl SupportsEvaluator {
896    /// Dispatch on the `AtQuery` AST variant.
897    fn evaluate(query: &AtQuery) -> bool {
898        match query {
899            // `@supports (display: grid)` — a parenthesised group
900            AtQuery::Group(items) => Self::evaluate_group(items),
901            // `@supports (display: grid)` — the inner condition after unwrapping
902            AtQuery::Condition { name, value } => Self::is_supported(name, value),
903            // Media query range syntax is not a @supports condition.
904            AtQuery::Range { .. } => false,
905            // Stray keyword outside a group (malformed input)
906            AtQuery::Keyword(_) => false,
907        }
908    }
909
910    /// Evaluate a group of `@supports` items.
911    ///
912    /// The parser produces flat groups like:
913    /// - `(display: grid)` → `[Group([Condition])]`
914    /// - `(A) and (B)` → `[Group, Keyword("and"), Group]`
915    /// - `not (A)` → `[Keyword("not"), Group]`
916    /// - `(A) or (B)` → `[Group, Keyword("or"), Group]`
917    fn evaluate_group(items: &[AtQuery]) -> bool {
918        if items.is_empty() {
919            return false;
920        }
921
922        // `not (display: grid)` — negate
923        if matches!(items.first(), Some(AtQuery::Keyword(k)) if k.eq_ignore_ascii_case("not")) {
924            return items.len() > 1 && !Self::evaluate(&AtQuery::Group(items[1..].to_vec()));
925        }
926
927        // `(display: flex) and (gap: 10px)` — all operands must be supported
928        if let Some(operands) = Self::split_by_keyword(items, "and") {
929            operands.iter().all(|g| Self::evaluate(g))
930        // `(display: flex) or (display: grid)` — at least one must be supported
931        } else if let Some(operands) = Self::split_by_keyword(items, "or") {
932            operands.iter().any(|g| Self::evaluate(g))
933        // Single group — unwrap one level
934        } else if items.len() == 1 {
935            Self::evaluate(&items[0])
936        } else {
937            items.iter().all(Self::evaluate)
938        }
939    }
940
941    fn is_supported(name: &str, value: &CssValue) -> bool {
942        super::builder::apply_declaration(
943            name,
944            value,
945            &mut ui_layout::Style::default(),
946            &mut super::types::ContainerStyle::default(),
947            &mut super::types::TextStyle::default(),
948            &mut super::types::TextFlowStyle::default(),
949            &ui_layout::Style::default(),
950            &super::types::ContainerStyle::default(),
951            &super::types::TextStyle::default(),
952            &super::types::TextFlowStyle::default(),
953            &mut super::types::Overflow::default(),
954            super::types::ColorScheme::Light,
955        )
956        .is_some()
957    }
958
959    fn split_by_keyword<'a>(items: &'a [AtQuery], keyword: &str) -> Option<Vec<&'a AtQuery>> {
960        let has = items
961            .iter()
962            .any(|item| matches!(item, AtQuery::Keyword(k) if k.eq_ignore_ascii_case(keyword)));
963        if !has {
964            return None;
965        }
966        Some(
967            items
968                .iter()
969                .filter(
970                    |item| !matches!(item, AtQuery::Keyword(k) if k.eq_ignore_ascii_case(keyword)),
971                )
972                .collect(),
973        )
974    }
975}
976
977// ============================================================
978//  DeclarationResolver — `!important` extraction, `var()` resolution
979// ============================================================
980
981pub(super) struct DeclarationResolver;
982
983impl DeclarationResolver {
984    fn collect(children: &[CssNode]) -> Vec<Declaration> {
985        let mut result = Vec::new();
986
987        for child in children {
988            let CssNodeType::Declaration { name, value } = &child.node() else {
989                continue;
990            };
991
992            let (value, important) = Self::extract_important(value);
993
994            result.push(Declaration {
995                name: name.clone(),
996                value,
997                important,
998            });
999        }
1000
1001        result
1002    }
1003
1004    /// Extract `!important` from a CSS value.
1005    ///
1006    /// `border: 1px solid black !important` is parsed as a `List` where the
1007    /// last two items are `Keyword("!")` and `Keyword("important")`.
1008    fn extract_important(value: &CssValue) -> (CssValue, bool) {
1009        match value {
1010            CssValue::List(list) if list.len() >= 2 => {
1011                let len = list.len();
1012                let is_important = matches!(
1013                    (&list[len - 2], &list[len - 1]),
1014                    (
1015                        CssValue::Keyword(bang),
1016                        CssValue::Keyword(ident)
1017                    )
1018                    if bang == "!" && ident.eq_ignore_ascii_case("important")
1019                );
1020
1021                if is_important {
1022                    let value = if len - 2 == 1 {
1023                        list.iter().next().unwrap().clone()
1024                    } else {
1025                        CssValue::List(list[..len - 2].to_vec())
1026                    };
1027                    (value, true)
1028                } else {
1029                    (value.clone(), false)
1030                }
1031            }
1032            _ => (value.clone(), false),
1033        }
1034    }
1035
1036    pub fn resolve_var(
1037        value: &CssValue,
1038        custom_props: &Properties,
1039        visited: &mut HashSet<CssIdent>,
1040    ) -> Option<CssValue> {
1041        match value {
1042            // `var(--accent)` / `var(--missing, red)` — resolve the custom property
1043            CssValue::Function(name, args) if name == "var" => {
1044                let var_name = match args.first().and_then(|argument| argument.first()) {
1045                    Some(CssValue::Keyword(name)) => name,
1046                    _ => return None,
1047                };
1048
1049                if !visited.insert(var_name.clone()) {
1050                    return None;
1051                }
1052
1053                let result = if let Some(v) = custom_props.get(var_name.as_str()) {
1054                    Self::resolve_var(&v.value, custom_props, visited)
1055                } else if let Some(fallback) = args.get(1).and_then(|argument| argument.first()) {
1056                    Self::resolve_var(fallback, custom_props, visited)
1057                } else {
1058                    None
1059                };
1060
1061                visited.remove(var_name);
1062                result
1063            }
1064
1065            // `env(safe-area-inset-top, 0px)` — env() substitution
1066            // (css-env-1 §3). UA-defined environment variables are not
1067            // implemented yet, so every name is unrecognized: substitute
1068            // the fallback (with any nested env() substituted as well),
1069            // and without one the declaration is invalid at
1070            // computed-value time.
1071            CssValue::Function(name, args) if name == "env" => {
1072                match args.first().and_then(|argument| argument.first()) {
1073                    Some(CssValue::Keyword(_)) => {}
1074                    _ => return None,
1075                }
1076
1077                // The fallback is everything after the first comma —
1078                // a `<declaration-value>` that may span groups and hold
1079                // commas itself (`env(x, red, blue)`).
1080                let fallback: Vec<CssValue> = args[1..]
1081                    .iter()
1082                    .flatten()
1083                    .map(|v| Self::resolve_var(v, custom_props, &mut visited.clone()))
1084                    .collect::<Option<_>>()?;
1085                match fallback.len() {
1086                    0 => None,
1087                    1 => Some(fallback.into_iter().next().unwrap()),
1088                    _ => Some(CssValue::List(fallback)),
1089                }
1090            }
1091
1092            // `rgb(var(--r), var(--g), var(--b))` — resolve args independently
1093            CssValue::Function(name, args) => {
1094                let resolved_args = args
1095                    .iter()
1096                    .map(|argument| {
1097                        argument
1098                            .iter()
1099                            .map(|v| Self::resolve_var(v, custom_props, &mut visited.clone()))
1100                            .collect::<Option<Vec<_>>>()
1101                    })
1102                    .collect::<Option<Vec<_>>>()?;
1103                Some(CssValue::Function(name.clone(), resolved_args))
1104            }
1105
1106            // `1px solid var(--color)` — resolve each item independently
1107            CssValue::List(list) => {
1108                let resolved = list
1109                    .iter()
1110                    .map(|v| Self::resolve_var(v, custom_props, &mut visited.clone()))
1111                    .collect::<Option<Vec<_>>>()?;
1112                Some(CssValue::List(resolved))
1113            }
1114
1115            // `10px` / `"hello"` / `#fff` — already concrete, pass through
1116            _ => Some(value.clone()),
1117        }
1118    }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124    use crate::engine::css::parser::Parser;
1125    use crate::engine::css::values::Unit;
1126
1127    fn resolve(css: &str, origin: StyleOrigin) -> ResolvedStyles {
1128        let stylesheet = Parser::new(css).parse().unwrap();
1129        CssResolver::resolve_with_origin(&stylesheet, origin)
1130    }
1131
1132    #[test]
1133    fn author_declaration_outranks_more_specific_user_agent_declaration() {
1134        let user_agent = resolve(
1135            r#"input[type="text"] { display: inline-block; }"#,
1136            StyleOrigin::UserAgent,
1137        );
1138        let author = resolve("input { display: block; }", StyleOrigin::Author);
1139
1140        assert!(author[0].outranks(&user_agent[0]));
1141    }
1142
1143    #[test]
1144    fn append_rebases_order_across_stylesheets() {
1145        let mut styles = resolve("input { display: inline; }", StyleOrigin::Author);
1146        let later = resolve("input { display: block; }", StyleOrigin::Author);
1147        append_resolved_styles(&mut styles, later);
1148
1149        assert!(styles[1].order > styles[0].order);
1150        assert!(styles[1].outranks(&styles[0]));
1151    }
1152
1153    #[test]
1154    fn resolve_inline_style_extracts_declarations_and_important() {
1155        let decls = resolve_inline_style("color: red; margin: 4px 8px !important; width: 10px");
1156        assert_eq!(decls.len(), 3);
1157
1158        assert_eq!(decls[0].0, "color");
1159        assert!(!decls[0].2);
1160
1161        assert_eq!(decls[1].0, "margin");
1162        assert!(decls[1].2);
1163
1164        assert_eq!(decls[2].0, "width");
1165    }
1166
1167    #[test]
1168    fn resolve_inline_style_tolerates_empty_and_malformed_input() {
1169        assert!(resolve_inline_style("").is_empty());
1170        assert!(resolve_inline_style(";;;").is_empty());
1171    }
1172
1173    #[test]
1174    fn resolve_inline_style_resolves_var() {
1175        let decls = resolve_inline_style("--accent: blue; color: var(--accent)");
1176        assert_eq!(decls.len(), 2);
1177
1178        let (_, color_value, _) = decls.iter().find(|(n, _, _)| n == "color").unwrap();
1179        assert_eq!(color_value, &CssValue::Keyword("blue".into()));
1180    }
1181
1182    #[test]
1183    fn media_width_conditions_follow_viewport() {
1184        let styles = resolve(
1185            "@media screen and (max-width: 600px) { div { color: red; } }",
1186            StyleOrigin::Author,
1187        );
1188        let narrow = MediaEnvironment::new((600.0, 800.0), ColorScheme::Light);
1189        let wide = MediaEnvironment::new((601.0, 800.0), ColorScheme::Light);
1190
1191        assert_eq!(filter_media(&styles, &narrow).count(), 1);
1192        assert_eq!(filter_media(&styles, &wide).count(), 0);
1193    }
1194
1195    #[test]
1196    fn scratch_style_adjacent_media_conditions_follow_desktop_viewport() {
1197        let stylesheet = Parser::new(
1198            "@media only screen and (max-width : 479px){#view{text-align:center}.inner{margin:0 auto;width:100%}}@media only screen and (min-width : 480px)and (max-width : 767px){#view{text-align:center}.inner{margin:0 auto;width:480px}}@media only screen and (min-width : 768px)and (max-width : 941px){#view{text-align:center}.inner{margin:0 auto;width:768px}}@media only screen and (min-width : 942px){.inner{margin:0 auto;width:942px}}html,body{display:block}",
1199        )
1200        .parse_lossy();
1201        let styles = CssResolver::resolve_with_origin(&stylesheet, StyleOrigin::Author);
1202        let desktop = MediaEnvironment::new((1280.0, 800.0), ColorScheme::Light);
1203        let declarations = filter_media(&styles, &desktop)
1204            .filter(|declaration| declaration.name == "width")
1205            .collect::<Vec<_>>();
1206
1207        assert_eq!(declarations.len(), 1);
1208        assert_eq!(declarations[0].name, "width");
1209        assert_eq!(declarations[0].value, CssValue::Length(942.0, Unit::Px));
1210    }
1211
1212    #[test]
1213    fn media_query_lists_use_or_semantics() {
1214        let styles = resolve(
1215            "@media print, (orientation: landscape) { div { color: red; } }",
1216            StyleOrigin::Author,
1217        );
1218        let landscape = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
1219        let portrait = MediaEnvironment::new((600.0, 800.0), ColorScheme::Light);
1220
1221        assert_eq!(filter_media(&styles, &landscape).count(), 1);
1222        assert_eq!(filter_media(&styles, &portrait).count(), 0);
1223    }
1224
1225    #[test]
1226    fn media_color_scheme_matches_system_preference() {
1227        let styles = resolve(
1228            "@media (prefers-color-scheme: dark) { div { color: white; } }",
1229            StyleOrigin::Author,
1230        );
1231        let light = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
1232        let dark = MediaEnvironment::new((800.0, 600.0), ColorScheme::Dark);
1233
1234        assert_eq!(filter_media(&styles, &light).count(), 0);
1235        assert_eq!(filter_media(&styles, &dark).count(), 1);
1236    }
1237
1238    #[test]
1239    fn empty_media_query_does_not_match() {
1240        let styles = resolve("@media { div { color: red; } }", StyleOrigin::Author);
1241        let environment = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
1242
1243        assert_eq!(filter_media(&styles, &environment).count(), 0);
1244    }
1245
1246    #[test]
1247    fn test_rule_set_partitioning_and_querying() {
1248        let styles = resolve(
1249            r#"
1250            * { margin: 0; }
1251            #header { color: red; }
1252            .btn { display: inline-block; }
1253            span { font-size: 12px; }
1254            div.container { padding: 10px; }
1255            "#,
1256            StyleOrigin::Author,
1257        );
1258        let env = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
1259        let rule_set = RuleSet::from_declarations(&styles, &env);
1260        let decls_of = |group: &SelectorGroup| -> Vec<&ResolvedDeclaration> {
1261            group
1262                .decls
1263                .iter()
1264                .map(|&idx| &rule_set.declarations()[idx])
1265                .collect()
1266        };
1267
1268        // Test element 1: <div id="header" class="btn">
1269        let el1 = crate::engine::css::matcher::ElementInfo {
1270            tag_name: "div".into(),
1271            id: Some("header".into()),
1272            classes: vec!["btn".into()].into(),
1273            ..Default::default()
1274        };
1275        let candidates1: Vec<_> = rule_set.query_candidates(&el1).flat_map(decls_of).collect();
1276        // Should match universal (*), id (#header), and class (.btn)
1277        assert!(candidates1.iter().any(|d| d.name == "margin"));
1278        assert!(candidates1.iter().any(|d| d.name == "color"));
1279        assert!(candidates1.iter().any(|d| d.name == "display"));
1280        // Should NOT include span rule
1281        assert!(!candidates1.iter().any(|d| d.name == "font-size"));
1282
1283        // Test element 2: <span class="other">
1284        let el2 = crate::engine::css::matcher::ElementInfo {
1285            tag_name: "span".into(),
1286            id: None,
1287            classes: vec!["other".into()].into(),
1288            ..Default::default()
1289        };
1290        let candidates2: Vec<_> = rule_set.query_candidates(&el2).flat_map(decls_of).collect();
1291        // Should match universal (*) and tag (span)
1292        assert!(candidates2.iter().any(|d| d.name == "margin"));
1293        assert!(candidates2.iter().any(|d| d.name == "font-size"));
1294        // Should NOT include id (#header) or class (.btn)
1295        assert!(!candidates2.iter().any(|d| d.name == "color"));
1296        assert!(!candidates2.iter().any(|d| d.name == "display"));
1297    }
1298
1299    #[test]
1300    fn rule_set_groups_shared_selectors() {
1301        let styles = resolve(
1302            r#"
1303            div { color: red; font-size: 12px; }
1304            div { margin: 0; }
1305            p { padding: 1px; }
1306            p.padded { padding: 2px; }
1307            "#,
1308            StyleOrigin::Author,
1309        );
1310        let env = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
1311        let rule_set = RuleSet::from_declarations(&styles, &env);
1312
1313        // The two `div` blocks share an identical selector, so all three
1314        // declarations merge into a single group.
1315        let div = crate::engine::css::matcher::ElementInfo {
1316            tag_name: "div".into(),
1317            ..Default::default()
1318        };
1319        let groups: Vec<_> = rule_set.query_candidates(&div).collect();
1320        assert_eq!(groups.len(), 1);
1321        assert_eq!(groups[0].decls.len(), 3);
1322
1323        // Plain `p` only matches the `p` group.
1324        let p = crate::engine::css::matcher::ElementInfo {
1325            tag_name: "p".into(),
1326            ..Default::default()
1327        };
1328        assert_eq!(rule_set.query_candidates(&p).count(), 1);
1329
1330        // Distinct selectors stay in distinct groups.
1331        let padded = crate::engine::css::matcher::ElementInfo {
1332            tag_name: "p".into(),
1333            classes: vec!["padded".into()].into(),
1334            ..Default::default()
1335        };
1336        assert_eq!(rule_set.query_candidates(&padded).count(), 2);
1337    }
1338
1339    #[test]
1340    fn rule_set_indexes_attribute_subjects() {
1341        let styles = resolve(
1342            r#"
1343            [hidden] { display: none; }
1344            [data-tip] { position: relative; }
1345            "#,
1346            StyleOrigin::Author,
1347        );
1348        let env = MediaEnvironment::new((800.0, 600.0), ColorScheme::Light);
1349        let rule_set = RuleSet::from_declarations(&styles, &env);
1350
1351        let hidden = crate::engine::css::matcher::ElementInfo {
1352            tag_name: "div".into(),
1353            attributes: vec![("hidden".into(), "".into())].into(),
1354            ..Default::default()
1355        };
1356        assert_eq!(
1357            rule_set
1358                .query_candidates(&hidden)
1359                .flat_map(|group| group.decls.iter())
1360                .count(),
1361            1,
1362            "only the [hidden] group is queried"
1363        );
1364
1365        let plain = crate::engine::css::matcher::ElementInfo {
1366            tag_name: "div".into(),
1367            ..Default::default()
1368        };
1369        assert_eq!(
1370            rule_set
1371                .query_candidates(&plain)
1372                .flat_map(|group| group.decls.iter())
1373                .count(),
1374            0,
1375            "no candidates for attribute-less element"
1376        );
1377    }
1378
1379    #[test]
1380    fn media_width_calc_rem_is_resolved() {
1381        let styles = resolve(
1382            "@media (width > calc(19rem)) { div { color: red; } }",
1383            StyleOrigin::Author,
1384        );
1385        // 19rem = 304px
1386        let wide = MediaEnvironment::new((400.0, 800.0), ColorScheme::Light);
1387        let narrow = MediaEnvironment::new((200.0, 800.0), ColorScheme::Light);
1388        assert_eq!(filter_media(&styles, &wide).count(), 1);
1389        assert_eq!(filter_media(&styles, &narrow).count(), 0);
1390    }
1391
1392    #[test]
1393    fn media_range_calc_vw() {
1394        let styles = resolve(
1395            "@media (width > calc(100vw - 40px)) { div { color: red; } }",
1396            StyleOrigin::Author,
1397        );
1398        // 100vw = viewport_width, so condition is width > (viewport_width - 40)
1399        let env = MediaEnvironment::new((100.0, 800.0), ColorScheme::Light);
1400        // 100 > (100 - 40) = 60 => true
1401        assert_eq!(filter_media(&styles, &env).count(), 1);
1402        let env2 = MediaEnvironment::new((30.0, 800.0), ColorScheme::Light);
1403        // 30 > (30 - 40) = -10 => true
1404        assert_eq!(filter_media(&styles, &env2).count(), 1);
1405    }
1406
1407    #[test]
1408    fn media_min_width_rem() {
1409        let styles = resolve(
1410            "@media (min-width: 2rem) { div { color: red; } }",
1411            StyleOrigin::Author,
1412        );
1413        // 2rem = 32px
1414        let env = MediaEnvironment::new((40.0, 800.0), ColorScheme::Light);
1415        assert_eq!(filter_media(&styles, &env).count(), 1);
1416        let env2 = MediaEnvironment::new((20.0, 800.0), ColorScheme::Light);
1417        assert_eq!(filter_media(&styles, &env2).count(), 0);
1418    }
1419
1420    #[test]
1421    fn media_width_calc_min_function() {
1422        let styles = resolve(
1423            "@media (width > min(800px, 90vw)) { div { color: red; } }",
1424            StyleOrigin::Author,
1425        );
1426        // viewport 1000px: min(800, 900) = 800, 1000 > 800 => true
1427        let env = MediaEnvironment::new((1000.0, 800.0), ColorScheme::Light);
1428        assert_eq!(filter_media(&styles, &env).count(), 1);
1429        // viewport 500px: min(800, 450) = 450, 500 > 450 => true
1430        let env2 = MediaEnvironment::new((500.0, 800.0), ColorScheme::Light);
1431        assert_eq!(filter_media(&styles, &env2).count(), 1);
1432        // viewport 400px: min(800, 360) = 360, 400 > 360 => true
1433        let env3 = MediaEnvironment::new((400.0, 800.0), ColorScheme::Light);
1434        assert_eq!(filter_media(&styles, &env3).count(), 1);
1435        // viewport 300px: min(800, 270) = 270, 300 > 270 => true
1436        let env4 = MediaEnvironment::new((300.0, 800.0), ColorScheme::Light);
1437        assert_eq!(filter_media(&styles, &env4).count(), 1);
1438    }
1439}