diff --git a/.isu/issues.json b/.isu/issues.json index 1b3f32d..8d8736e 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 405, + "next_id": 406, "issues": [ { "id": 1, @@ -4949,6 +4949,19 @@ "author": "piefev", "state": "closed", "created_at": "2026-07-18T08:05:58Z" + }, + { + "id": 405, + "repo": "we", + "title": "Track remaining opera.com hero and cookie panel parity gap", + "body": "Follow-up from isu issue 281 after fixing the mobile-header absolute-positioning offset. The opera.com real-web scenario still fails Chromium screenshot parity.\\n\\nRepro:\\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\\n\\nCurrent failures after the header fix:\\n - Desktop L101: 64.68% match, 433885/1228500 px differ.\\n - Mobile L113: 68.54% match, 103570/329160 px differ.\\n\\nArtifacts:\\n - crates/e2e/artifacts/real-web/opera.com/desktop.png\\n - crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\\n - crates/e2e/artifacts/real-web/opera.com/mobile.png\\n - crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\\n\\nObserved remaining gaps are concentrated in the hero/cookie-panel area: title/hero vertical placement, cookie panel text metrics/height, and lower hero image crop. The mobile header logo/menu now align with the committed Chromium golden, so this should be tracked independently of the flex absolute-positioning fix.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T09:10:52Z" } ] } diff --git a/crates/e2e/scenarios/real-web/opera.com.we b/crates/e2e/scenarios/real-web/opera.com.we index 9ae7e93..63aa4c1 100644 --- a/crates/e2e/scenarios/real-web/opera.com.we +++ b/crates/e2e/scenarios/real-web/opera.com.we @@ -88,7 +88,6 @@ cache_put opera https://cdn-production-opera-website.operacdn.com/staticfiles/as cache_put opera https://cdn-production-opera-website.operacdn.com/staticfiles/assets/images/main/home/opera__home--hero__mobile-android.0ce2e2d9de27.png ../../real-web/snapshots/opera.com/cdn-production-opera-website.operacdn.com/staticfiles/assets/images/main/home/opera__home--hero__mobile-android.0ce2e2d9de27.png image/png cache_put opera https://cdn-production-opera-website.operacdn.com/staticfiles/assets/images/main/home/opera__home--hero__mobile-android%402x.239d7c979096.png ../../real-web/snapshots/opera.com/cdn-production-opera-website.operacdn.com/staticfiles/assets/images/main/home/opera__home--hero__mobile-android%402x.239d7c979096.png image/png goto_as https://www.opera.com/ crates/e2e/real-web/snapshots/opera.com/index.html -screenshot real-web/opera.com/desktop.png dump_dom real-web/opera.com/desktop_dom.txt dump_console real-web/opera.com/desktop_console.txt assert_dom_contains "Opera" @@ -98,11 +97,11 @@ type [id=footer__lang-input-next] interactivity dump_dom real-web/opera.com/desktop_interaction_dom.txt assert_dom_contains https://www.opera.com/interactivity assert_dom_contains survey_container +screenshot real-web/opera.com/desktop.png assert_screenshot_matches real-web/opera.com/desktop.png opera.com.desktop.chromium.expected.png viewport 390 844 goto_as https://www.opera.com/ crates/e2e/real-web/snapshots/opera.com/index.html -screenshot real-web/opera.com/mobile.png dump_dom real-web/opera.com/mobile_dom.txt dump_console real-web/opera.com/mobile_console.txt assert_dom_contains "Opera" @@ -110,4 +109,5 @@ type [id=footer__lang-input-next] interactivity dump_dom real-web/opera.com/mobile_interaction_dom.txt assert_dom_contains https://www.opera.com/interactivity assert_dom_contains survey_container +screenshot real-web/opera.com/mobile.png assert_screenshot_matches real-web/opera.com/mobile.png opera.com.mobile.chromium.expected.png diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index ca375e4..d5f941f 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2633,6 +2633,29 @@ fn padding_box_rect(b: &LayoutBox) -> Rect { } } +#[derive(Debug, Clone, Copy)] +struct AbsposStaticContext { + rect: Rect, + display: Display, + flex_direction: FlexDirection, + align_items: AlignItems, + justify_content: JustifyContent, +} + +fn abspos_static_context(parent: &LayoutBox) -> Option { + if matches!(parent.display, Display::Flex | Display::InlineFlex) { + Some(AbsposStaticContext { + rect: parent.rect, + display: parent.display, + flex_direction: parent.flex_direction, + align_items: parent.align_items, + justify_content: parent.justify_content, + }) + } else { + None + } +} + /// Lay out all absolutely and fixed positioned children of `parent`. /// /// `abs_cb` is the padding box of the nearest positioned ancestor passed from @@ -2661,12 +2684,14 @@ fn layout_abspos_children( } else { abs_cb }; + let static_context = abspos_static_context(parent); for i in 0..parent.children.len() { if parent.children[i].position == Position::Absolute { layout_absolute_child( &mut parent.children[i], new_abs_cb, + static_context, viewport_width, viewport_height, font, @@ -2677,6 +2702,54 @@ fn layout_abspos_children( layout_absolute_child( &mut parent.children[i], viewport_cb, + None, + viewport_width, + viewport_height, + font, + font_registry, + doc, + ); + } + } +} + +/// Recompute out-of-flow descendants after an in-flow subtree has been shifted +/// from provisional measurement coordinates to its final position. +/// +/// Flex and grid layout measure items at temporary origins and then offset the +/// whole item subtree into place. Absolute/fixed descendants do not always use +/// that item as their containing block, so a blind subtree offset can move them +/// away from the positioned ancestor or viewport they should remain anchored to. +fn relayout_out_of_flow_descendants( + parent: &mut LayoutBox, + inherited_abs_cb: Rect, + viewport_width: f32, + viewport_height: f32, + font: &Font, + font_registry: Option<&FontRegistry>, + doc: &Document, +) { + layout_abspos_children( + parent, + inherited_abs_cb, + viewport_width, + viewport_height, + font, + font_registry, + doc, + ); + + let child_abs_cb = if parent.position != Position::Static { + padding_box_rect(parent) + } else { + inherited_abs_cb + }; + + for child in &mut parent.children { + if is_in_flow(child) { + relayout_out_of_flow_descendants( + child, + child_abs_cb, viewport_width, viewport_height, font, @@ -2691,9 +2764,11 @@ fn layout_abspos_children( /// /// `cb` is the containing block (padding box of nearest positioned ancestor /// for absolute, or the viewport for fixed). +#[allow(clippy::too_many_arguments)] fn layout_absolute_child( child: &mut LayoutBox, cb: Rect, + static_context: Option, viewport_width: f32, viewport_height: f32, font: &Font, @@ -2966,8 +3041,9 @@ fn layout_absolute_child( - child.padding.bottom - child.rect.height } else { - // Static position: containing block origin. - cb.y + child.margin.top + child.border.top + child.padding.top + static_context + .and_then(|ctx| flex_abspos_static_y(child, ctx)) + .unwrap_or(cb.y + child.margin.top + child.border.top + child.padding.top) }; // Shift from temporary y to final y. @@ -2996,6 +3072,53 @@ fn layout_absolute_child( ); } +fn flex_abspos_static_y(child: &LayoutBox, ctx: AbsposStaticContext) -> Option { + if !matches!(ctx.display, Display::Flex | Display::InlineFlex) { + return None; + } + + let is_row = matches!( + ctx.flex_direction, + FlexDirection::Row | FlexDirection::RowReverse + ); + + if is_row { + let outer_cross = child.margin.top + + child.border.top + + child.padding.top + + child.rect.height + + child.padding.bottom + + child.border.bottom + + child.margin.bottom; + let align = match child.align_self { + AlignSelf::Auto => ctx.align_items, + AlignSelf::FlexStart => AlignItems::FlexStart, + AlignSelf::FlexEnd => AlignItems::FlexEnd, + AlignSelf::Center => AlignItems::Center, + AlignSelf::Baseline => AlignItems::Baseline, + AlignSelf::Stretch => AlignItems::Stretch, + }; + let free = ctx.rect.height - outer_cross; + let offset = match align { + AlignItems::FlexEnd => free, + AlignItems::Center => free / 2.0, + AlignItems::FlexStart | AlignItems::Stretch | AlignItems::Baseline => 0.0, + }; + Some(ctx.rect.y + offset + child.margin.top + child.border.top + child.padding.top) + } else { + let outer_main = child.margin.top + + child.border.top + + child.padding.top + + child.rect.height + + child.padding.bottom + + child.border.bottom + + child.margin.bottom; + let free = (ctx.rect.height - outer_main).max(0.0); + let (initial, _) = compute_content_distribution(ctx.justify_content, free, 1); + Some(ctx.rect.y + initial + child.margin.top + child.border.top + child.padding.top) + } +} + fn has_block_children(b: &LayoutBox) -> bool { b.children .iter() @@ -5834,11 +5957,33 @@ fn layout_flex_children( let dy = content_cross - child.rect.y; offset_subtree(child, dx, dy); child.rect.width = target_main; + if dx != 0.0 || dy != 0.0 { + relayout_out_of_flow_descendants( + child, + abs_cb, + viewport_width, + viewport_height, + font, + font_registry, + doc, + ); + } } else { let dx = content_cross - child.rect.x; let dy = content_main - child.rect.y; offset_subtree(child, dx, dy); child.rect.height = target_main; + if dx != 0.0 || dy != 0.0 { + relayout_out_of_flow_descendants( + child, + abs_cb, + viewport_width, + viewport_height, + font, + font_registry, + doc, + ); + } } if !is_reverse { @@ -7687,6 +7832,7 @@ fn adjust_fieldset_legend(fieldset: &mut LayoutBox, doc: &Document) { #[cfg(test)] mod tests { use super::*; + use we_css::parser::Parser; use we_dom::Document; use we_style::computed::{extract_stylesheets, resolve_styles}; @@ -7705,10 +7851,14 @@ mod tests { } fn layout_doc(doc: &Document) -> LayoutTree { + layout_doc_viewport(doc, 800.0, 600.0) + } + + fn layout_doc_viewport(doc: &Document, width: f32, height: f32) -> LayoutTree { let font = test_font(); let sheets = extract_stylesheets(doc); - let styled = resolve_styles(doc, &sheets, (800.0, 600.0)).unwrap(); - layout(&styled, doc, 800.0, 600.0, &font, &HashMap::new()) + let styled = resolve_styles(doc, &sheets, (width, height)).unwrap(); + layout(&styled, doc, width, height, &font, &HashMap::new()) } fn find_box(b: &LayoutBox, node: NodeId) -> Option<&LayoutBox> { @@ -11294,6 +11444,193 @@ body { margin: 0; } assert_eq!(child.rect.y, 24.0); } + #[test] + fn absolute_auto_top_in_flex_container_uses_aligned_static_position() { + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let bar = doc.create_element("div"); + let brand = doc.create_element("a"); + doc.append_child(body, bar); + doc.append_child(bar, brand); + doc.set_attribute(body, "style", "margin: 0;"); + doc.set_attribute( + bar, + "style", + "box-sizing: border-box; display: flex; align-items: center; height: 72px; padding: 16px 20px; position: relative; width: 390px;", + ); + doc.set_attribute( + brand, + "style", + "height: 30px; left: 18px; position: absolute; width: 82px;", + ); + let tree = layout_doc(&doc); + + let brand = find_box(&tree.root, brand).expect("brand box"); + + assert!((brand.rect.x - 18.0).abs() < 0.01); + assert!( + (brand.rect.y - 21.0).abs() < 0.01, + "auto top should use centered flex static position, got {}", + brand.rect.y + ); + } + + #[test] + fn nested_fixed_header_absolute_mobile_bar_stays_at_viewport_top() { + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let fixed = doc.create_element("header"); + let wrapper = doc.create_element("div"); + let mobile = doc.create_element("div"); + let brand = doc.create_element("a"); + doc.append_child(body, fixed); + doc.append_child(fixed, wrapper); + doc.append_child(wrapper, mobile); + doc.append_child(mobile, brand); + doc.set_attribute(body, "style", "margin: 0;"); + doc.set_attribute( + fixed, + "style", + "left: 0; min-height: 104px; position: fixed; top: 0; width: 390px;", + ); + doc.set_attribute( + wrapper, + "style", + "box-sizing: border-box; display: flex; height: 72px; padding: 16px 0; position: relative; width: 390px;", + ); + doc.set_attribute( + mobile, + "style", + "align-items: center; box-sizing: border-box; display: flex; height: 72px; left: 0; padding: 16px 20px; position: absolute; top: 0; width: 390px;", + ); + doc.set_attribute( + brand, + "style", + "height: 30px; left: 18px; position: absolute; width: 82px;", + ); + let tree = layout_doc(&doc); + + let mobile = find_box(&tree.root, mobile).expect("mobile bar"); + let brand = find_box(&tree.root, brand).expect("brand box"); + + assert!( + (mobile.rect.y - 16.0).abs() < 0.01, + "mobile bar content y should be its 16px top padding, got {}", + mobile.rect.y + ); + assert!( + (brand.rect.y - 21.0).abs() < 0.01, + "brand should center in the viewport-top mobile bar, got {}", + brand.rect.y + ); + } + + #[test] + fn opera_mobile_header_ignores_unmatched_navigation_sibling_rule() { + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let fixed = doc.create_element("header"); + let nav = doc.create_element("div"); + let wrapper = doc.create_element("div"); + let desktop_brand = doc.create_element("a"); + let menu = doc.create_element("div"); + let mobile = doc.create_element("div"); + let brand = doc.create_element("a"); + let logo = doc.create_element("img"); + doc.append_child(body, fixed); + doc.append_child(fixed, nav); + doc.append_child(nav, wrapper); + doc.append_child(wrapper, desktop_brand); + doc.append_child(wrapper, menu); + doc.append_child(wrapper, mobile); + doc.append_child(mobile, brand); + doc.append_child(brand, logo); + doc.set_attribute(body, "style", "margin: 0;"); + doc.set_attribute(fixed, "class", "hf hf__header active"); + doc.set_attribute(nav, "class", "header "); + doc.set_attribute(wrapper, "class", "header__wrapper container-fluid wrapper"); + doc.set_attribute(desktop_brand, "class", "header__brand"); + doc.set_attribute(menu, "class", "header__menu"); + doc.set_attribute(mobile, "class", "header__mobile container-fluid"); + doc.set_attribute(brand, "class", "header__mobile__brand"); + doc.set_attribute(logo, "width", "274"); + doc.set_attribute(logo, "height", "100"); + doc.set_attribute( + logo, + "style", + "height: 30px; width: auto; aspect-ratio: 274/100;", + ); + let css = r#" +body { margin: 0; } +header.hf__header { + left: 0; + min-height: 104px; + position: fixed; + top: 0; + width: 390px; +} +.header { + box-sizing: border-box; + display: flex; + height: 104px; + padding: 24px 0; + position: relative; +} +.header__wrapper { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 100%; +} +.header__mobile { display: none; } +@media(max-width:1223px) { + .header { height: 72px; } + .header__mobile { + align-items: center; + box-sizing: border-box; + display: flex; + height: 72px; + left: 0; + padding: 16px 20px; + position: absolute; + top: 0; + width: 100vw; + } + .header__mobile__brand { + display: block; + left: 18px; + position: absolute; + } + .header__brand { display: none; } +} +.header__navigation-top~.header__mobile { + height: 100px; + padding-top: 44px; +} +"#; + let font = test_font(); + let stylesheet = Parser::parse(css); + let styled = resolve_styles(&doc, &[stylesheet], (390.0, 844.0)).unwrap(); + let mut image_sizes = HashMap::new(); + image_sizes.insert(logo, ReplacedContent::Size(274.0, 100.0)); + let tree = layout(&styled, &doc, 390.0, 844.0, &font, &image_sizes); + + let mobile = find_box(&tree.root, mobile).expect("mobile bar"); + let logo = find_box(&tree.root, logo).expect("mobile logo"); + + assert!( + (mobile.rect.y - 16.0).abs() < 0.01, + "unmatched sibling rule must not change header padding, got {}", + mobile.rect.y + ); + assert!( + logo.rect.y < 24.0, + "logo should stay in the viewport-top mobile header, got {}", + logo.rect.y + ); + } + // ----------------------------------------------------------------------- // Absolute positioning tests // ----------------------------------------------------------------------- diff --git a/crates/style/src/matching.rs b/crates/style/src/matching.rs index 765e12e..3e7932f 100644 --- a/crates/style/src/matching.rs +++ b/crates/style/src/matching.rs @@ -982,6 +982,27 @@ mod tests { let sel2 = parse_first_selector("p.intro ~ a {}"); assert!(matches_selector(&t.doc, t.a_link, &sel2)); + + let sel3 = parse_first_selector(".intro~a {}"); + assert!(matches_selector(&t.doc, t.a_link, &sel3)); + + let sel4 = parse_first_selector(".missing~a {}"); + assert!(!matches_selector(&t.doc, t.a_link, &sel4)); + } + + #[test] + fn general_sibling_class_selector_does_not_match_without_previous_sibling() { + let mut doc = Document::new(); + let root = doc.root(); + let parent = doc.create_element("div"); + let mobile = doc.create_element("div"); + doc.append_child(root, parent); + doc.append_child(parent, mobile); + doc.set_attribute(mobile, "class", "header__mobile container-fluid"); + + let sel = parse_first_selector(".header__navigation-top~.header__mobile {}"); + + assert!(!matches_selector(&doc, mobile, &sel)); } // -----------------------------------------------------------------------