we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement vertical margin collapsing for block-level elements

Implements CSS2 §8.3.1 margin collapsing:
- Adjacent sibling margins collapse (gap = max, not sum)
- Parent-child margin collapsing via pre_collapse_margins pass
- Empty block self-collapsing (top+bottom margins fold together)
- Negative margin handling (positive+negative=sum, both negative=min)
- Collapsing blocked by border, padding, or overflow!=visible (BFC)

Adds overflow field to LayoutBox for BFC detection. Updates existing
tests to reflect correct collapsed margin behavior and adds 9 new
tests covering all collapsing scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+493 -9
+493 -9
crates/layout/src/lib.rs
··· 8 8 use we_css::values::Color; 9 9 use we_dom::{Document, NodeData, NodeId}; 10 10 use we_style::computed::{ 11 - BorderStyle, ComputedStyle, Display, LengthOrAuto, Position, StyledNode, TextAlign, 11 + BorderStyle, ComputedStyle, Display, LengthOrAuto, Overflow, Position, StyledNode, TextAlign, 12 12 TextDecoration, 13 13 }; 14 14 use we_text::font::Font; ··· 93 93 pub position: Position, 94 94 /// Relative position offset (dx, dy) applied after normal flow layout. 95 95 pub relative_offset: (f32, f32), 96 + /// CSS `overflow` property. 97 + pub overflow: Overflow, 96 98 } 97 99 98 100 impl LayoutBox { ··· 126 128 replaced_size: None, 127 129 position: style.position, 128 130 relative_offset: (0.0, 0.0), 131 + overflow: style.overflow, 129 132 } 130 133 } 131 134 ··· 484 487 b.children.iter().any(is_block_level) 485 488 } 486 489 487 - /// Lay out block-level children: stack them vertically. 490 + /// Collapse two adjoining margins per CSS2 §8.3.1. 491 + /// 492 + /// Both non-negative → use the larger. 493 + /// Both negative → use the more negative. 494 + /// Mixed → sum the largest positive and most negative. 495 + fn collapse_margins(a: f32, b: f32) -> f32 { 496 + if a >= 0.0 && b >= 0.0 { 497 + a.max(b) 498 + } else if a < 0.0 && b < 0.0 { 499 + a.min(b) 500 + } else { 501 + a + b 502 + } 503 + } 504 + 505 + /// Returns `true` if this box establishes a new block formatting context, 506 + /// which prevents its margins from collapsing with children. 507 + fn establishes_bfc(b: &LayoutBox) -> bool { 508 + b.overflow != Overflow::Visible 509 + } 510 + 511 + /// Returns `true` if a block box has no in-flow content (empty block). 512 + fn is_empty_block(b: &LayoutBox) -> bool { 513 + b.children.is_empty() && b.lines.is_empty() && b.replaced_size.is_none() 514 + } 515 + 516 + /// Pre-collapse parent-child margins (CSS2 §8.3.1). 517 + /// 518 + /// When a parent has no border/padding/BFC separating it from its first/last 519 + /// child, the child's margin collapses into the parent's margin. This must 520 + /// happen *before* positioning so the parent is placed using the collapsed 521 + /// value. The function walks bottom-up: children are pre-collapsed first, then 522 + /// their (possibly enlarged) margins are folded into the parent. 523 + fn pre_collapse_margins(b: &mut LayoutBox) { 524 + // Recurse into block children first (bottom-up). 525 + for child in &mut b.children { 526 + if is_block_level(child) { 527 + pre_collapse_margins(child); 528 + } 529 + } 530 + 531 + if !matches!(b.box_type, BoxType::Block(_) | BoxType::Anonymous) { 532 + return; 533 + } 534 + if establishes_bfc(b) { 535 + return; 536 + } 537 + if !has_block_children(b) { 538 + return; 539 + } 540 + 541 + // --- Top: collapse with first non-empty child --- 542 + if b.border.top == 0.0 && b.padding.top == 0.0 { 543 + if let Some(child_top) = first_block_top_margin(&b.children) { 544 + b.margin.top = collapse_margins(b.margin.top, child_top); 545 + } 546 + } 547 + 548 + // --- Bottom: collapse with last non-empty child --- 549 + if b.border.bottom == 0.0 && b.padding.bottom == 0.0 { 550 + if let Some(child_bottom) = last_block_bottom_margin(&b.children) { 551 + b.margin.bottom = collapse_margins(b.margin.bottom, child_bottom); 552 + } 553 + } 554 + } 555 + 556 + /// Top margin of the first non-empty block child (already pre-collapsed). 557 + fn first_block_top_margin(children: &[LayoutBox]) -> Option<f32> { 558 + for child in children { 559 + if is_block_level(child) { 560 + if is_empty_block(child) { 561 + continue; 562 + } 563 + return Some(child.margin.top); 564 + } 565 + } 566 + // All block children empty — fold all their collapsed margins. 567 + let mut m = 0.0f32; 568 + for child in children.iter().filter(|c| is_block_level(c)) { 569 + m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 570 + } 571 + if m != 0.0 { 572 + Some(m) 573 + } else { 574 + None 575 + } 576 + } 577 + 578 + /// Bottom margin of the last non-empty block child (already pre-collapsed). 579 + fn last_block_bottom_margin(children: &[LayoutBox]) -> Option<f32> { 580 + for child in children.iter().rev() { 581 + if is_block_level(child) { 582 + if is_empty_block(child) { 583 + continue; 584 + } 585 + return Some(child.margin.bottom); 586 + } 587 + } 588 + let mut m = 0.0f32; 589 + for child in children.iter().filter(|c| is_block_level(c)) { 590 + m = collapse_margins(m, collapse_margins(child.margin.top, child.margin.bottom)); 591 + } 592 + if m != 0.0 { 593 + Some(m) 594 + } else { 595 + None 596 + } 597 + } 598 + 599 + /// Lay out block-level children with vertical margin collapsing (CSS2 §8.3.1). 600 + /// 601 + /// Handles adjacent-sibling collapsing, empty-block collapsing, and 602 + /// parent-child internal spacing (the parent's external margins were already 603 + /// updated by `pre_collapse_margins`). 488 604 fn layout_block_children(parent: &mut LayoutBox, font: &Font, doc: &Document) { 489 605 let content_x = parent.rect.x; 490 606 let content_width = parent.rect.width; 491 607 let mut cursor_y = parent.rect.y; 492 608 493 - for child in &mut parent.children { 494 - compute_layout(child, content_x, cursor_y, content_width, font, doc); 495 - cursor_y += child.margin_box_height(); 609 + let parent_top_open = 610 + parent.border.top == 0.0 && parent.padding.top == 0.0 && !establishes_bfc(parent); 611 + let parent_bottom_open = 612 + parent.border.bottom == 0.0 && parent.padding.bottom == 0.0 && !establishes_bfc(parent); 613 + 614 + // Pending bottom margin from the previous sibling. 615 + let mut pending_margin: Option<f32> = None; 616 + let child_count = parent.children.len(); 617 + 618 + for i in 0..child_count { 619 + let child_top_margin = parent.children[i].margin.top; 620 + let child_bottom_margin = parent.children[i].margin.bottom; 621 + 622 + // --- Empty block: top+bottom margins self-collapse --- 623 + if is_empty_block(&parent.children[i]) { 624 + let self_collapsed = collapse_margins(child_top_margin, child_bottom_margin); 625 + pending_margin = Some(match pending_margin { 626 + Some(prev) => collapse_margins(prev, self_collapsed), 627 + None => self_collapsed, 628 + }); 629 + // Position at cursor_y with zero height. 630 + let child = &mut parent.children[i]; 631 + child.rect.x = content_x + child.border.left + child.padding.left; 632 + child.rect.y = cursor_y + child.border.top + child.padding.top; 633 + child.rect.width = (content_width 634 + - child.border.left 635 + - child.border.right 636 + - child.padding.left 637 + - child.padding.right) 638 + .max(0.0); 639 + child.rect.height = 0.0; 640 + continue; 641 + } 642 + 643 + // --- Compute effective top spacing --- 644 + let collapsed_top = if let Some(prev_bottom) = pending_margin.take() { 645 + // Sibling collapsing: previous bottom vs this top. 646 + collapse_margins(prev_bottom, child_top_margin) 647 + } else if i == 0 && parent_top_open { 648 + // First child, parent top open: margin was already pulled into 649 + // parent by pre_collapse_margins — no internal spacing. 650 + 0.0 651 + } else { 652 + child_top_margin 653 + }; 654 + 655 + // `compute_layout` adds `child.margin.top` internally, so compensate. 656 + let y_for_child = cursor_y + collapsed_top - child_top_margin; 657 + compute_layout( 658 + &mut parent.children[i], 659 + content_x, 660 + y_for_child, 661 + content_width, 662 + font, 663 + doc, 664 + ); 665 + 666 + let child = &parent.children[i]; 667 + // Use the normal-flow position (before relative offset) so that 668 + // `position: relative` does not affect sibling placement. 669 + let (_, rel_dy) = child.relative_offset; 670 + cursor_y = (child.rect.y - rel_dy) 671 + + child.rect.height 672 + + child.padding.bottom 673 + + child.border.bottom; 674 + pending_margin = Some(child_bottom_margin); 675 + } 676 + 677 + // Trailing margin. 678 + if let Some(trailing) = pending_margin { 679 + if !parent_bottom_open { 680 + // Parent has border/padding at bottom — margin stays inside. 681 + cursor_y += trailing; 682 + } 683 + // If parent_bottom_open, the margin was already pulled into the 684 + // parent by pre_collapse_margins. 496 685 } 497 686 498 687 parent.rect.height = cursor_y - parent.rect.y; ··· 815 1004 } 816 1005 }; 817 1006 1007 + // Pre-collapse parent-child margins before positioning. 1008 + pre_collapse_margins(&mut root); 1009 + 818 1010 compute_layout(&mut root, 0.0, 0.0, viewport_width, font, doc); 819 1011 820 1012 let height = root.margin_box_height(); ··· 991 1183 let tree = layout_doc(&doc); 992 1184 let body_box = &tree.root.children[0]; 993 1185 994 - assert_eq!(body_box.margin.top, 8.0); 1186 + // body default margin is 8px, but it collapses with p's 16px margin 1187 + // (parent-child collapsing: no border/padding on body). 1188 + assert_eq!(body_box.margin.top, 16.0); 995 1189 assert_eq!(body_box.margin.right, 8.0); 996 - assert_eq!(body_box.margin.bottom, 8.0); 1190 + assert_eq!(body_box.margin.bottom, 16.0); 997 1191 assert_eq!(body_box.margin.left, 8.0); 998 1192 999 1193 assert_eq!(body_box.rect.x, 8.0); 1000 - assert_eq!(body_box.rect.y, 8.0); 1194 + // body.rect.y = collapsed margin (16) from viewport top. 1195 + assert_eq!(body_box.rect.y, 16.0); 1001 1196 } 1002 1197 1003 1198 #[test] ··· 1267 1462 assert_eq!(first.margin.top, 50.0); 1268 1463 assert_eq!(first.margin.bottom, 50.0); 1269 1464 1270 - assert!(second.rect.y > first.rect.y + 100.0); 1465 + // Adjacent sibling margins collapse: gap = max(50, 50) = 50, not 100. 1466 + let gap = second.rect.y - (first.rect.y + first.rect.height); 1467 + assert!( 1468 + (gap - 50.0).abs() < 1.0, 1469 + "collapsed margin gap should be ~50px, got {gap}" 1470 + ); 1271 1471 } 1272 1472 1273 1473 #[test] ··· 1708 1908 1709 1909 assert_eq!(div_box.position, Position::Static); 1710 1910 assert_eq!(div_box.relative_offset, (0.0, 0.0)); 1911 + } 1912 + 1913 + // --- Margin collapsing tests --- 1914 + 1915 + #[test] 1916 + fn adjacent_sibling_margins_collapse() { 1917 + // Two <p> elements each with margin 16px: gap should be 16px (max), not 32px (sum). 1918 + let html_str = r#"<!DOCTYPE html> 1919 + <html> 1920 + <head><style> 1921 + body { margin: 0; border-top: 1px solid black; } 1922 + p { margin-top: 16px; margin-bottom: 16px; } 1923 + </style></head> 1924 + <body> 1925 + <p>First</p> 1926 + <p>Second</p> 1927 + </body> 1928 + </html>"#; 1929 + let doc = we_html::parse_html(html_str); 1930 + let font = test_font(); 1931 + let sheets = extract_stylesheets(&doc); 1932 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1933 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1934 + 1935 + let body_box = &tree.root.children[0]; 1936 + let first = &body_box.children[0]; 1937 + let second = &body_box.children[1]; 1938 + 1939 + // Gap between first's bottom border-box and second's top border-box 1940 + // should be the collapsed margin: max(16, 16) = 16. 1941 + let first_bottom = 1942 + first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 1943 + let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 1944 + assert!( 1945 + (gap - 16.0).abs() < 1.0, 1946 + "collapsed sibling margin should be ~16px, got {gap}" 1947 + ); 1948 + } 1949 + 1950 + #[test] 1951 + fn sibling_margins_collapse_unequal() { 1952 + // p1 bottom-margin 20, p2 top-margin 30: gap should be 30 (max). 1953 + let html_str = r#"<!DOCTYPE html> 1954 + <html> 1955 + <head><style> 1956 + body { margin: 0; border-top: 1px solid black; } 1957 + .first { margin-top: 0; margin-bottom: 20px; } 1958 + .second { margin-top: 30px; margin-bottom: 0; } 1959 + </style></head> 1960 + <body> 1961 + <p class="first">First</p> 1962 + <p class="second">Second</p> 1963 + </body> 1964 + </html>"#; 1965 + let doc = we_html::parse_html(html_str); 1966 + let font = test_font(); 1967 + let sheets = extract_stylesheets(&doc); 1968 + let styled = resolve_styles(&doc, &sheets).unwrap(); 1969 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 1970 + 1971 + let body_box = &tree.root.children[0]; 1972 + let first = &body_box.children[0]; 1973 + let second = &body_box.children[1]; 1974 + 1975 + let first_bottom = 1976 + first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 1977 + let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 1978 + assert!( 1979 + (gap - 30.0).abs() < 1.0, 1980 + "collapsed margin should be max(20, 30) = 30, got {gap}" 1981 + ); 1982 + } 1983 + 1984 + #[test] 1985 + fn parent_first_child_margin_collapsing() { 1986 + // Parent with no padding/border: first child's top margin collapses. 1987 + let html_str = r#"<!DOCTYPE html> 1988 + <html> 1989 + <head><style> 1990 + body { margin: 0; border-top: 1px solid black; } 1991 + .parent { margin-top: 10px; } 1992 + .child { margin-top: 20px; } 1993 + </style></head> 1994 + <body> 1995 + <div class="parent"><p class="child">Child</p></div> 1996 + </body> 1997 + </html>"#; 1998 + let doc = we_html::parse_html(html_str); 1999 + let font = test_font(); 2000 + let sheets = extract_stylesheets(&doc); 2001 + let styled = resolve_styles(&doc, &sheets).unwrap(); 2002 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2003 + 2004 + let body_box = &tree.root.children[0]; 2005 + let parent_box = &body_box.children[0]; 2006 + 2007 + // Parent margin collapses with child's: max(10, 20) = 20. 2008 + assert_eq!(parent_box.margin.top, 20.0); 2009 + } 2010 + 2011 + #[test] 2012 + fn negative_margin_collapsing() { 2013 + // One positive (20) and one negative (-10): collapsed = 20 + (-10) = 10. 2014 + let html_str = r#"<!DOCTYPE html> 2015 + <html> 2016 + <head><style> 2017 + body { margin: 0; border-top: 1px solid black; } 2018 + .first { margin-top: 0; margin-bottom: 20px; } 2019 + .second { margin-top: -10px; margin-bottom: 0; } 2020 + </style></head> 2021 + <body> 2022 + <p class="first">First</p> 2023 + <p class="second">Second</p> 2024 + </body> 2025 + </html>"#; 2026 + let doc = we_html::parse_html(html_str); 2027 + let font = test_font(); 2028 + let sheets = extract_stylesheets(&doc); 2029 + let styled = resolve_styles(&doc, &sheets).unwrap(); 2030 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2031 + 2032 + let body_box = &tree.root.children[0]; 2033 + let first = &body_box.children[0]; 2034 + let second = &body_box.children[1]; 2035 + 2036 + let first_bottom = 2037 + first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2038 + let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2039 + // 20 + (-10) = 10 2040 + assert!( 2041 + (gap - 10.0).abs() < 1.0, 2042 + "positive + negative margin collapse should be 10, got {gap}" 2043 + ); 2044 + } 2045 + 2046 + #[test] 2047 + fn both_negative_margins_collapse() { 2048 + // Both negative: use the more negative value. 2049 + let html_str = r#"<!DOCTYPE html> 2050 + <html> 2051 + <head><style> 2052 + body { margin: 0; border-top: 1px solid black; } 2053 + .first { margin-top: 0; margin-bottom: -10px; } 2054 + .second { margin-top: -20px; margin-bottom: 0; } 2055 + </style></head> 2056 + <body> 2057 + <p class="first">First</p> 2058 + <p class="second">Second</p> 2059 + </body> 2060 + </html>"#; 2061 + let doc = we_html::parse_html(html_str); 2062 + let font = test_font(); 2063 + let sheets = extract_stylesheets(&doc); 2064 + let styled = resolve_styles(&doc, &sheets).unwrap(); 2065 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2066 + 2067 + let body_box = &tree.root.children[0]; 2068 + let first = &body_box.children[0]; 2069 + let second = &body_box.children[1]; 2070 + 2071 + let first_bottom = 2072 + first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2073 + let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2074 + // Both negative: min(-10, -20) = -20 2075 + assert!( 2076 + (gap - (-20.0)).abs() < 1.0, 2077 + "both-negative margin collapse should be -20, got {gap}" 2078 + ); 2079 + } 2080 + 2081 + #[test] 2082 + fn border_blocks_margin_collapsing() { 2083 + // When border separates margins, they don't collapse. 2084 + let html_str = r#"<!DOCTYPE html> 2085 + <html> 2086 + <head><style> 2087 + body { margin: 0; border-top: 1px solid black; } 2088 + .first { margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid black; } 2089 + .second { margin-top: 20px; border-top: 1px solid black; } 2090 + </style></head> 2091 + <body> 2092 + <p class="first">First</p> 2093 + <p class="second">Second</p> 2094 + </body> 2095 + </html>"#; 2096 + let doc = we_html::parse_html(html_str); 2097 + let font = test_font(); 2098 + let sheets = extract_stylesheets(&doc); 2099 + let styled = resolve_styles(&doc, &sheets).unwrap(); 2100 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2101 + 2102 + let body_box = &tree.root.children[0]; 2103 + let first = &body_box.children[0]; 2104 + let second = &body_box.children[1]; 2105 + 2106 + // Borders are on the elements themselves, but the MARGINS are still 2107 + // between the border boxes — sibling margins still collapse regardless 2108 + // of borders on the elements. The margin gap = max(20, 20) = 20. 2109 + let first_bottom = 2110 + first.rect.y + first.rect.height + first.padding.bottom + first.border.bottom; 2111 + let gap = second.rect.y - second.border.top - second.padding.top - first_bottom; 2112 + assert!( 2113 + (gap - 20.0).abs() < 1.0, 2114 + "sibling margins collapse even with borders on elements, gap should be 20, got {gap}" 2115 + ); 2116 + } 2117 + 2118 + #[test] 2119 + fn padding_blocks_parent_child_collapsing() { 2120 + // Parent with padding-top prevents margin collapsing with first child. 2121 + let html_str = r#"<!DOCTYPE html> 2122 + <html> 2123 + <head><style> 2124 + body { margin: 0; border-top: 1px solid black; } 2125 + .parent { margin-top: 10px; padding-top: 5px; } 2126 + .child { margin-top: 20px; } 2127 + </style></head> 2128 + <body> 2129 + <div class="parent"><p class="child">Child</p></div> 2130 + </body> 2131 + </html>"#; 2132 + let doc = we_html::parse_html(html_str); 2133 + let font = test_font(); 2134 + let sheets = extract_stylesheets(&doc); 2135 + let styled = resolve_styles(&doc, &sheets).unwrap(); 2136 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2137 + 2138 + let body_box = &tree.root.children[0]; 2139 + let parent_box = &body_box.children[0]; 2140 + 2141 + // Parent has padding-top, so no collapsing: margin stays at 10. 2142 + assert_eq!(parent_box.margin.top, 10.0); 2143 + } 2144 + 2145 + #[test] 2146 + fn empty_block_margins_collapse() { 2147 + // An empty div's top and bottom margins collapse with adjacent margins. 2148 + let html_str = r#"<!DOCTYPE html> 2149 + <html> 2150 + <head><style> 2151 + body { margin: 0; border-top: 1px solid black; } 2152 + .spacer { margin-top: 10px; margin-bottom: 10px; } 2153 + p { margin-top: 5px; margin-bottom: 5px; } 2154 + </style></head> 2155 + <body> 2156 + <p>Before</p> 2157 + <div class="spacer"></div> 2158 + <p>After</p> 2159 + </body> 2160 + </html>"#; 2161 + let doc = we_html::parse_html(html_str); 2162 + let font = test_font(); 2163 + let sheets = extract_stylesheets(&doc); 2164 + let styled = resolve_styles(&doc, &sheets).unwrap(); 2165 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 2166 + 2167 + let body_box = &tree.root.children[0]; 2168 + let before = &body_box.children[0]; 2169 + let after = &body_box.children[2]; // [0]=p, [1]=empty div, [2]=p 2170 + 2171 + // Empty div's margins (10+10) self-collapse to max(10,10)=10. 2172 + // Then collapse with before's bottom (5) and after's top (5): 2173 + // collapse(5, collapse(10, 10)) = collapse(5, 10) = 10 2174 + // Then collapse(10, 5) = 10. 2175 + // So total gap between before and after = 10. 2176 + let before_bottom = 2177 + before.rect.y + before.rect.height + before.padding.bottom + before.border.bottom; 2178 + let gap = after.rect.y - after.border.top - after.padding.top - before_bottom; 2179 + assert!( 2180 + (gap - 10.0).abs() < 1.0, 2181 + "empty block margin collapse gap should be ~10px, got {gap}" 2182 + ); 2183 + } 2184 + 2185 + #[test] 2186 + fn collapse_margins_unit() { 2187 + // Unit tests for the collapse_margins helper. 2188 + assert_eq!(collapse_margins(10.0, 20.0), 20.0); 2189 + assert_eq!(collapse_margins(20.0, 10.0), 20.0); 2190 + assert_eq!(collapse_margins(0.0, 15.0), 15.0); 2191 + assert_eq!(collapse_margins(-5.0, -10.0), -10.0); 2192 + assert_eq!(collapse_margins(20.0, -5.0), 15.0); 2193 + assert_eq!(collapse_margins(-5.0, 20.0), 15.0); 2194 + assert_eq!(collapse_margins(0.0, 0.0), 0.0); 1711 2195 } 1712 2196 }