//! Stylesheet cascade: parse `
"#, ); let target = tree.get_element_by_id("target").unwrap(); let sibling = tree.get_element_by_id("sibling").unwrap(); let laid = crate::dom::layout_dom(&tree, (800.0, 600.0)); assert_eq!(laid.rects[&target].width, 300.0); assert_eq!(laid.rects[&target].height, 20.0); assert_eq!(laid.rects[&sibling].x, 300.0); } #[test] fn animation_sparse_tracks_ignore_unrelated_intermediate_keyframes() { let css = r#" @keyframes sparse { from { width:100px; opacity:0; background-color:red } 50% { opacity:1 } to { width:300px; opacity:0; background-color:blue } } #target { animation:sparse 1s linear both } "#; let style = sampled_animation_style(css, 500.0, "target"); assert_eq!(style.width, crate::Dimension::Px(200.0)); assert_opacity(style.opacity.unwrap(), 1.0); assert_eq!(style.background_color, Some([128, 0, 128, 255])); let implicit = r#" @keyframes middle { 50% { width:150px } } #target { width:50px; animation:middle 1s linear both } "#; assert_eq!( sampled_animation_style(implicit, 250.0, "target").width, crate::Dimension::Px(100.0) ); assert_eq!( sampled_animation_style(implicit, 750.0, "target").width, crate::Dimension::Px(100.0) ); } #[test] fn keyframe_duplicate_offsets_merge_per_property_and_ignore_important() { let css = r#" @keyframes merged { 0% { width:100px; color:red; opacity:.1 } from { width:999px !important; opacity:.8 } } #target { width:40px; animation:merged 1s both } "#; let style = sampled_animation_style(css, 0.0, "target"); assert_eq!(style.width, crate::Dimension::Px(100.0)); assert_eq!(style.color, Some([255, 0, 0, 255])); assert_opacity(style.opacity.unwrap(), 0.8); } #[test] fn author_important_overrides_every_sampled_animation_property() { let css = r#" @keyframes forced { from, to { width:300px; background-color:red } } #target { animation:forced 1s both; width:80px !important; background-color:blue !important; } "#; let style = sampled_animation_style(css, 0.0, "target"); assert_eq!(style.width, crate::Dimension::Px(80.0)); assert_eq!(style.background_color, Some([0, 0, 255, 255])); } #[test] fn conditional_keyframes_and_empty_later_definitions_follow_browser_selection() { let css = r#" @keyframes chosen { from, to { width:222px } } @media (max-width:100px) { @keyframes chosen { from, to { width:111px } } } @supports (unknown-parity-property:value) { @keyframes chosen { from, to { width:123px } } } #target { width:50px; animation:chosen 1s both } "#; assert_eq!( sampled_animation_style(css, 0.0, "target").width, crate::Dimension::Px(222.0) ); let empty = r#" @keyframes cleared { from, to { width:300px } } @keyframes cleared {} #target { width:50px; animation:cleared 1s both } "#; assert_eq!( sampled_animation_style(empty, 0.0, "target").width, crate::Dimension::Px(50.0) ); } #[test] fn keyframe_names_follow_cascade_layer_order_and_standard_prefix_priority() { let unlayered = r#" @keyframes chosen { from, to { width:222px } } @layer outer { @keyframes chosen { from, to { width:111px } } } #target { animation:chosen 1s both } "#; assert_eq!( sampled_animation_style(unlayered, 0.0, "target").width, crate::Dimension::Px(222.0) ); let ordered = r#" @layer weak, strong; @layer strong { @keyframes chosen { from, to { width:333px } } } @layer weak { @keyframes chosen { from, to { width:444px } } } #target { animation:chosen 1s both } "#; assert_eq!( sampled_animation_style(ordered, 0.0, "target").width, crate::Dimension::Px(333.0) ); let reversed = ordered.replace("@layer weak, strong", "@layer strong, weak"); assert_eq!( sampled_animation_style(&reversed, 0.0, "target").width, crate::Dimension::Px(444.0) ); let prefix = r#" @keyframes chosen { from, to { width:555px } } @-webkit-keyframes chosen { from, to { width:666px } } #target { animation:chosen 1s both } "#; assert_eq!( sampled_animation_style(prefix, 0.0, "target").width, crate::Dimension::Px(555.0) ); } #[test] fn animation_timeline_handles_delay_fill_iterations_and_direction_at_t0() { assert_opacity(sampled_fade("", 0.0), 0.0); assert_opacity(sampled_fade("animation-delay:250ms", 0.0), 0.4); assert_opacity( sampled_fade("animation-delay:250ms;animation-fill-mode:backwards", 0.0), 0.0, ); assert_opacity( sampled_fade( "animation-delay:250ms;animation-fill-mode:backwards;animation-direction:reverse", 0.0, ), 1.0, ); assert_opacity(sampled_fade("animation-delay:-250ms", 0.0), 0.25); assert_opacity(sampled_fade("animation-delay:-1s", 0.0), 0.0); assert_opacity( sampled_fade("animation-delay:-1s;animation-direction:alternate", 0.0), 1.0, ); assert_opacity( sampled_fade( "animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:none", 0.0, ), 0.4, ); assert_opacity( sampled_fade( "animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards", 0.0, ), 1.0, ); assert_opacity( sampled_fade( "animation-delay:-1s;animation-iteration-count:1;animation-fill-mode:forwards;animation-direction:reverse", 0.0, ), 0.0, ); assert_opacity( sampled_fade( "animation-delay:-2s;animation-iteration-count:2;animation-fill-mode:forwards;animation-direction:alternate", 0.0, ), 0.0, ); assert_opacity( sampled_fade( "animation-iteration-count:0;animation-fill-mode:forwards", 0.0, ), 0.0, ); assert_opacity( sampled_fade( "animation-duration:0s;animation-iteration-count:1;animation-fill-mode:none", 0.0, ), 0.4, ); assert_opacity( sampled_fade( "animation-duration:0s;animation-iteration-count:1;animation-fill-mode:forwards", 0.0, ), 1.0, ); assert_opacity( sampled_fade( "animation-delay:-2.5s;animation-iteration-count:2.5;animation-fill-mode:forwards", 0.0, ), 0.5, ); } #[test] fn animation_calc_delay_pause_and_important_origin_are_deterministic() { assert_opacity( sampled_fade("--step:.1s;animation-delay:calc(var(--step) * -2.5)", 0.0), 0.25, ); assert_opacity(sampled_fade("animation-play-state:paused", 500.0), 0.0); assert_opacity(sampled_fade("", 500.0), 0.5); assert_opacity( sampled_fade( "animation-delay:-250ms !important;opacity:.8 !important", 0.0, ), 0.8, ); } #[test] fn opacity_segments_use_underlying_endpoints_and_exact_later_boundaries() { let missing_endpoints = r#" @keyframes middle { 50% { opacity:1 } } #target { opacity:.4; animation:middle 1s linear 1 both } "#; assert_opacity( sampled_animation_style(missing_endpoints, 250.0, "target") .opacity .unwrap(), 0.7, ); assert_opacity( sampled_animation_style(missing_endpoints, 750.0, "target") .opacity .unwrap(), 0.7, ); let exact_boundary = r#" @keyframes peak { 0% { opacity:0 } 50% { opacity:1 } 100% { opacity:0 } } #target { opacity:.4; animation:peak 1s linear 1 both } "#; assert_opacity( sampled_animation_style(exact_boundary, 500.0, "target") .opacity .unwrap(), 1.0, ); } #[test] fn missing_keyframe_offsets_follow_web_animation_distribution() { let stop = |offset| KeyframeStop { offset, declarations: "opacity:1".to_string(), source_order: 0, }; let single = [stop(None)]; assert_eq!(normalized_keyframe_offsets(&single)[0].0, 1.0); let pair = [stop(None), stop(None)]; assert_eq!( normalized_keyframe_offsets(&pair) .iter() .map(|(offset, _)| *offset) .collect::>(), [0.0, 1.0] ); let distributed = [ stop(Some(0.0)), stop(None), stop(None), stop(Some(0.75)), stop(None), ]; assert_eq!( normalized_keyframe_offsets(&distributed) .iter() .map(|(offset, _)| *offset) .collect::>(), [0.0, 0.25, 0.5, 0.75, 1.0] ); } #[test] fn mozilla_stagger_samples_only_the_delay_zero_frame() { let mut html = String::new(); let mut selectors = String::new(); for frame in 0..12 { html.push_str(&format!(r#""#)); selectors.push_str(&format!( "#frame{frame}{{animation-delay:calc(var(--base-delay) * {frame})}}" )); } let tree = obscura_dom::parse_html(&html); let css = format!( r#" @keyframes wave {{ 0%, 8.333% {{ opacity:1 }} 8.4%, to {{ opacity:0 }} }} .frame {{ --base-delay:.1s; opacity:0; animation:wave 1.2s linear infinite; }} {selectors} "# ); let sheet = Stylesheet::parse_for_viewport_at_animation_time( &tree, &[css], (1280.0, 720.0), crate::AnimationSampleTime::default(), ); for frame in 0..12 { let id = format!("frame{frame}"); let target = tree.get_element_by_id(&id).unwrap(); let node = tree.get_node(target).unwrap(); let element = node.as_element().unwrap(); let classes = vec!["frame".to_string()]; let mut matcher = tree.matcher(); let mut style = LayoutStyle::default(); sheet.apply( &tree, &mut matcher, target, node.get_attribute("id"), &classes, element.local.as_ref(), &mut style, &HashMap::new(), None, ); assert_opacity(style.opacity.unwrap(), if frame == 0 { 1.0 } else { 0.0 }); } } #[test] fn media_rules_use_the_live_width_and_height() { let css = r#" .base { color: black; } @media (max-width: 950px) { .narrow { color: green; } } @media (min-height: 900px) { .tall { color: blue; } } @media (orientation: portrait) { .portrait { color: red; } } "#; let selectors = |viewport| { parse_stylesheet_for_viewport(css, viewport) .into_iter() .map(|(selector, _)| selector) .collect::>() }; let narrow_tall = selectors((900.0, 1000.0)); assert!(narrow_tall.iter().any(|s| s == ".base")); assert!(narrow_tall.iter().any(|s| s == ".narrow")); assert!(narrow_tall.iter().any(|s| s == ".tall")); assert!(narrow_tall.iter().any(|s| s == ".portrait")); let wide_short = selectors((1280.0, 720.0)); assert!(wide_short.iter().any(|s| s == ".base")); assert!(!wide_short.iter().any(|s| s == ".narrow")); assert!(!wide_short.iter().any(|s| s == ".tall")); assert!(!wide_short.iter().any(|s| s == ".portrait")); } #[test] fn nested_media_rules_use_the_live_viewport() { let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }"; let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0)); let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0)); assert!(narrow .iter() .any(|(selector, declarations)| selector == ".card" && declarations.contains("width:100%"))); assert!(!wide .iter() .any(|(_, declarations)| declarations.contains("width:100%"))); } #[test] fn tailwind_container_query_is_retained_but_simple_parser_omits_it() { let css = r#" .base { width: 10px } @container (min-width: 28rem) { .\@md\:flex-row { flex-direction: row } } @container main not (max-inline-size: 60em) { .named { width: 20px } } "#; let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, ); assert_eq!(parsed.len(), 3); assert_eq!(parsed[1].container_condition_id, ContainerConditionId(1)); assert_eq!(parsed[2].container_condition_id, ContainerConditionId(2)); assert_eq!(conditions.len(), 3); assert_eq!( conditions[1].alternatives[0].condition, Some(ContainerQueryExpr::Feature(ContainerSizeFeature { axis: ContainerQueryAxis::Width, comparison: ContainerQueryComparison::Min, length: ContainerQueryLength::Rem(28.0), })) ); assert_eq!(conditions[2].alternatives[0].name.as_deref(), Some("main")); assert!(matches!( conditions[2].alternatives[0].condition, Some(ContainerQueryExpr::Not(_)) )); assert_eq!(parse_stylesheet(css).len(), 1); } #[test] fn container_at_rule_keyword_is_ascii_insensitive_and_exact() { assert_eq!( at_rule_prelude("CoNtAiNeR (min-width:1px)", "container"), Some("(min-width:1px)") ); assert_eq!( at_rule_prelude("container/**/(min-width:1px)", "container"), Some("(min-width:1px)") ); assert!(at_rule_prelude("containerfoo (min-width:1px)", "container").is_none()); assert!(at_rule_prelude("container-type (min-width:1px)", "container").is_none()); let css = r#" @CONTAINER (min-width:1px) { .top-level { width:1px } } .host { @CoNtAiNeR (min-width:2px) { width:2px } @containerfoo (min-width:3px) { height:3px } } .comment-host { @CONTAINER/**/(min-width:5px) { height:5px } } @containerfoo (min-width:4px) { .unknown-prefix { width:4px } } "#; let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, ); assert_eq!(conditions.len(), 4); assert_eq!(parsed.len(), 3, "unknown prefix at-rules must be dropped"); assert!(parsed.iter().any(|rule| rule.selector == ".top-level")); assert!(parsed .iter() .any(|rule| { rule.selector == ".host" && rule.declarations.contains("width:2px") })); assert!(parsed.iter().any(|rule| { rule.selector == ".comment-host" && rule.declarations.contains("height:5px") })); assert!(!parsed.iter().any(|rule| { rule.selector == ".unknown-prefix" || rule.declarations.contains("height:3px") })); } #[test] fn global_at_rules_inside_container_are_not_conditioned() { let css = r#" @container (min-width:10000px) { @property --cq-token { syntax: ""; inherits: false; initial-value: 17px; } .conditional { width:999px } } "#; let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, ); assert_eq!(conditions.len(), 2); let registration = parsed .iter() .find(|rule| { rule.selector == format!("{PROPERTY_REGISTRATION_SELECTOR_PREFIX}--cq-token") }) .expect("@property initial value should be registered"); assert_eq!( registration.container_condition_id, ContainerConditionId::NONE ); assert!(registration.declarations.contains("initial-value: 17px")); let conditional = parsed .iter() .find(|rule| rule.selector == ".conditional") .expect("ordinary conditional rule should remain indexed"); assert_eq!(conditional.container_condition_id, ContainerConditionId(1)); assert!(parse_stylesheet(css).is_empty()); } fn apply_registered_property_test_style( sheet: &Stylesheet, tree: &DomTree, target: NodeId, parent_props: &HashMap, ) -> (LayoutStyle, HashMap) { let node = tree.get_node(target).unwrap(); let element = node.as_element().unwrap(); let classes = node .get_attribute("class") .map(|value| value.split_whitespace().map(str::to_string).collect::>()) .unwrap_or_default(); let mut matcher = tree.matcher(); let mut style = LayoutStyle::default(); let effective = sheet.apply( tree, &mut matcher, target, node.get_attribute("id"), &classes, element.local.as_ref(), &mut style, parent_props, None, ); (style, effective.unwrap_or_else(|| parent_props.clone())) } #[test] fn registered_custom_properties_obey_inherits_descriptors() { let tree = obscura_dom::parse_html( r#"
"#, ); let css = r#" @property --private { syntax:""; inherits:false; initial-value:75%; } @property --shared { syntax:""; inherits:true; initial-value:25%; } #parent { --private:20%; --shared:30% } #child, #initial { width:var(--private); height:var(--shared); } "#; let sheet = Stylesheet::parse(&tree, &[css.to_string()]); let parent = tree.get_element_by_id("parent").unwrap(); let child = tree.get_element_by_id("child").unwrap(); let initial = tree.get_element_by_id("initial").unwrap(); let (_, parent_props) = apply_registered_property_test_style(&sheet, &tree, parent, &HashMap::new()); let (child_style, _) = apply_registered_property_test_style(&sheet, &tree, child, &parent_props); assert_eq!(child_style.width, crate::Dimension::Percent(0.75)); assert_eq!(child_style.height, crate::Dimension::Percent(0.30)); let (initial_style, _) = apply_registered_property_test_style(&sheet, &tree, initial, &HashMap::new()); assert_eq!(initial_style.width, crate::Dimension::Percent(0.75)); assert_eq!(initial_style.height, crate::Dimension::Percent(0.25)); } #[test] fn registered_custom_property_overrides_and_var_fallbacks_stay_distinct() { let tree = obscura_dom::parse_html( r#"
"#, ); let css = r#" @property --registered { syntax:""; inherits:false; initial-value:75%; } #override { --registered:60%; width:var(--registered, 10%); height:var(--missing, 12%); } #reset { --registered:initial; width:var(--registered, 10%); height:var(--missing, 12%); } #invalid { --registered:red; width:var(--registered, 10%); } "#; let sheet = Stylesheet::parse(&tree, &[css.to_string()]); let override_id = tree.get_element_by_id("override").unwrap(); let reset_id = tree.get_element_by_id("reset").unwrap(); let invalid_id = tree.get_element_by_id("invalid").unwrap(); let (overridden, _) = apply_registered_property_test_style( &sheet, &tree, override_id, &HashMap::new(), ); assert_eq!(overridden.width, crate::Dimension::Percent(0.60)); assert_eq!(overridden.height, crate::Dimension::Percent(0.12)); let (reset, _) = apply_registered_property_test_style(&sheet, &tree, reset_id, &HashMap::new()); assert_eq!(reset.width, crate::Dimension::Percent(0.75)); assert_eq!(reset.height, crate::Dimension::Percent(0.12)); let (invalid, invalid_props) = apply_registered_property_test_style(&sheet, &tree, invalid_id, &HashMap::new()); assert_eq!(invalid.width, crate::Dimension::Percent(0.75)); assert_eq!( invalid_props.get("--registered").map(String::as_str), Some("75%"), "an invalid typed value computes to the registered initial value" ); } #[test] fn var_substitution_preserves_neighboring_token_boundaries() { let props = HashMap::from([ ("--stroke".to_string(), "2px".to_string()), ("--amount".to_string(), "10".to_string()), ]); assert_eq!( substitute_var_value("var(--stroke)solid", &props, 0).as_deref(), Some("2px solid") ); assert_eq!( substitute_var_value("calc(var(--stroke)*3)", &props, 0).as_deref(), Some("calc(2px*3)"), "punctuation adjacency must not gain calc-significant whitespace" ); assert_eq!( substitute_var_value("calc(var(--amount)- 2px)", &props, 0).as_deref(), Some("calc(10- 2px)"), "substitution must not make an invalid unspaced calc minus valid" ); assert_eq!( substitute_var_value("+var(--amount)", &props, 0).as_deref(), Some("+ 10"), "a delim plus followed by a number must not become a signed number token" ); assert_eq!( substitute_var_value("var(--amount)%", &props, 0).as_deref(), Some("10 %"), "a number token followed by a percent delimiter is not a percentage token" ); let tree = obscura_dom::parse_html(r#"
"#); let css = r#" #target { --stroke:2px; --ink:#123456; border:var(--stroke)solid var(--ink); height:calc(var(--stroke)*3); } "#; let sheet = Stylesheet::parse(&tree, &[css.to_string()]); let target = tree.get_element_by_id("target").unwrap(); let (style, _) = apply_registered_property_test_style(&sheet, &tree, target, &HashMap::new()); assert_eq!( style.border, crate::Edges { top: 2.0, right: 2.0, bottom: 2.0, left: 2.0, } ); assert_eq!(style.border_color, Some([0x12, 0x34, 0x56, 0xff])); assert_eq!(style.height, crate::Dimension::Px(6.0)); } #[test] fn wildcard_duplicate_and_invalid_property_registrations_are_bounded() { let tree = obscura_dom::parse_html( r#"
"#, ); let css = r#" @property --wild { syntax:"*"; inherits:false } @property --typed { syntax:""; inherits:false; initial-value:2; } @property --typed { syntax:""; initial-value:9; } @property --unsupported { syntax:""; inherits:false; initial-value:30deg; } @property --last { syntax:""; inherits:false; initial-value:1; } @property --last { syntax:""; inherits:false; initial-value:3; } #wild { --wild:20px; width:var(--wild, 7px) } #wild-fallback { width:var(--wild, 7px) } #typed { opacity:var(--typed, .1); height:var(--unsupported, 11px); } "#; let sheet = Stylesheet::parse(&tree, &[css.to_string()]); let wild = tree.get_element_by_id("wild").unwrap(); let wild_fallback = tree.get_element_by_id("wild-fallback").unwrap(); let typed = tree.get_element_by_id("typed").unwrap(); let (wild_style, _) = apply_registered_property_test_style(&sheet, &tree, wild, &HashMap::new()); assert_eq!(wild_style.width, crate::Dimension::Px(20.0)); let (fallback_style, _) = apply_registered_property_test_style(&sheet, &tree, wild_fallback, &HashMap::new()); assert_eq!(fallback_style.width, crate::Dimension::Px(7.0)); let (typed_style, typed_props) = apply_registered_property_test_style(&sheet, &tree, typed, &HashMap::new()); assert_eq!(typed_style.opacity, Some(2.0)); assert_eq!(typed_style.height, crate::Dimension::Px(11.0)); assert_eq!( typed_props.get("--typed").map(String::as_str), Some("2"), "a later invalid duplicate registration must not replace the valid one" ); assert_eq!( typed_props.get("--last").map(String::as_str), Some("3"), "the last valid registration wins" ); assert!(!typed_props.contains_key("--unsupported")); } #[test] fn registered_percentage_initial_value_keeps_radial_gradient_valid() { let tree = obscura_dom::parse_html(r#"
"#); let css = r#" @property --pulse-outer { syntax:""; inherits:false; initial-value:75%; } #pulse { background-image:radial-gradient( circle at 50% 50%, transparent var(--pulse-outer), rgb(255 255 255) 100% ); } "#; let sheet = Stylesheet::parse(&tree, &[css.to_string()]); let pulse = tree.get_element_by_id("pulse").unwrap(); let (style, props) = apply_registered_property_test_style(&sheet, &tree, pulse, &HashMap::new()); assert_eq!(props.get("--pulse-outer").map(String::as_str), Some("75%")); let (_, stops) = style .background_radial_gradient .expect("registered initial value should preserve the gradient"); assert_eq!(stops[0].1, Some(0.75)); assert_eq!(stops[1].1, Some(1.0)); } #[test] fn container_boolean_grammar_rejects_mixed_operators() { for invalid in [ "(min-width:1px) and (max-width:2px) or (min-inline-size:3px)", "not (min-width:1px) and (max-width:2px)", "(min-width:1px) or not (max-width:2px)", ] { assert!( parse_container_query_expr(invalid).is_none(), "invalid boolean grammar was accepted: {invalid}" ); } assert!(matches!( parse_container_query_expr( "(min-width:1px) and ((max-width:2px) or (min-inline-size:3px))" ), Some(ContainerQueryExpr::And(_)) )); assert!(matches!( parse_container_query_expr("not ((min-width:1px) and (max-inline-size:2px))"), Some(ContainerQueryExpr::Not(_)) )); } #[test] fn container_custom_ident_rejects_css_wide_and_default() { for reserved in [ "none", "not", "and", "or", "default", "initial", "inherit", "unset", "revert", "revert-layer", ] { assert!( parse_container_query_name(reserved).is_none(), "reserved custom-ident was accepted: {reserved}" ); if reserved != "not" { assert!( parse_container_query(&format!("{reserved} (min-width:1px)")).is_none(), "reserved query name was accepted: {reserved}" ); } } assert!( matches!( parse_container_query("not (min-width:1px)"), Some(ContainerQuery { name: None, condition: Some(ContainerQueryExpr::Not(_)), }) ), "`not` is reserved as a name but valid as the unary query operator" ); for valid in ["auto", "normal", "container", "--card", "main"] { assert_eq!( parse_container_query_name(valid).as_deref(), Some(valid), "valid query name was rejected: {valid}" ); } } #[test] fn unknown_comma_arm_does_not_drop_supported_arm() { let queries = parse_container_query_list("(future(foo)), main (min-width:1px)") .expect("general-enclosed arm is valid unknown syntax"); assert_eq!(queries.len(), 2); assert_eq!(queries[0].condition, Some(ContainerQueryExpr::Unknown)); assert_eq!(queries[1].name.as_deref(), Some("main")); assert!(matches!( queries[1].condition, Some(ContainerQueryExpr::Feature(_)) )); let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( "@container (future(foo)), main (min-width:1px) {.card{display:grid}}", (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, ); assert_eq!(parsed.len(), 1); assert_eq!(conditions[1].alternatives.len(), 2); } #[test] fn container_query_recursion_depth_is_bounded() { let nested = format!( "{}min-width:1px{}", "(".repeat(MAX_CONTAINER_QUERY_DEPTH + 16), ")".repeat(MAX_CONTAINER_QUERY_DEPTH + 16) ); assert!(parse_container_query_expr(&nested).is_none()); } #[test] fn supports_accepts_container_css_wide_values() { for property in ["container", "container-name", "container-type"] { for keyword in ["initial", "inherit", "unset", "revert", "revert-layer"] { assert!( supports_condition_applies(&format!("({property}:{keyword})")), "{property}:{keyword} is a valid whole-value CSS-wide declaration" ); } } } fn evaluate_container_styles( tree: &DomTree, sheet: &Stylesheet, target: NodeId, snapshot: &ContainerSnapshot, ) -> (LayoutStyle, ContainerDecisionSignature, ContainerQueryStats) { let node = tree.get_node(target).unwrap(); let element = node.as_element().unwrap(); let id = node.get_attribute("id"); let classes: Vec = node .get_attribute("class") .map(|value| value.split_whitespace().map(str::to_string).collect()) .unwrap_or_default(); let mut matcher = tree.matcher(); let mut evaluator = ContainerQueryEvaluator::new(tree, snapshot); let mut style = LayoutStyle::default(); sheet.apply_with_container_queries( tree, &mut matcher, target, id, &classes, element.local.as_ref(), &mut style, &HashMap::new(), None, &mut evaluator, ); let (signature, stats) = evaluator.finish(); (style, signature, stats) } fn container_box( container_type: crate::ContainerType, names: &[&str], content_width: f32, font_size: f32, ) -> ContainerBox { ContainerBox { container_type, available_type: container_type, names: names.iter().map(|name| (*name).to_string()).collect(), content_width, content_height: 100.0, font_size, } } #[test] fn container_evaluator_honors_tailwind_threshold_and_cache() { let tree = obscura_dom::parse_html( r#"
"#, ); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" #target { width:1px; height:1px } @container (min-width:28rem) { #target { width:2px } #target { height:2px } } "# .to_string()], ); let snapshot = |width| { let mut snapshot = ContainerSnapshot { root_font_size: 16.0, ..Default::default() }; snapshot.boxes.insert( container, container_box(crate::ContainerType::InlineSize, &[], width, 16.0), ); snapshot }; let (below, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot(447.0)); assert_eq!(below.width, crate::Dimension::Px(1.0)); assert_eq!(below.height, crate::Dimension::Px(1.0)); let (at, _, stats) = evaluate_container_styles(&tree, &sheet, target, &snapshot(448.0)); assert_eq!(at.width, crate::Dimension::Px(2.0)); assert_eq!(at.height, crate::Dimension::Px(2.0)); assert_eq!(stats.evaluations, 1); assert!(stats.cache_hits >= 1); } #[test] fn container_evaluator_matches_selector_before_ancestor_lookup() { let tree = obscura_dom::parse_html( r#"
"#, ); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" @container (min-width:1px) { div[data-never-present] { width:999px } } "# .to_string()], ); let mut snapshot = ContainerSnapshot { root_font_size: 16.0, ..Default::default() }; snapshot.boxes.insert( container, container_box(crate::ContainerType::InlineSize, &[], 500.0, 16.0), ); let (_, signature, stats) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(stats.evaluations, 0); assert_eq!(stats.ancestor_steps, 0); assert!(signature.decisions.is_empty()); } #[test] fn container_evaluator_selects_nearest_eligible_named_ancestor() { let tree = obscura_dom::parse_html( r#"
"#, ); let outer = tree.get_element_by_id("outer").unwrap(); let inner = tree.get_element_by_id("inner").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" #target { width:1px; height:1px } @container shell (min-width:500px) { #target { width:11px } } @container (min-width:500px) { #target { height:22px } } "# .to_string()], ); let mut snapshot = ContainerSnapshot { root_font_size: 16.0, ..Default::default() }; snapshot.boxes.insert( outer, container_box( crate::ContainerType::InlineSize, &["shell"], 600.0, 16.0, ), ); snapshot.boxes.insert( inner, container_box( crate::ContainerType::InlineSize, &["other"], 300.0, 16.0, ), ); let (style, _, stats) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(style.width, crate::Dimension::Px(11.0)); assert_eq!(style.height, crate::Dimension::Px(1.0)); assert!(stats.ancestor_steps >= 3); } #[test] fn container_query_em_uses_container_font_and_rem_uses_root_font() { let tree = obscura_dom::parse_html( r#"
"#, ); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" #target { width:1px; height:1px } @container (min-width:30em) { #target { width:3px } } @container (min-width:30rem) { #target { height:3px } } "# .to_string()], ); let mut snapshot = ContainerSnapshot { root_font_size: 20.0, ..Default::default() }; snapshot.boxes.insert( container, container_box(crate::ContainerType::InlineSize, &[], 400.0, 10.0), ); let (style, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(style.width, crate::Dimension::Px(3.0)); assert_eq!(style.height, crate::Dimension::Px(1.0)); } #[test] fn container_snapshot_compares_only_axes_exposed_by_container_type() { let mut inline_a = container_box(crate::ContainerType::InlineSize, &[], 400.0, 16.0); let mut inline_b = inline_a.clone(); inline_a.content_height = 100.0; inline_b.content_height = 900.0; assert_eq!(inline_a, inline_b); let mut size_a = container_box(crate::ContainerType::Size, &[], 400.0, 16.0); let mut size_b = size_a.clone(); size_a.content_height = 100.0; size_b.content_height = 900.0; assert_ne!(size_a, size_b); } #[test] fn nested_container_conditions_select_independent_containers() { let tree = obscura_dom::parse_html( r#"
"#, ); let outer = tree.get_element_by_id("outer").unwrap(); let inner = tree.get_element_by_id("inner").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" #target { width:1px } @container outer (min-width:500px) { @container inner (min-width:200px) { #target { width:9px } } } "# .to_string()], ); let snapshot = |inner_width| { let mut snapshot = ContainerSnapshot { root_font_size: 16.0, ..Default::default() }; snapshot.boxes.insert( outer, container_box( crate::ContainerType::InlineSize, &["outer"], 600.0, 16.0, ), ); snapshot.boxes.insert( inner, container_box( crate::ContainerType::InlineSize, &["inner"], inner_width, 16.0, ), ); snapshot }; let (matching, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot(200.0)); assert_eq!(matching.width, crate::Dimension::Px(9.0)); let (failing, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot(199.0)); assert_eq!(failing.width, crate::Dimension::Px(1.0)); } #[test] fn unknown_container_alternative_does_not_mask_true_alternative() { let tree = obscura_dom::parse_html( r#"
"#, ); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" #target { width:1px } @container (future(foo)), (min-width:100px) { #target { width:7px } } "# .to_string()], ); let mut snapshot = ContainerSnapshot { root_font_size: 16.0, ..Default::default() }; snapshot.boxes.insert( container, container_box(crate::ContainerType::InlineSize, &[], 100.0, 16.0), ); let (style, _, _) = evaluate_container_styles(&tree, &sheet, target, &snapshot); assert_eq!(style.width, crate::Dimension::Px(7.0)); } #[test] fn container_query_boolean_evaluation_uses_kleene_truth_tables() { let container = container_box(crate::ContainerType::InlineSize, &[], 200.0, 16.0); let min_width = |threshold| { ContainerQueryExpr::Feature(ContainerSizeFeature { axis: ContainerQueryAxis::Width, comparison: ContainerQueryComparison::Min, length: ContainerQueryLength::Px(threshold), }) }; assert_eq!( evaluate_container_query_expr( &ContainerQueryExpr::Or(vec![ min_width(100.0), ContainerQueryExpr::Unknown, ]), &container, 16.0, ), ContainerQueryTruth::True ); assert_eq!( evaluate_container_query_expr( &ContainerQueryExpr::And(vec![ min_width(300.0), ContainerQueryExpr::Unknown, ]), &container, 16.0, ), ContainerQueryTruth::False ); assert_eq!( evaluate_container_query_expr( &ContainerQueryExpr::Or(vec![ min_width(300.0), ContainerQueryExpr::Unknown, ]), &container, 16.0, ), ContainerQueryTruth::Unknown ); assert_eq!( evaluate_container_query_expr( &ContainerQueryExpr::Not(Box::new(ContainerQueryExpr::Unknown)), &container, 16.0, ), ContainerQueryTruth::Unknown ); } #[test] fn container_range_syntax_supports_strict_inclusive_and_chained_queries() { let container = container_box(crate::ContainerType::Size, &[], 200.0, 16.0); for query in [ "(width)", "(width > 199px)", "(width>=200px)", "(199px < width)", "(199px < width <= 200px)", "(height = 100px)", "(block-size >= 100px)", ] { let expression = parse_container_query_expr(query).expect("valid range query"); assert_eq!( evaluate_container_query_expr(&expression, &container, 16.0), ContainerQueryTruth::True, "{query}" ); } for query in [ "(width > 200px)", "(width < 200px)", "(200px < width < 300px)", "(height > 100px)", ] { let expression = parse_container_query_expr(query).expect("valid range query"); assert_eq!( evaluate_container_query_expr(&expression, &container, 16.0), ContainerQueryTruth::False, "{query}" ); } for invalid in [ "(100px < width > 200px)", "(200px > width < 100px)", "(100px = width = 100px)", "(100px < width = 200px)", ] { assert!( parse_container_query_expr(invalid).is_none(), "invalid mixed/equality chain parsed: {invalid}" ); } } #[test] fn block_axis_query_requires_size_container() { let tree = obscura_dom::parse_html( r#"
"#, ); let container = tree.get_element_by_id("container").unwrap(); let target = tree.get_element_by_id("target").unwrap(); let sheet = Stylesheet::parse( &tree, &[r#" #target { width:1px } @container (height >= 100px) { #target { width:8px } } "# .to_string()], ); let snapshot = |container_type| { let mut snapshot = ContainerSnapshot { root_font_size: 16.0, ..Default::default() }; snapshot.boxes.insert( container, container_box(container_type, &[], 300.0, 16.0), ); snapshot }; let (inline_only, _, _) = evaluate_container_styles( &tree, &sheet, target, &snapshot(crate::ContainerType::InlineSize), ); assert_eq!(inline_only.width, crate::Dimension::Px(1.0)); let (size, _, _) = evaluate_container_styles( &tree, &sheet, target, &snapshot(crate::ContainerType::Size), ); assert_eq!(size.width, crate::Dimension::Px(8.0)); } #[test] fn nested_container_rules_form_a_parent_condition_chain() { let css = "@container shell (min-width:40rem){\ @container (max-inline-size:50rem){.card{display:grid}}}"; let mut conditions = condition_arena_root(); let parsed = parse_stylesheet_for_viewport_preserving_containers( css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, ); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].container_condition_id, ContainerConditionId(2)); assert_eq!(conditions[2].parent, ContainerConditionId(1)); assert_eq!(conditions[1].parent, ContainerConditionId::NONE); } #[test] fn unresolved_container_rules_do_not_enter_cascade_or_pseudos() { let tree = obscura_dom::parse_html(r#"
"#); let target = tree.query_selector("#target").unwrap().unwrap(); let sheet = Stylesheet::parse(&tree, &[r#" #target{width:10px} @container (min-width:28rem){ #target{width:999px} #target::before{content:"inactive"} } #target{height:20px} "#.to_string()]); assert_eq!(sheet.rules.len(), 3, "conditional rule remains indexed"); assert_eq!(sheet.container_conditions.len(), 2); let mut matcher = tree.matcher(); let mut style = LayoutStyle::default(); sheet.apply( &tree, &mut matcher, target, Some("target"), &[], "div", &mut style, &HashMap::new(), None, ); assert_eq!(style.width, crate::Dimension::Px(10.0)); assert_eq!(style.height, crate::Dimension::Px(20.0)); let (before, after) = sheet.pseudo_styles(&tree, &mut matcher, target, &HashMap::new(), &style); assert!(before.is_none() && after.is_none()); } #[test] fn no_container_parser_output_and_order_are_unchanged() { let css = ".card{width:10px}\ @supports (display:grid){.card{display:grid}}\ @media (min-width:64rem){.card{width:20px}}\ .card{height:30px}"; assert_eq!( parse_stylesheet_for_viewport(css, (1280.0, 720.0)), vec![ (".card".into(), "width:10px;".into()), (".card".into(), "display:grid;".into()), (".card".into(), "width:20px;".into()), (".card".into(), "height:30px;".into()), ] ); let mut conditions = condition_arena_root(); let rich = parse_stylesheet_for_viewport_preserving_containers( css, (1280.0, 720.0), &mut conditions, ContainerConditionId::NONE, ); assert_eq!(conditions.len(), 1); assert!(rich.iter().all(|rule| rule.container_condition_id == ContainerConditionId::NONE)); } #[test] fn supports_conditions_gate_legacy_framework_fallbacks() { let legacy_probe = "(((-webkit-hyphens:none)) and \ (not (margin-trim:inline))) or \ ((-moz-orient:inline) and \ (not (color:rgb(from red r g b))))"; assert!(!supports_condition_applies(legacy_probe)); assert!(supports_condition_applies( "(display:grid) and (selector(.card > *))" )); assert!(supports_condition_applies( "not (unknown-engine-prop:value)" )); let css = format!( "@supports {legacy_probe} {{ .legacy {{ line-height:1.5 }} }}\ @supports (display:grid) {{ .modern {{ display:grid }} }}\ .host {{ @supports {legacy_probe} {{ width:999px }} }}" ); let rules = parse_stylesheet_for_viewport(&css, (1280.0, 720.0)); assert!( !rules.iter().any(|(selector, declarations)| { selector == ".legacy" || declarations.contains("999px") }), "false supports branches must be skipped: {rules:?}" ); assert!( rules.iter().any(|(selector, declarations)| { selector == ".modern" && declarations.contains("display:grid") }), "true supports branch should remain: {rules:?}" ); } #[test] fn supports_conditions_reject_invalid_boolean_grammar() { assert!(supports_condition_applies("(display:grid) and (word-break:break-all)")); assert!(supports_condition_applies("(display:grid) or (unknown:value)")); assert!(!supports_condition_applies( "(display:grid) and (word-break:break-all) or (display:flex)" )); assert!(!supports_condition_applies( "not ((display:grid) and (word-break:break-all) or (display:flex))" )); assert!(!supports_condition_applies("not ()")); assert!(!supports_condition_applies("(display:grid;)")); assert!(!supports_condition_applies("not (display:grid")); } #[test] fn supports_polygon_clip_path_activates_only_painted_shape_subset() { let css = "@supports (clip-path:polygon(0 0,100% 0,50% 100%)){\ .transition{height:90px}\ }\ @supports (clip-path:polygon(0 0,100% 0,50% 100%) content-box){\ .unsupported{height:999px}\ }"; let rules = parse_stylesheet_for_viewport(css, (1280.0, 720.0)); assert!( rules.iter().any(|(selector, declarations)| { selector == ".transition" && declarations.contains("height:90px") }), "the supported polygon branch must enter the cascade: {rules:?}" ); assert!( !rules.iter().any(|(selector, _)| selector == ".unsupported"), "an unpainted geometry-box variant must not pass @supports: {rules:?}" ); } #[test] fn media_breakpoints_support_font_relative_lengths_and_ranges() { assert!(!media_query_applies_for_viewport( "@media (min-width: 64rem)", (900.0, 1000.0) )); assert!(media_query_applies_for_viewport( "@media (min-width: 64rem)", (1024.0, 768.0) )); assert!(media_query_applies_for_viewport( "@media (56.25rem <= width)", (900.0, 1000.0) )); assert!(!media_query_applies_for_viewport( "@media (width > calc(60em - 1px))", (900.0, 1000.0) )); } #[test] fn negated_min_width_queries_form_max_breakpoints() { assert!(media_query_applies_for_viewport( "@media not all and (min-width: 40rem)", (639.0, 900.0) )); assert!(!media_query_applies_for_viewport( "@media not all and (min-width: 40rem)", (1280.0, 900.0) )); let css = r#" .hidden { display: none } @media not all and (min-width: 40rem) { .max-sm\:inline { display: inline } } @media (min-width: 80rem) { .xl\:inline { display: inline } } "#; let desktop = parse_stylesheet_for_viewport(css, (1280.0, 900.0)); assert!(!desktop .iter() .any(|(selector, _)| selector == r".max-sm\:inline")); assert!(desktop .iter() .any(|(selector, _)| selector == r".xl\:inline")); } #[test] fn media_query_lists_are_or_conditions() { assert!(media_query_applies_for_viewport( "@media print, (min-width: 64rem)", (1280.0, 720.0) )); assert!(!media_query_applies_for_viewport( "@media print, (min-width: 64rem)", (900.0, 1000.0) )); } #[test] fn rem_breakpoint_does_not_reveal_desktop_menu_on_narrow_viewport() { let css = r#" header .menu-toolkit { display: none } @media (min-width: 64rem) { header .menu-toolkit { display: flex } } "#; let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0)); assert!(narrow.iter().any(|(selector, declarations)| { selector == "header .menu-toolkit" && declarations.contains("display: none") })); assert!(!narrow.iter().any(|(_, declarations)| declarations.contains("display: flex"))); let wide = parse_stylesheet_for_viewport(css, (1024.0, 768.0)); assert!(wide.iter().any(|(_, declarations)| declarations.contains("display: flex"))); } }