1
//! Final positioning of layout nodes (relative, absolute, and fixed schemes)
2
// +spec:positioning:79d47e - Implements relative, absolute, and fixed positioning schemes
3

            
4
use crate::solver3::layout_tree::LayoutNodeId;
5
use crate::debug_log;
6
use std::collections::BTreeMap;
7

            
8
use azul_core::{
9
    dom::{NodeId, NodeType},
10
    geom::{LogicalPosition, LogicalRect, LogicalSize},
11
    hit_test::ScrollPosition,
12
    resources::RendererResources,
13
    styled_dom::StyledDom,
14
};
15
use azul_css::{
16
    corety::LayoutDebugMessage,
17
    css::CssPropertyValue,
18
    props::{
19
        basic::pixel::PixelValue,
20
        layout::{LayoutPosition, LayoutWritingMode},
21
        property::{CssProperty, CssPropertyType},
22
    },
23
};
24

            
25
use crate::{
26
    font_traits::{FontLoaderTrait, ParsedFontTrait, TextLayoutCache},
27
    solver3::{
28
        fc::{layout_formatting_context, FloatingContext, LayoutConstraints, TextAlign},
29
        getters::{
30
            get_aspect_ratio_property, get_direction_property, get_display_property, get_writing_mode, get_position, MultiValue,
31
            get_css_top, get_css_bottom, get_css_left, get_css_right,
32
            get_css_height, get_css_width,
33
        },
34
        layout_tree::LayoutTree,
35
        LayoutContext, LayoutError, Result,
36
    },
37
};
38

            
39
#[derive(Debug, Default)]
40
pub(crate) struct PositionOffsets {
41
    pub(crate) top: Option<f32>,
42
    pub(crate) right: Option<f32>,
43
    pub(crate) bottom: Option<f32>,
44
    pub(crate) left: Option<f32>,
45
}
46

            
47
// +spec:positioning:94ef0f - position property: static|relative|absolute|sticky|fixed, initial static, applies to all elements except table-column-group/table-column
48
/// Looks up the `position` property using the compact-cache-aware getter.
49
// +spec:positioning:ba937d - positioned elements have position != static
50
2522674
#[must_use] pub fn get_position_type(styled_dom: &StyledDom, dom_id: Option<NodeId>) -> LayoutPosition {
51
2522674
    let Some(id) = dom_id else {
52
648
        return LayoutPosition::Static;
53
    };
54
2522026
    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
55
2522026
    get_position(styled_dom, id, node_state).unwrap_or_default()
56
2522674
}
57

            
58
// +spec:positioning:bda1d5 - resolves inset properties (top/right/bottom/left) as inward offsets per CSS Position 3 §3.1
59
// +spec:positioning:bf9168 - resolves inset properties (top/right/bottom/left) to control positioned box location
60
// +spec:positioning:f8e0a1 - inset properties (top/right/bottom/left) resolved for positioned elements; auto = unconstrained
61
/// Reads and resolves `top`, `right`, `bottom`, `left` properties,
62
/// including percentages relative to the containing block's size, and em/rem units.
63
// +spec:positioning:7ec143 - top/right/bottom/left offset resolution with percentage against containing block
64
#[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
65
6768
pub(crate) fn resolve_position_offsets(
66
6768
    styled_dom: &StyledDom,
67
6768
    dom_id: Option<NodeId>,
68
6768
    cb_size: LogicalSize,
69
6768
    viewport_size: LogicalSize,
70
6768
) -> PositionOffsets {
71
    use azul_css::props::basic::pixel::{PhysicalSize, PropertyContext, ResolutionContext};
72

            
73
    use crate::solver3::getters::{
74
        get_element_font_size, get_parent_font_size, get_root_font_size,
75
    };
76

            
77
6768
    let Some(id) = dom_id else {
78
1
        return PositionOffsets::default();
79
    };
80
6767
    let node_state = &styled_dom.styled_nodes.as_container()[id].styled_node_state;
81

            
82
    // Create resolution context with font sizes and containing block size
83
6767
    let element_font_size = get_element_font_size(styled_dom, id, node_state);
84
6767
    let parent_font_size = get_parent_font_size(styled_dom, id, node_state);
85
6767
    let root_font_size = get_root_font_size(styled_dom, node_state);
86

            
87
6767
    let containing_block_size = PhysicalSize::new(cb_size.width, cb_size.height);
88

            
89
6767
    let resolution_context = ResolutionContext {
90
6767
        vertical_writing_mode: false,
91
6767
        element_font_size,
92
6767
        parent_font_size,
93
6767
        root_font_size,
94
6767
        containing_block_size,
95
6767
        element_size: None, // Not needed for position offsets
96
6767
        viewport_size: PhysicalSize::new(viewport_size.width, viewport_size.height),
97
6767
    };
98

            
99
6767
    let mut offsets = PositionOffsets::default();
100

            
101
    // +spec:containing-block:d4b3b9 - percentage offsets resolve against CB width (left/right) or height (top/bottom)
102
    // Resolve offsets using compact-cache-aware getters
103
    // top/bottom use Height context (% refers to containing block height)
104
6767
    offsets.top = match get_css_top(styled_dom, id, node_state) {
105
5363
        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Height)),
106
1404
        _ => None,
107
    };
108

            
109
6767
    offsets.bottom = match get_css_bottom(styled_dom, id, node_state) {
110
181
        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Height)),
111
6586
        _ => None,
112
    };
113

            
114
    // left/right use Width context (% refers to containing block width)
115
6767
    offsets.left = match get_css_left(styled_dom, id, node_state) {
116
5342
        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Width)),
117
1425
        _ => None,
118
    };
119

            
120
6767
    offsets.right = match get_css_right(styled_dom, id, node_state) {
121
190
        MultiValue::Exact(pv) => Some(pv.resolve_with_context(&resolution_context, PropertyContext::Width)),
122
6577
        _ => None,
123
    };
124

            
125
6767
    offsets
126
6768
}
127

            
128
// +spec:block-formatting-context:f5f992 - Out-of-flow: floated or absolutely positioned boxes laid out outside normal flow
129
// +spec:positioning:bb19f8 - absolute/fixed positioning: out-of-flow, positioned relative to containing block/viewport
130
/// After the main layout pass, this function iterates through the tree and correctly
131
/// calculates the final positions of out-of-flow elements (`absolute`, `fixed`).
132
// +spec:positioning:5bfef3 - abspos elements use static position for auto offsets, resolve against nearest positioned ancestor CB
133
// +spec:positioning:7fff75 - Absolute positioning: removed from flow, offset relative to containing block, establishes new CB
134
// +spec:positioning:839cbb - absolute elements positioned/sized solely relative to their containing block, modified by inset properties
135
// +spec:positioning:898590 - absolute positioning takes elements out of flow and positions them relative to containing block
136
// +spec:positioning:c37c1b - abspos boxes laid out in containing block after its final size is determined
137
// +spec:positioning:cbe481 - absolute positioning removes elements from flow and positions them relative to containing block
138
// +spec:positioning:ebff77 - absolute positioning layout model (replaces old §6 abspos model)
139
// +spec:positioning:3b3ba4 - Absolute positioning: box offset from containing block, removed from normal flow; fixed positioning: CB = viewport
140
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
141
/// # Panics
142
///
143
/// Panics if a resolved offset (`top`/`bottom`) is None where both edges are expected.
144
5002
pub fn position_out_of_flow_elements<T: ParsedFontTrait>(
145
5002
    ctx: &mut LayoutContext<'_, T>,
146
5002
    tree: &mut LayoutTree,
147
5002
    text_cache: &mut TextLayoutCache,
148
5002
    calculated_positions: &mut super::PositionVec,
149
5002
    viewport: LogicalRect,
150
5002
) {
151
    use azul_css::props::style::StyleDirection;
152
    // Returns `()` (not Result<()>): inner fallible calls use skip-on-err (see above), so this fn
153
    // never propagates Err. Avoids the lift-fragile Result<(),LayoutError> Ok-niche read.
154
194509
    for node_index in 0..tree.nodes.len() {
155
194509
        let node = &tree.nodes[node_index];
156
194509
        let Some(dom_id) = node.dom_node_id else {
157
122
            continue;
158
        };
159

            
160
194387
        let position_type = get_position_type(ctx.styled_dom, Some(dom_id));
161

            
162
        // +spec:positioning:1d87f6 - Fixed/absolute positioning schemes with box offset resolution (top/right/bottom/left)
163
        // +spec:positioning:8bde1d - absolute: out of flow, positioned by containing block
164
        // +spec:positioning:c11be9 - absolute positioning: effect of box offsets depends on which properties are auto (non-replaced) or intrinsic dimensions (replaced)
165
        // +spec:positioning:9020aa - "absolutely positioned" means position:absolute or position:fixed
166
194387
        if position_type == LayoutPosition::Absolute || position_type == LayoutPosition::Fixed {
167
            // is a grid container have their CB determined by grid-placement properties;
168
            // Taffy already handles this during grid layout, so skip re-positioning here.
169
            // Same applies to flex containers (Flexbox §4.1).
170
            {
171
                use azul_core::dom::FormattingContext;
172
2732
                let parent_is_flex_or_grid = node.parent.and_then(|p| tree.get(LayoutNodeId::new(p))).is_some_and(|pn| {
173
2669
                    matches!(pn.formatting_context, FormattingContext::Flex | FormattingContext::Grid)
174
2669
                });
175
2732
                if parent_is_flex_or_grid {
176
2
                    continue;
177
2730
                }
178
            }
179

            
180
            // Get parent info before any mutable borrows
181
2730
            let parent_info: Option<(usize, LogicalPosition, f32, f32, f32, f32)> = {
182
2730
                let node = &tree.nodes[node_index];
183
2730
                node.parent.and_then(|parent_idx| {
184
2667
                    let parent_node = tree.get(LayoutNodeId::new(parent_idx))?;
185
2667
                    let parent_dom_id = parent_node.dom_node_id?;
186
2667
                    let parent_position = get_position_type(ctx.styled_dom, Some(parent_dom_id));
187
2667
                    if parent_position == LayoutPosition::Absolute
188
147
                        || parent_position == LayoutPosition::Fixed
189
                    {
190
2520
                        calculated_positions.get(parent_idx).map(|parent_pos| {
191
2520
                            let pbp = parent_node.box_props.unpack();
192
2520
                            (
193
2520
                                parent_idx,
194
2520
                                *parent_pos,
195
2520
                                pbp.border.left,
196
2520
                                pbp.border.top,
197
2520
                                pbp.padding.left,
198
2520
                                pbp.padding.top,
199
2520
                            )
200
2520
                        })
201
                    } else {
202
147
                        None
203
                    }
204
2667
                })
205
            };
206

            
207
            // +spec:containing-block:17a946 - fixed boxes use viewport as containing block
208
            // +spec:containing-block:83a32a - fixed positioning: containing block is viewport; absolute: nearest positioned ancestor or initial CB
209
            // +spec:containing-block:9b617d - fixed elements use viewport (initial fixed containing block)
210
            // +spec:containing-block:899e47 - fixed elements use viewport (initial fixed containing block)
211
            // +spec:containing-block:faa9a3 - fixed positioning falls back to initial containing block (viewport) when no ancestor establishes one
212
            // +spec:containing-block:faa9a3 - fixed positioning CB falls back to initial containing block (viewport) when no ancestor establishes one
213
            // +spec:positioning:067eab - CB for fixed = viewport, for absolute = nearest positioned ancestor
214
            // +spec:positioning:067eab - fixed CB is viewport; absolute CB is nearest positioned ancestor's padding-box
215
            // +spec:positioning:9777da - fixed positioning uses viewport as containing block
216
            // +spec:positioning:9777da - Fixed positioning uses viewport as containing block
217
            // +spec:positioning:9ccf9a - fixed-position CB is viewport (transform/will-change/contain could override, not yet implemented)
218
            // +spec:positioning:a68970 - fixed positioning uses viewport as containing block
219
            // +spec:positioning:8fff44 - fixed: same as absolute but positioned relative to viewport
220
            // +spec:positioning:744713 - fixed position uses viewport as containing block
221
            // +spec:positioning:f0ad47 - fixed elements use viewport as containing block; content outside viewport cannot be scrolled to
222
            // +spec:containing-block:df8387 - fixed positioning: containing block is the viewport
223
2730
            let containing_block_rect = if position_type == LayoutPosition::Fixed {
224
3
                viewport
225
            } else {
226
                // skip-on-err (was `?`): a CB-resolution failure for one out-of-flow node skips
227
                // that node rather than aborting the whole layout. Lets this fn return `()`
228
                // (no Result<(),LayoutError> Ok-niche read, which the remill→wasm lift mis-lowers).
229
2727
                match find_absolute_containing_block_rect(
230
2727
                    tree,
231
2727
                    node_index,
232
2727
                    ctx.styled_dom,
233
2727
                    calculated_positions,
234
2727
                    viewport,
235
2727
                ) {
236
2727
                    Ok(r) => r,
237
                    Err(_) => continue,
238
                }
239
            };
240

            
241
            // Get node again after containing block calculation
242
2730
            let node = &tree.nodes[node_index];
243

            
244
            // Calculate used size for out-of-flow elements. ALWAYS solve from
245
            // the containing block + own CSS + intrinsics: abs elements are
246
            // skipped by the in-flow pass, so a pre-existing `used_size` here
247
            // is junk from that skip (typically Some(0x0)) — trusting it made
248
            // an auto-sized abs box (`top/left` only) keep 0x0 forever and
249
            // its children disappear. §10.3.7: their used size never depends
250
            // on in-flow layout, so recomputing is deterministic.
251
2730
            let element_size = {
252
2730
                let intrinsic = tree.warm(LayoutNodeId::new(node_index)).and_then(|w| w.intrinsic_sizes).unwrap_or_default();
253
2730
                let Ok(size) = crate::solver3::sizing::calculate_used_size_for_node(
254
2730
                    ctx.styled_dom,
255
2730
                    Some(dom_id),
256
2730
                    &containing_block_rect.size,
257
2730
                    intrinsic,
258
2730
                    &node.box_props.unpack(),
259
2730
                    &ctx.viewport_size,
260
2730
                ) else {
261
                    continue;
262
                };
263

            
264
                // Store the calculated size in the tree node
265
2730
                if let Some(node_mut) = tree.get_mut(LayoutNodeId::new(node_index)) {
266
2730
                    node_mut.used_size = Some(size);
267
2730
                }
268

            
269
2730
                size
270
            };
271

            
272
            // +spec:positioning:dc23fa - sizing/positioning into inset-modified containing block (§4)
273
            // +spec:positioning:623e45 - inset properties reduce the containing block into the inset-modified containing block
274
            // Resolve offsets using the now-known containing block size.
275
2730
            let offsets =
276
2730
                resolve_position_offsets(ctx.styled_dom, Some(dom_id), containing_block_rect.size, viewport.size);
277

            
278
            // +spec:box-model:ae3899 - static position is the margin-edge position from normal flow
279
            // +spec:positioning:9a90a3 - static position: the position the element would have had in normal flow
280
            // +spec:positioning:ca3e89 - static-position rectangle uses block-start inline-start alignment (CSS2.1 hypothetical box)
281
2730
            let mut static_pos = calculated_positions
282
2730
                .get(node_index)
283
2730
                .copied()
284
2730
                .unwrap_or_default();
285

            
286
            // Special case: If this is a fixed-position element and it has a positioned
287
            // parent, update static_pos to be relative to the parent's final absolute
288
            // position (content-box). The initial static_pos from process_out_of_flow_children
289
            // may include border/padding offsets, so we must always recalculate here.
290
2730
            if position_type == LayoutPosition::Fixed {
291
                if let Some((_, parent_pos, border_left, border_top, padding_left, padding_top)) =
292
3
                    parent_info
293
                {
294
                    // Add parent's border and padding to get content-box position
295
                    static_pos = LogicalPosition::new(
296
                        parent_pos.x + border_left + padding_left,
297
                        parent_pos.y + border_top + padding_top,
298
                    );
299
3
                }
300
2727
            }
301

            
302
2730
            let mut final_pos = LogicalPosition::zero();
303

            
304
            // +spec:box-model:ea2f43 - top + margin + border + padding + height + bottom = CB height
305
            // +spec:box-model:b4f5b3 - vertical constraint equation for abs-pos non-replaced elements
306
            // +spec:positioning:16d82c - vertical dimension constraint for abs-positioned non-replaced elements
307
            // +spec:positioning:8f474b - §10.6.4 vertical constraint for absolutely positioned non-replaced elements
308
            // +spec:positioning:50218d - absolute: top margin edge offset below containing block top edge
309
            // top + margin-top + border-top + padding-top + height + padding-bottom +
310
            // border-bottom + margin-bottom + bottom = containing block height
311
2730
            let node_state = &ctx.styled_dom.styled_nodes.as_container()[dom_id].styled_node_state;
312

            
313
            // Extract all box_props values upfront to avoid borrow conflicts with tree.get_mut()
314
2730
            let (margin_top_val, margin_bottom_val, margin_auto,
315
2730
                 margin_left_val, margin_right_val, margin_left_auto_flag, margin_right_auto_flag) = {
316
2730
                let node = &tree.nodes[node_index];
317
2730
                let nbp = node.box_props.unpack();
318
2730
                (nbp.margin.top, nbp.margin.bottom,
319
2730
                 nbp.margin_auto,
320
2730
                 nbp.margin.left, nbp.margin.right,
321
2730
                 nbp.margin_auto.left, nbp.margin_auto.right)
322
2730
            };
323
            // +spec:positioning:d730e5 - CB height is independent of the abspos element, so percentage heights always resolve
324
2730
            let cb_height = containing_block_rect.size.height;
325

            
326
2730
            let css_height = get_css_height(ctx.styled_dom, dom_id, node_state);
327
            // +spec:replaced-elements:7d8ba8 - §10.6.5: for absolutely positioned replaced
328
            // elements, height is determined first (as for inline replaced elements), so treat
329
            // it as "not auto" in the constraint equation even if CSS says auto.
330
2730
            let node_data = &ctx.styled_dom.node_data.as_container()[dom_id];
331
2730
            let is_replaced = matches!(node_data.node_type, NodeType::Image(_))
332
2730
                || node_data.is_virtual_view_node();
333
2730
            let height_is_auto = css_height.is_auto() && !is_replaced;
334
            // +spec:overflow:941a06 - resolve auto inset properties: if only one is auto, solved to zero via constraint; if both auto, use static position
335
2730
            let top_is_auto = offsets.top.is_none();
336
2730
            let bottom_is_auto = offsets.bottom.is_none();
337

            
338
            // element_size is border-box (includes border + padding + content).
339
            // The constraint equation is:
340
            //   top + margin-top + border-box-height + margin-bottom + bottom = CB height
341
            // (border-top, padding-top, content-height, padding-bottom, border-bottom
342
            //  are all inside border-box-height)
343
2730
            let mut used_height = element_size.height;
344
            // +spec:height-calculation:44939a - set auto values for margin-top/margin-bottom to 0
345
            // +spec:height-calculation:2f6e10 - if bottom is auto, replace auto margin-top/margin-bottom with 0
346
2730
            let mut used_margin_top = if margin_auto.top { 0.0 } else { margin_top_val };
347
2730
            let mut used_margin_bottom = if margin_auto.bottom { 0.0 } else { margin_bottom_val };
348

            
349
            // +spec:box-model:3a9c2a - resolving auto insets: static position fallback when insets are auto
350
            // +spec:box-model:bd442c - weaker inset resolves to align margin box with inset-modified CB edge
351
            // +spec:height-calculation:93e91c - abs non-replaced height: auto margin centering, single auto margin solve, over-constrained ignore bottom
352
            // +spec:positioning:6e7732 - §10.6.4 vertical constraint equation for abspos non-replaced elements
353
            // +spec:positioning:b63d0f - absolute positioning with top:auto uses static position (change bars example)
354
            // +spec:positioning:da8a0c - resolving auto insets: normal alignment treated as start, so auto insets resolve to static position
355
            // +spec:positioning:820b22 - 10.6.4: absolutely positioned non-replaced elements vertical constraint equation and 6 rules
356
2730
            if top_is_auto && height_is_auto && bottom_is_auto {
357
11
                // +spec:positioning:08e0ac - absolute element with top:auto uses static position (current line)
358
11
                // +spec:positioning:aab294 - both inset properties auto: resolve to static position
359
11
                // +spec:positioning:d9bb3c - hypothetical position: UA may guess static position rather than fully computing hypothetical box
360
11
                // All three auto: set top to static position, height from content, solve for bottom
361
11
                // +spec:height-calculation:51627d - auto margins to 0, top = static position, height from content (rule 3)
362
11
                // +spec:positioning:460f2f - All three auto: set top to static position, height from content, solve for bottom
363
11
                final_pos.y = static_pos.y;
364
2719
            } else if !top_is_auto && !height_is_auto && !bottom_is_auto {
365
                // +spec:overflow:fc0c9e - over-constrained abspos: auto margins minimize overflow (CSS2.1 equivalent of Box Alignment 3 safe alignment)
366
                // +spec:positioning:88f760 - auto margins of absolutely-positioned boxes (vertical)
367
                // None are auto: over-constrained case
368
                // +spec:height-calculation:03c071 - none auto: equal auto margins, solve single auto margin, or ignore bottom if over-constrained
369
10
                let top_val = offsets.top.unwrap();
370
10
                let bottom_val = offsets.bottom.unwrap();
371
10
                if margin_auto.top && margin_auto.bottom {
372
1
                    // +spec:height-calculation:5112a4 - both margin-top/bottom auto: solve with equal values
373
1
                    let available = cb_height - top_val - used_height - bottom_val;
374
1
                    let each = available / 2.0;
375
1
                    used_margin_top = each;
376
1
                    used_margin_bottom = each;
377
9
                } else if margin_auto.top {
378
                    used_margin_top = cb_height - top_val - used_height - used_margin_bottom - bottom_val;
379
9
                } else if margin_auto.bottom {
380
                    used_margin_bottom = cb_height - top_val - used_height - used_margin_top - bottom_val;
381
9
                }
382
                // else: over-constrained, ignore bottom
383
10
                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
384
2709
            } else if top_is_auto && height_is_auto && !bottom_is_auto {
385
8
                // +spec:height-calculation:909b50 - top and height auto, bottom not auto: height from BFC auto heights, solve for top
386
8
                // Rule 1: height from content, auto margins to 0, solve for top
387
8
                let bottom_val = offsets.bottom.unwrap();
388
8
                let top_val = cb_height - used_margin_top - used_height - used_margin_bottom - bottom_val;
389
8
                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
390
2701
            } else if top_is_auto && bottom_is_auto && !height_is_auto {
391
8
                // +spec:positioning:64e1ba - top+bottom auto, height not auto: set top to static position, solve for bottom
392
8
                final_pos.y = static_pos.y;
393
2693
            } else if height_is_auto && bottom_is_auto && !top_is_auto {
394
1284
                // Rule 3: height from content, auto margins to 0, solve for bottom
395
1284
                let top_val = offsets.top.unwrap();
396
1284
                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
397
1409
            } else if top_is_auto && !height_is_auto && !bottom_is_auto {
398
8
                // +spec:height-calculation:33dce8 - top auto, height and bottom not auto: solve for top
399
8
                // Rule 4: auto margins to 0, solve for top
400
8
                let bottom_val = offsets.bottom.unwrap();
401
8
                let top_val = cb_height - used_margin_top - used_height - used_margin_bottom - bottom_val;
402
8
                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
403
1401
            } else if height_is_auto && !top_is_auto && !bottom_is_auto {
404
                // +spec:intrinsic-sizing:566a43 - abspos auto height with non-auto insets: stretch-fit size
405
                // +spec:intrinsic-sizing:c7227f - except: if box has aspect-ratio, ratio-dependent axis uses max-content
406
50
                let has_aspect_ratio = matches!(
407
50
                    get_aspect_ratio_property(ctx.styled_dom, dom_id, node_state),
408
                    MultiValue::Exact(azul_css::props::style::effects::StyleAspectRatio::Ratio(_))
409
                );
410
50
                let top_val = offsets.top.unwrap();
411
50
                let bottom_val = offsets.bottom.unwrap();
412
50
                if !has_aspect_ratio {
413
50
                    // solve for height from constraint equation (stretch-fit):
414
50
                    // height = cb_height - top - margin_top - margin_bottom - bottom
415
50
                    // +spec:containing-block:b3f0dd - clamp effective CB size to zero when insets exceed it (weaker inset reduced)
416
50
                    used_height = (cb_height - top_val - used_margin_top - used_margin_bottom - bottom_val).max(0.0);
417
50
                }
418
                // else: keep content-based height (max-content) per aspect-ratio exception
419
50
                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
420
                // Update the element size with the resolved height
421
50
                if let Some(node_mut) = tree.get_mut(LayoutNodeId::new(node_index)) {
422
50
                    if let Some(ref mut size) = node_mut.used_size {
423
50
                        size.height = used_height;
424
50
                    }
425
                }
426
1351
            } else if bottom_is_auto && !top_is_auto && !height_is_auto {
427
1351
                // Rule 6: auto margins to 0, solve for bottom
428
1351
                let top_val = offsets.top.unwrap();
429
1351
                final_pos.y = containing_block_rect.origin.y + top_val + used_margin_top;
430
1351
            } else {
431
                // Fallback to static position
432
                final_pos.y = static_pos.y;
433
            }
434

            
435
            // +spec:box-model:984243 - horizontal constraint equation for abs-pos non-replaced elements
436
            // +spec:positioning:3be194 - position abs replaced element after establishing width
437
            // Constraint: left + margin-left + border-left + padding-left + width +
438
            // +spec:width-calculation:1661b4 - constraint equation and six rules for abs-pos horizontal (§10.3.7)
439
            // left + margin-left + border-left + padding-left + width +
440
            //   padding-right + border-right + margin-right + right = CB width
441
            // Since element_size.width is border-box (border + padding + content),
442
            // simplifies to: left + margin-left + border_box_width + margin-right + right = CB width
443
            {
444
2730
                let margin_left = margin_left_val;
445
2730
                let margin_right = margin_right_val;
446
2730
                let margin_left_auto = margin_left_auto_flag;
447
2730
                let margin_right_auto = margin_right_auto_flag;
448
2730
                let cb_width = containing_block_rect.size.width;
449
2730
                let border_box_width = element_size.width;
450
2730
                let left_val = offsets.left;
451
2730
                let right_val = offsets.right;
452
2730
                let left_is_auto = left_val.is_none();
453
2730
                let right_is_auto = right_val.is_none();
454

            
455
                // Get direction of containing block for over-constrained resolution
456
2730
                let cb_direction = {
457
2730
                    let cb_dom_id = if position_type == LayoutPosition::Fixed {
458
3
                        None // viewport CB, default LTR
459
                    } else {
460
2727
                        let mut parent = tree.nodes[node_index].parent;
461
2727
                        let mut found = None;
462
2732
                        while let Some(pidx) = parent {
463
2667
                            if let Some(pnode) = tree.get(LayoutNodeId::new(pidx)) {
464
2667
                                if get_position_type(ctx.styled_dom, pnode.dom_node_id).is_positioned() {
465
2662
                                    found = pnode.dom_node_id;
466
2662
                                    break;
467
5
                                }
468
5
                                parent = pnode.parent;
469
                            } else {
470
                                break;
471
                            }
472
                        }
473
2727
                        found
474
                    };
475
2730
                    match cb_dom_id {
476
2662
                        Some(cb_id) => {
477
2662
                            let cb_ns = &ctx.styled_dom.styled_nodes.as_container()[cb_id].styled_node_state;
478
2662
                            match get_direction_property(ctx.styled_dom, cb_id, cb_ns) {
479
2662
                                MultiValue::Exact(v) => v,
480
                                _ => StyleDirection::Ltr,
481
                            }
482
                        }
483
68
                        None => StyleDirection::Ltr,
484
                    }
485
                };
486

            
487
                // +spec:replaced-elements:7d8ba8 - §10.3.8: for absolutely positioned replaced elements, width is determined
488
                // first (as for inline replaced), so treat as "not auto" in the constraint.
489
2730
                let width_is_auto = get_css_width(ctx.styled_dom, dom_id, node_state).is_auto() && !is_replaced;
490

            
491
2730
                if !left_is_auto && !width_is_auto && !right_is_auto {
492
                    // +spec:positioning:88f760 - auto margins of absolutely-positioned boxes (horizontal)
493
                    // +spec:width-calculation:942c77 - abs-pos non-replaced width: auto margins, over-constrained resolution
494
                    // None of left/width/right are auto — solve for margins or handle over-constrained
495
                    // +spec:width-calculation:dff69d - §10.3.7 abs-pos non-replaced: none auto → equal auto margins, solve single auto margin, or over-constrained
496
12
                    let left = left_val.unwrap();
497
12
                    let right = right_val.unwrap();
498
12
                    let remaining = cb_width - left - border_box_width - right;
499

            
500
                    // +spec:writing-modes:9c3b40 - abspos auto margins: if negative remaining in inline axis, start margin=0, end margin gets remainder
501
12
                    if margin_left_auto && margin_right_auto {
502
                        // +spec:positioning:ab47b3 - auto margins can be negative in absolute positioning
503
                        // Both margins auto: equal values unless negative
504
2
                        let each_margin = remaining / 2.0;
505
2
                        if each_margin < 0.0 {
506
1
                            match cb_direction {
507
1
                                StyleDirection::Ltr => {
508
1
                                    final_pos.x = containing_block_rect.origin.x + left;
509
1
                                }
510
                                StyleDirection::Rtl => {
511
                                    final_pos.x = containing_block_rect.origin.x + left + remaining;
512
                                }
513
                            }
514
1
                        } else {
515
1
                            final_pos.x = containing_block_rect.origin.x + left + each_margin;
516
1
                        }
517
10
                    } else if margin_left_auto {
518
                        let solved_margin_left = remaining - margin_right;
519
                        final_pos.x = containing_block_rect.origin.x + left + solved_margin_left;
520
10
                    } else if margin_right_auto {
521
                        final_pos.x = containing_block_rect.origin.x + left + margin_left;
522
                    } else {
523
                        // Over-constrained: ignore right (LTR) or left (RTL)
524
10
                        match cb_direction {
525
9
                            StyleDirection::Ltr => {
526
9
                                final_pos.x = containing_block_rect.origin.x + left + margin_left;
527
9
                            }
528
1
                            StyleDirection::Rtl => {
529
1
                                let solved_left = cb_width - margin_left - border_box_width - margin_right - right;
530
1
                                final_pos.x = containing_block_rect.origin.x + solved_left + margin_left;
531
1
                            }
532
                        }
533
                    }
534
                } else {
535
                    // +spec:overflow:f323cb - auto inset: align margin box to stronger inset edge (may overflow CB)
536
                    // +spec:width-calculation:bbf97a - set auto margins to 0 for abspos when left/width/right has auto
537
                    // Set auto margins to 0, apply six rules
538
                    // +spec:box-model:2da091 - if either inset is auto, auto margins resolve to zero
539
                    // +spec:intrinsic-sizing:087b57 - abspos auto margins resolve to 0 when any inset is auto
540
                    // +spec:width-calculation:0c29ce - set auto margins to 0, then apply six rules for abs pos width
541
2718
                    let m_left = if margin_left_auto { 0.0 } else { margin_left };
542
2718
                    let m_right = if margin_right_auto { 0.0 } else { margin_right };
543

            
544
                    // +spec:width-calculation:2b2852 - all three auto: set auto margins to 0, use static position for left (LTR)
545
                    // +spec:width-calculation:c120b3 - all three of left/width/right auto: set auto margins to 0, then use direction to pick static position
546
2718
                    if left_is_auto && width_is_auto && right_is_auto {
547
11
                        match cb_direction {
548
11
                            StyleDirection::Ltr => {
549
11
                                // Set left to static position, apply rule 3 (width from content, solve for right)
550
11
                                final_pos.x = static_pos.x;
551
11
                            }
552
                            StyleDirection::Rtl => {
553
                                // Set right to static position, apply rule 1 (width from content, solve for left)
554
                                let static_offset = static_pos.x - containing_block_rect.origin.x;
555
                                let right_static = (cb_width - static_offset - border_box_width).max(0.0);
556
                                let solved_left = cb_width - m_left - border_box_width - m_right - right_static;
557
                                final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
558
                            }
559
                        }
560
2707
                    } else if left_is_auto && width_is_auto && !right_is_auto {
561
8
                        // left+width auto, right not auto: width from content, solve for left
562
8
                        let right = right_val.unwrap();
563
8
                        let solved_left = cb_width - m_left - border_box_width - m_right - right;
564
8
                        final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
565
2699
                    } else if left_is_auto && !width_is_auto && right_is_auto {
566
8
                        // left+right auto: set left to static position (LTR)
567
8
                        final_pos.x = static_pos.x;
568
2691
                    } else if !left_is_auto && width_is_auto && right_is_auto {
569
1284
                        // width+right auto: position from left
570
1284
                        let left = left_val.unwrap();
571
1284
                        final_pos.x = containing_block_rect.origin.x + left + m_left;
572
1407
                    } else if left_is_auto && !width_is_auto && !right_is_auto {
573
17
                        // left auto: solve for left
574
17
                        let right = right_val.unwrap();
575
17
                        let solved_left = cb_width - m_left - border_box_width - m_right - right;
576
17
                        final_pos.x = containing_block_rect.origin.x + solved_left + m_left;
577
1390
                    } else if !left_is_auto && width_is_auto && !right_is_auto {
578
                        // +spec:intrinsic-sizing:566a43 - abspos auto width with non-auto insets: stretch-fit size
579
                        // +spec:intrinsic-sizing:c7227f - except: if box has aspect-ratio, ratio-dependent axis uses max-content
580
48
                        let has_aspect_ratio = matches!(
581
48
                            get_aspect_ratio_property(ctx.styled_dom, dom_id, node_state),
582
                            MultiValue::Exact(azul_css::props::style::effects::StyleAspectRatio::Ratio(_))
583
                        );
584
48
                        let left = left_val.unwrap();
585
48
                        let right = right_val.unwrap();
586
48
                        if !has_aspect_ratio {
587
                            // width = cb_width - left - margin_left - margin_right - right
588
48
                            let used_width = (cb_width - left - m_left - m_right - right).max(0.0);
589
48
                            if let Some(node_mut) = tree.get_mut(LayoutNodeId::new(node_index)) {
590
48
                                if let Some(ref mut size) = node_mut.used_size {
591
48
                                    size.width = used_width;
592
48
                                }
593
                            }
594
                        }
595
                        // else: keep content-based width (max-content) per aspect-ratio exception
596
48
                        final_pos.x = containing_block_rect.origin.x + left + m_left;
597
1342
                    } else if !left_is_auto && !width_is_auto && right_is_auto {
598
1342
                        // right auto: position from left
599
1342
                        let left = left_val.unwrap();
600
1342
                        final_pos.x = containing_block_rect.origin.x + left + m_left;
601
1342
                    } else {
602
                        final_pos.x = static_pos.x;
603
                    }
604
                }
605
            }
606

            
607
2730
            super::pos_set(calculated_positions, node_index, final_pos);
608

            
609
            // The absolute box is now at its FINAL, definite size (§10.3.7 /
610
            // §10.6.4) — "abspos boxes laid out in containing block after its
611
            // final size is determined". The in-flow pass deliberately skips
612
            // abs subtrees, so THIS is the one authoritative interior layout:
613
            // against the solved box, unconditionally (before, a collapsed-
614
            // child heuristic left block interiors unlaid — children of an
615
            // abs box simply disappeared — and the flex path let the taffy
616
            // root re-derive the box's own height from content, clobbering
617
            // the §10.6.4 stretch-fit). The solved border-box size is
618
            // restored after the run precisely because a formatting-context
619
            // root may re-derive its own size; child positions are then
620
            // propagated from the final box (the interior run only writes
621
            // warm relative positions). The abs box's used size is
622
            // independent of its content, so this cannot loop.
623
2730
            let mut solved_size = tree.nodes[node_index].used_size.unwrap_or(element_size);
624
2730
            if !tree.children(node_index).is_empty() {
625
2622
                let inner = tree.nodes[node_index]
626
2622
                    .box_props
627
2622
                    .inner_size(solved_size, LayoutWritingMode::HorizontalTb);
628
2622
                let constraints = LayoutConstraints {
629
2622
                    available_size: inner,
630
2622
                    writing_mode: LayoutWritingMode::HorizontalTb,
631
2622
                    writing_mode_ctx: super::geometry::WritingModeContext::default(),
632
2622
                    bfc_state: None,
633
2622
                    text_align: TextAlign::Start,
634
2622
                    containing_block_size: inner,
635
2622
                    available_width_type:
636
2622
                        crate::text3::cache::AvailableSpace::Definite(inner.width),
637
2622
                    fragmentainer: None,
638
2622
                };
639
2622
                let mut reflow_float_cache: std::collections::HashMap<usize, FloatingContext> =
640
2622
                    std::collections::HashMap::new();
641
2622
                let interior = layout_formatting_context(
642
2622
                    ctx,
643
2622
                    tree,
644
2622
                    text_cache,
645
2622
                    node_index,
646
2622
                    &constraints,
647
2622
                    &mut reflow_float_cache,
648
                );
649

            
650
                // §10.6.4 rules 1 and 3: with height:auto and at most one
651
                // vertical inset, the used height IS the laid-out content
652
                // height — adopt it from the interior run (the pre-layout
653
                // value was the 0 placeholder). Rule 1 (bottom set, top
654
                // auto) then re-derives top with the REAL height.
655
2622
                if height_is_auto && (top_is_auto || bottom_is_auto) {
656
1269
                    if let Ok(res) = &interior {
657
1269
                        let bp = tree.nodes[node_index].box_props.unpack();
658
1269
                        solved_size.height = res.output.overflow_size.height
659
1269
                            + bp.padding.top
660
1269
                            + bp.padding.bottom
661
1269
                            + bp.border.top
662
1269
                            + bp.border.bottom;
663
1269
                        if top_is_auto && !bottom_is_auto {
664
                            let bottom_val = offsets.bottom.unwrap_or(0.0);
665
                            let top_val = cb_height
666
                                - used_margin_top
667
                                - solved_size.height
668
                                - used_margin_bottom
669
                                - bottom_val;
670
                            final_pos.y =
671
                                containing_block_rect.origin.y + top_val + used_margin_top;
672
                            super::pos_set(calculated_positions, node_index, final_pos);
673
1269
                        }
674
                    }
675
1353
                }
676

            
677
2622
                if let Some(node_mut) = tree.get_mut(LayoutNodeId::new(node_index)) {
678
2622
                    node_mut.used_size = Some(solved_size);
679
2622
                }
680
2622
                let bp = tree.nodes[node_index].box_props.unpack();
681
2622
                let content_pos = LogicalPosition::new(
682
2622
                    final_pos.x + bp.border.left + bp.padding.left,
683
2622
                    final_pos.y + bp.border.top + bp.padding.top,
684
                );
685
2622
                crate::solver3::cache::position_bfc_child_descendants(
686
2622
                    tree,
687
2622
                    node_index,
688
2622
                    content_pos,
689
2622
                    calculated_positions,
690
                );
691
108
            }
692
191655
        }
693
    }
694
5002
}
695

            
696
// +spec:positioning:5b0d7f - relative positioning: offset from normal flow position, siblings unaffected
697
// +spec:positioning:8afbe2 - Relative positioning preserves normal flow size and space; only visual offset applied after layout
698
// +spec:positioning:3502d5 - relative and absolute positioning supported for combined use
699
// +spec:positioning:b22222 - relative positioning: offset from static position, purely visual effect
700
// +spec:positioning:b814b6 - relative/absolute/fixed positioning scheme (CSS Positioned Layout Module Level 3)
701
/// Final pass to shift relatively positioned elements from their static flow position.
702
// +spec:block-formatting-context:60ccf9 - relative positioning shifts inline boxes as a unit after normal flow
703
// +spec:display-property:17239f - relative positioning offsets element after normal flow; abspos elements taken out of flow
704
// +spec:positioning:cbe066 - relative positioning implementation
705
///
706
/// Resolves percentage-based offsets for `top`, `left`, etc.
707
/// For relatively positioned elements, percentages are
708
/// relative to the dimensions of the parent element's content box.
709
// +spec:positioning:2d8e15 - relative positioning shifts elements as a unit after normal flow without affecting surrounding content
710
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
711
4937
pub fn adjust_relative_positions<T: ParsedFontTrait>(
712
4937
    ctx: &mut LayoutContext<'_, T>,
713
4937
    tree: &LayoutTree,
714
4937
    calculated_positions: &mut super::PositionVec,
715
4937
    viewport: LogicalRect, // The viewport is needed if the root element is relative.
716
4937
) {
717
    use azul_css::props::style::StyleDirection;
718
    // NOTE: returns `()` (not `Result<()>`). This fn is Ok-always — its only `?` are on `Option`
719
    // inside `.and_then` closures, never propagating to the fn body. The previous `Result<(),
720
    // LayoutError>` return forced the `?` at the call site to read an Ok-niche discriminant, which
721
    // the remill→wasm lift mis-lowers (per-build, ASLR-dependent) → a FALSE Err that aborted the
722
    // whole layout in the web backend (rect=0). Matches sibling reposition_* fns that return ().
723
    // Iterate through all nodes. We need the index to modify the position map.
724
194380
    for node_index in 0..tree.nodes.len() {
725
194380
        let node = &tree.nodes[node_index];
726
194380
        let position_type = get_position_type(ctx.styled_dom, node.dom_node_id);
727

            
728
        // +spec:block-formatting-context:faa1cf - static boxes: top/right/bottom/left do not apply
729
        // Early continue for non-relative positioning
730
        // +spec:overflow:cfb09a - Sticky positioning uses relative-like offsets, clamped to nearest scrollport at scroll time
731
194380
        if position_type != LayoutPosition::Relative && position_type != LayoutPosition::Sticky {
732
193038
            continue;
733
1342
        }
734

            
735
        // +spec:table-layout:6cb73b - position:relative effect on table elements is undefined; skip them
736
        // +spec:table-layout:718f91 - relative positioning on table-row/row-group shifts all contents
737
        {
738
            use azul_css::props::layout::LayoutDisplay;
739
1342
            let display = get_display_property(ctx.styled_dom, node.dom_node_id);
740
1342
            if let MultiValue::Exact(d) = display {
741
                // +spec:positioning:4614dd - position does not apply to table-column-group or table-column boxes
742
                // Table-row and row-group elements DO support relative positioning:
743
                // the shift affects all contents including cells originating in the row.
744
                // Table-column, table-column-group, table-cell, and table-caption do not.
745
1339
                if matches!(
746
1342
                    d,
747
                    LayoutDisplay::TableColumnGroup
748
                        | LayoutDisplay::TableColumn
749
                        | LayoutDisplay::TableCell
750
                        | LayoutDisplay::TableCaption
751
                ) {
752
3
                    continue;
753
1339
                }
754
            }
755
        }
756

            
757
        // Determine the containing block size for resolving percentages.
758
        // For `position: relative`, this is the parent's content box size.
759
1339
        let containing_block_size = node.parent
760
1339
            .and_then(|parent_idx| tree.get(LayoutNodeId::new(parent_idx)))
761
1339
            .map_or(viewport.size, |parent_node| {
762
                // Get parent's writing mode to correctly calculate its inner (content) size.
763
1339
                let parent_wm = parent_node.dom_node_id
764
1339
                    .map(|pid| {
765
1339
                        let ps = &ctx.styled_dom.styled_nodes.as_container()[pid].styled_node_state;
766
1339
                        get_writing_mode(ctx.styled_dom, pid, ps).unwrap_or_default()
767
1339
                    })
768
1339
                    .unwrap_or_default();
769
1339
                let parent_used_size = parent_node.used_size.unwrap_or_default();
770
1339
                parent_node.box_props.inner_size(parent_used_size, parent_wm)
771
1339
            });
772

            
773
        // +spec:positioning:418c74 - inset percentages resolve against containing block size per axis; auto is unconstrained
774
1339
        let offsets =
775
1339
            resolve_position_offsets(ctx.styled_dom, node.dom_node_id, containing_block_size, viewport.size);
776

            
777
        // Get a mutable reference to the position and apply the offsets.
778
1339
        let Some(current_pos) = calculated_positions.get_mut(node_index) else {
779
1
            continue;
780
        };
781

            
782
1338
        let initial_pos = *current_pos;
783

            
784
        // +spec:positioning:5eb813 - relative positioning offsets contents from normal flow position
785
        // +spec:positioning:a2e5f1 - relative positioning shifts element from static position (vs absolute/float)
786
        // top/bottom/left/right offsets are applied relative to the static position.
787
1338
        let mut delta_x = 0.0;
788
1338
        let mut delta_y = 0.0;
789

            
790
        // +spec:positioning:218b50 - Relative positioning: top=-bottom, left=-right, direction-dependent resolution, top wins over bottom
791
        // According to CSS 2.1 Section 9.4.3:
792
        // - For `top` and `bottom`: if both are specified, `top` wins and `bottom` is ignored
793
        // - For `left` and `right`: depends on direction (ltr/rtl)
794
        //   - In LTR: if both specified, `left` wins and `right` is ignored
795
        //   - In RTL: if both specified, `right` wins and `left` is ignored
796

            
797
        // +spec:overflow:53dffd - both left/right auto → used values are 0, boxes stay in original position
798
        // +spec:positioning:5a099e - negative offsets can cause overlapping (no clamping applied)
799
        // +spec:positioning:d189de - bottom offset for relative positioning is with respect to the box's own bottom edge
800
        // +spec:positioning:d80f47 - opposing inset values are negations: top wins over bottom, left/right per direction
801
        // +spec:positioning:ecc27c - relative positioning: left/right move box horizontally without changing size, left = -right
802
        // +spec:positioning:50218d - relative: offset from static position (top edges of box itself)
803
        // both auto → 0; one auto → negative of other; neither auto → bottom ignored (top wins)
804
        // +spec:positioning:ac768b - relative positioning: both auto→0, one auto→neg of other, neither→top wins; direction-aware left/right
805
        // +spec:positioning:e3727e - top/bottom: both auto→0, one auto→negative of other, neither auto→bottom ignored
806
        // Vertical positioning: `top` takes precedence over `bottom`
807
1338
        if let Some(top) = offsets.top {
808
9
            delta_y = top;
809
1334
        } else if let Some(bottom) = offsets.bottom {
810
1
            delta_y = -bottom;
811
1328
        }
812

            
813
        // +spec:positioning:1732e8 - left/right for relatively positioned elements determined by 9.4.3 rules
814
        // Spec: "If the 'direction' property of the containing block is 'ltr', the value of 'left' wins"
815
        // Get the direction of the containing block (parent), not the element itself
816
1338
        let cb_direction = node.parent
817
1338
            .and_then(|parent_idx| tree.get(LayoutNodeId::new(parent_idx)))
818
1338
            .and_then(|parent_node| {
819
1338
                let parent_dom_id = parent_node.dom_node_id?;
820
1338
                let parent_state =
821
1338
                    &ctx.styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
822
1338
                match get_direction_property(ctx.styled_dom, parent_dom_id, parent_state) {
823
1338
                    MultiValue::Exact(v) => Some(v),
824
                    _ => None,
825
                }
826
1338
            })
827
1338
            .unwrap_or(StyleDirection::Ltr);
828
        // +spec:containing-block:6d4fb1 - over-constrained relative positioning: ltr→left wins, rtl→right wins
829
1338
        match cb_direction {
830
            StyleDirection::Ltr => {
831
1337
                if let Some(left) = offsets.left {
832
7
                    delta_x = left;
833
1332
                } else if let Some(right) = offsets.right {
834
1
                    // +spec:overflow:fb426c - left auto: used value is minus the value of right
835
1
                    delta_x = -right;
836
1329
                }
837
            }
838
            StyleDirection::Rtl => {
839
1
                if let Some(right) = offsets.right {
840
1
                    delta_x = -right;
841
1
                } else if let Some(left) = offsets.left {
842
                    delta_x = left;
843
                }
844
            }
845
        }
846

            
847
        // +spec:overflow:f1e1ce - relative positioning may cause overflow:auto/scroll boxes to need scrollbars
848
        // Only apply the shift if there is a non-zero delta.
849
1338
        if delta_x != 0.0 || delta_y != 0.0 {
850
12
            current_pos.x += delta_x;
851
12
            current_pos.y += delta_y;
852

            
853
12
            debug_log!(ctx, "Adjusted relative element #{} from {:?} to {:?} (delta: {}, {})",
854
                node_index, initial_pos, *current_pos, delta_x, delta_y);
855

            
856
            // +spec:table-layout:ec2600 - For table-row-group, table-header-group, table-footer-group, or table-row,
857
            // the relative shift affects all contents of the box including table cells.
858
            // Propagate the delta to all descendant nodes.
859
            {
860
                use azul_css::props::layout::LayoutDisplay;
861
12
                let display = get_display_property(ctx.styled_dom, node.dom_node_id);
862
12
                let is_table_row_like = matches!(
863
12
                    display,
864
                    MultiValue::Exact(
865
                        LayoutDisplay::TableRowGroup
866
                        | LayoutDisplay::TableHeaderGroup
867
                        | LayoutDisplay::TableFooterGroup
868
                        | LayoutDisplay::TableRow
869
                    )
870
                );
871
12
                if is_table_row_like {
872
                    // Shift all children (and their descendants) by the same delta
873
1
                    let mut stack = tree.children(node_index).to_vec();
874
2
                    while let Some(child_idx) = stack.pop() {
875
1
                        if let Some(child_pos) = calculated_positions.get_mut(child_idx) {
876
1
                            child_pos.x += delta_x;
877
1
                            child_pos.y += delta_y;
878
1
                        }
879
1
                        stack.extend_from_slice(tree.children(child_idx));
880
                    }
881
11
                }
882
            }
883
1326
        }
884
    }
885
4937
}
886

            
887
// +spec:overflow:bac4e5 - sticky view rectangle from inset properties relative to nearest scrollport
888

            
889
/// Finds the nearest scrollport (ancestor with overflow: scroll or auto) for a node.
890
/// Returns the content-box rect of the scrollport, or the viewport if none found.
891
27
fn find_nearest_scrollport(
892
27
    tree: &LayoutTree,
893
27
    node_index: usize,
894
27
    styled_dom: &StyledDom,
895
27
    calculated_positions: &super::PositionVec,
896
27
    viewport: LogicalRect,
897
27
) -> LogicalRect {
898
    use crate::solver3::getters::{get_overflow_x, get_overflow_y};
899
    use azul_css::props::layout::LayoutOverflow;
900

            
901
27
    let mut current_parent_idx = tree.get(LayoutNodeId::new(node_index)).and_then(|n| n.parent);
902

            
903
34
    while let Some(parent_index) = current_parent_idx {
904
28
        let Some(parent_node) = tree.get(LayoutNodeId::new(parent_index)) else {
905
            break;
906
        };
907
28
        let Some(parent_dom_id) = parent_node.dom_node_id else {
908
1
            current_parent_idx = parent_node.parent;
909
1
            continue;
910
        };
911

            
912
27
        let node_state = &styled_dom.styled_nodes.as_container()[parent_dom_id].styled_node_state;
913
27
        let ox = get_overflow_x(styled_dom, parent_dom_id, node_state);
914
27
        let oy = get_overflow_y(styled_dom, parent_dom_id, node_state);
915

            
916
27
        let is_scrollport = matches!(
917
27
            ox,
918
            MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
919
6
        ) || matches!(
920
23
            oy,
921
            MultiValue::Exact(LayoutOverflow::Scroll | LayoutOverflow::Auto)
922
        );
923

            
924
27
        if is_scrollport {
925
21
            let margin_box_pos = calculated_positions
926
21
                .get(parent_index)
927
21
                .copied()
928
21
                .unwrap_or_default();
929
21
            let border_box_size = parent_node.used_size.unwrap_or_default();
930

            
931
            // Content-box = margin-box pos + border + padding, size - border - padding
932
21
            let pbp = parent_node.box_props.unpack();
933
21
            let content_pos = LogicalPosition::new(
934
21
                margin_box_pos.x
935
21
                    + pbp.border.left
936
21
                    + pbp.padding.left,
937
21
                margin_box_pos.y
938
21
                    + pbp.border.top
939
21
                    + pbp.padding.top,
940
            );
941
21
            let content_size = LogicalSize::new(
942
21
                (border_box_size.width
943
21
                    - pbp.border.left
944
21
                    - pbp.border.right
945
21
                    - pbp.padding.left
946
21
                    - pbp.padding.right)
947
21
                    .max(0.0),
948
21
                (border_box_size.height
949
21
                    - pbp.border.top
950
21
                    - pbp.border.bottom
951
21
                    - pbp.padding.top
952
21
                    - pbp.padding.bottom)
953
21
                    .max(0.0),
954
            );
955
21
            return LogicalRect::new(content_pos, content_size);
956
6
        }
957

            
958
6
        current_parent_idx = parent_node.parent;
959
    }
960

            
961
6
    viewport
962
27
}
963

            
964
/// Find the scroll offset of the nearest scroll container ancestor.
965
/// Returns the scroll offset as a `LogicalPosition` (how far the content has scrolled).
966
///
967
/// `children_rect.origin` IS that offset (see
968
/// `ScrollManager::get_scroll_states_for_dom`), positive meaning "scrolled
969
/// down/right". `parent_rect.origin` is an ABSOLUTE window coordinate and must
970
/// NOT be subtracted from it — doing so mixed two spaces and reported a
971
/// container's own y as a scroll amount for every scroller below the top of
972
/// the window.
973
22
fn find_nearest_scroll_offset(
974
22
    tree: &LayoutTree,
975
22
    node_index: usize,
976
22
    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
977
22
) -> LogicalPosition {
978
22
    let mut parent = tree.get(LayoutNodeId::new(node_index)).and_then(|n| n.parent);
979
37
    while let Some(pidx) = parent {
980
23
        if let Some(pnode) = tree.get(LayoutNodeId::new(pidx)) {
981
23
            if let Some(dom_id) = pnode.dom_node_id {
982
22
                if let Some(scroll_pos) = scroll_offsets.get(&dom_id) {
983
8
                    return scroll_pos.children_rect.origin;
984
14
                }
985
1
            }
986
15
            parent = pnode.parent;
987
        } else {
988
            break;
989
        }
990
    }
991
14
    LogicalPosition::zero()
992
22
}
993

            
994
/// Adjusts positions of sticky-positioned elements based on scroll offset.
995
///
996
/// Sticky positioning works like relative positioning, but the element's position
997
/// is constrained by its inset properties (top/right/bottom/left) relative to the
998
/// nearest scrollport (scroll container ancestor). The margin box is further
999
/// constrained to remain within the containing block.
///
/// +spec:position-sticky:9449f1 - for sticky positioning, insets represent offsets from scrollport edge
/// +spec:position-sticky:75412d - multiple sticky boxes in same container offset independently
/// +spec:box-model:af9af8 - sticky positioning: shift element to stay within sticky view rectangle, margin box constrained to containing block
/// +spec:overflow:bac4e5 - compute sticky view rectangle, clamp end-edge insets to border box size
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4729
pub fn adjust_sticky_positions<T: ParsedFontTrait>(
4729
    ctx: &mut LayoutContext<'_, T>,
4729
    tree: &LayoutTree,
4729
    calculated_positions: &mut super::PositionVec,
4729
    scroll_offsets: &BTreeMap<NodeId, ScrollPosition>,
4729
    viewport: LogicalRect,
4729
) {
    // Returns `()` (not `Result<()>`): Ok-always (its only `?` is Option-`?` in an `.and_then`
    // closure). Avoids the lift-fragile Result<(),LayoutError> Ok-niche read at the call site.
191848
    for node_index in 0..tree.nodes.len() {
191848
        let node = &tree.nodes[node_index];
191848
        let position_type = get_position_type(ctx.styled_dom, node.dom_node_id);
191848
        if position_type != LayoutPosition::Sticky {
191834
            continue;
14
        }
14
        let Some(dom_id) = node.dom_node_id else {
            continue;
        };
        // Find the nearest scrollport for this sticky element
14
        let scrollport = find_nearest_scrollport(
14
            tree,
14
            node_index,
14
            ctx.styled_dom,
14
            calculated_positions,
14
            viewport,
        );
        // The containing block for percentage resolution is the parent's content box
14
        let containing_block = node.parent
14
            .and_then(|parent_idx| {
14
                let parent_node = tree.get(LayoutNodeId::new(parent_idx))?;
14
                let parent_pos = calculated_positions.get(parent_idx).copied().unwrap_or_default();
14
                let parent_size = parent_node.used_size.unwrap_or_default();
14
                let parent_wm = parent_node.dom_node_id
14
                    .map(|pid| {
14
                        let ps = &ctx.styled_dom.styled_nodes.as_container()[pid].styled_node_state;
14
                        get_writing_mode(ctx.styled_dom, pid, ps).unwrap_or_default()
14
                    })
14
                    .unwrap_or_default();
14
                let pbp = parent_node.box_props.unpack();
14
                let content_size = pbp.inner_size(parent_size, parent_wm);
14
                let content_origin = LogicalPosition::new(
14
                    parent_pos.x + pbp.border.left + pbp.padding.left,
14
                    parent_pos.y + pbp.border.top + pbp.padding.top,
                );
14
                Some(LogicalRect::new(content_origin, content_size))
14
            })
14
            .unwrap_or(viewport);
        // Resolve inset properties (top, right, bottom, left)
14
        let offsets = resolve_position_offsets(ctx.styled_dom, Some(dom_id), scrollport.size, viewport.size);
        // Get the scroll offset from the nearest scroll container
14
        let scroll_offset = find_nearest_scroll_offset(tree, node_index, scroll_offsets);
14
        let Some(current_pos) = calculated_positions.get_mut(node_index) else {
1
            continue;
        };
13
        let static_pos = *current_pos;
13
        let element_size = node.used_size.unwrap_or_default();
13
        let nbp = node.box_props.unpack();
13
        let margin = &nbp.margin;
13
        let mut shift_x = 0.0f32;
13
        let mut shift_y = 0.0f32;
        // For each side: if inset is not auto, clamp the border edge to stay
        // within the sticky view rectangle (scrollport inset by the specified amount).
        // The scroll offset shifts the effective scrollport position.
13
        if let Some(top_inset) = offsets.top {
9
            let sticky_edge = scrollport.origin.y + scroll_offset.y + top_inset;
9
            let border_top = current_pos.y;
9
            if border_top < sticky_edge {
7
                shift_y = shift_y.max(sticky_edge - border_top);
7
            }
4
        }
13
        if let Some(bottom_inset) = offsets.bottom {
1
            let sticky_edge = scrollport.origin.y + scroll_offset.y + scrollport.size.height - bottom_inset;
1
            let border_bottom = current_pos.y + element_size.height;
1
            if border_bottom > sticky_edge {
1
                shift_y = shift_y.min(sticky_edge - border_bottom);
1
            }
12
        }
13
        if let Some(left_inset) = offsets.left {
1
            let sticky_edge = scrollport.origin.x + scroll_offset.x + left_inset;
1
            let border_left = current_pos.x;
1
            if border_left < sticky_edge {
1
                shift_x = shift_x.max(sticky_edge - border_left);
1
            }
12
        }
13
        if let Some(right_inset) = offsets.right {
1
            let sticky_edge = scrollport.origin.x + scroll_offset.x + scrollport.size.width - right_inset;
1
            let border_right = current_pos.x + element_size.width;
1
            if border_right > sticky_edge {
1
                shift_x = shift_x.min(sticky_edge - border_right);
1
            }
12
        }
        // Constrain: the margin box must remain within the containing block
13
        if shift_y != 0.0 {
8
            let margin_box_top = current_pos.y - margin.top + shift_y;
8
            let margin_box_bottom = current_pos.y + element_size.height + margin.bottom + shift_y;
8
            if margin_box_top < containing_block.origin.y {
                shift_y += containing_block.origin.y - margin_box_top;
8
            }
8
            let cb_bottom = containing_block.origin.y + containing_block.size.height;
8
            if margin_box_bottom > cb_bottom {
2
                shift_y -= margin_box_bottom - cb_bottom;
6
            }
5
        }
13
        if shift_x != 0.0 {
2
            let margin_box_left = current_pos.x - margin.left + shift_x;
2
            let margin_box_right = current_pos.x + element_size.width + margin.right + shift_x;
2
            if margin_box_left < containing_block.origin.x {
                shift_x += containing_block.origin.x - margin_box_left;
2
            }
2
            let cb_right = containing_block.origin.x + containing_block.size.width;
2
            if margin_box_right > cb_right {
                shift_x -= margin_box_right - cb_right;
2
            }
11
        }
13
        if shift_x != 0.0 || shift_y != 0.0 {
10
            current_pos.x += shift_x;
10
            current_pos.y += shift_y;
10
            debug_log!(ctx, "Adjusted sticky element #{} from {:?} to {:?}",
                node_index, static_pos, *current_pos);
3
        }
    }
4729
}
// +spec:positioning:22f165 - absolute/fixed containing block: nearest positioned ancestor's padding-box, or initial CB
/// Helper to find the containing block for an absolutely positioned element.
/// CSS 2.1 Section 10.1: The containing block for absolutely positioned elements
/// is the padding box of the nearest positioned ancestor.
// +spec:containing-block:10af51 - absolutely positioned element's CB is nearest positioned ancestor
// +spec:positioning:2d0dbb - containing block for abspos is padding-box of nearest positioned ancestor, or initial CB
// +spec:positioning:3ac06c - abspos positioned relative to containing block ignoring fragmentation breaks
// +spec:positioning:d7e4b4 - containing block of abspos element is always definite (returns concrete LogicalRect)
// +spec:positioning:fc9dba - containing block resolution for absolutely positioned boxes
///
/// Returns a `LogicalRect` representing the padding-box of the nearest
/// positioned ancestor, or the viewport (initial containing block) if none exists.
/// This is the unified entry point used by both sizing and positioning phases.
// +spec:containing-block:18ae8e - Absolute positioning: abs-pos box establishes new CB for normal flow and abs-pos (but not fixed) descendants
// +spec:containing-block:b6cb8b - containing block for abs-pos is nearest positioned ancestor
// +spec:display-property:5a39bc - containing block for abspos is nearest positioned ancestor or initial containing block
// +spec:positioning:09a0fa - Absolute positioning: CB is padding-box of nearest positioned ancestor
// +spec:positioning:467cb1 - Containing block for abs pos = nearest positioned ancestor or initial CB
// +spec:positioning:99d0bb - containing block for absolute elements is nearest positioned ancestor
// +spec:positioning:92e099 - containing block for abs pos is nearest positioned ancestor or initial CB
// +spec:positioning:f57523 - containing block of abspos element is always definite (returns concrete LogicalRect)
// +spec:width-calculation:bf1aa6 - abspos CB is nearest positioned ancestor, else initial CB
// Containing block for absolutely positioned elements is established by
// nearest positioned ancestor (relative/absolute/fixed), or initial containing block if none.
// +spec:positioning:8f50de - relatively positioned parent serves as containing block for abspos descendants
// +spec:containing-block:6bcb0c - containing block is padding edge of nearest positioned ancestor, or initial containing block if none
// +spec:containing-block:bf17e5 - containing block for abspos is padding box of nearest positioned ancestor, or initial CB
// +spec:containing-block:d0f92d - containing block for positioned box is nearest positioned ancestor, or initial containing block
// +spec:containing-block:d7e013 - containing block for positioned box is nearest positioned ancestor or initial CB
// +spec:containing-block:05bc0d - positioning an element changes which ancestor establishes the CB for its descendants
// +spec:positioning:355ee4 - CB for abspos is padding edge of nearest positioned ancestor, or initial CB
// +spec:positioning:383794 - Containing block for abspos is nearest positioned ancestor, or initial containing block if none
// +spec:positioning:5b3e43 - Containing block for abs-pos is padding box of nearest positioned ancestor, or initial CB
// +spec:positioning:882e67 - containing block for abs pos is nearest positioned ancestor or initial CB
// +spec:positioning:292c5c - relative parent serves as containing block for absolute descendants
// +spec:positioning:00ce38 - CB for absolute is padding edge of nearest positioned ancestor
2756
pub(crate) fn find_absolute_containing_block_rect(
2756
    tree: &LayoutTree,
2756
    node_index: usize,
2756
    styled_dom: &StyledDom,
2756
    calculated_positions: &super::PositionVec,
2756
    viewport: LogicalRect,
2756
) -> Result<LogicalRect> {
    // +spec:positioning:748d87 - walk up to nearest positioned ancestor for CB
2756
    let mut current_parent_idx = tree.get(LayoutNodeId::new(node_index)).and_then(|n| n.parent);
    // +spec:positioning:aa361e - values other than static make a box positioned and establish an abspos containing block
2763
    while let Some(parent_index) = current_parent_idx {
2695
        let parent_node = tree.get(LayoutNodeId::new(parent_index)).ok_or(LayoutError::InvalidTree)?;
2694
        if get_position_type(styled_dom, parent_node.dom_node_id).is_positioned() {
            // calculated_positions stores margin-box positions
2687
            let margin_box_pos = calculated_positions
2687
                .get(parent_index)
2687
                .copied()
2687
                .unwrap_or_default();
            // used_size is the border-box size
2687
            let border_box_size = parent_node.used_size.unwrap_or_default();
            // +spec:containing-block:6bcb0c - containing block formed by padding edge of nearest positioned ancestor
            // +spec:positioning:df1921 - abs-pos percentage widths resolve against padding box of containing block
            // Calculate padding-box origin (margin-box + border)
2687
            let pbp = parent_node.box_props.unpack();
2687
            let padding_box_pos = LogicalPosition::new(
2687
                margin_box_pos.x + pbp.border.left,
2687
                margin_box_pos.y + pbp.border.top,
            );
            // Calculate padding-box size (border-box - borders)
2687
            let padding_box_size = LogicalSize::new(
2687
                (border_box_size.width
2687
                    - pbp.border.left
2687
                    - pbp.border.right)
2687
                    .max(0.0),
2687
                (border_box_size.height
2687
                    - pbp.border.top
2687
                    - pbp.border.bottom)
2687
                    .max(0.0),
            );
2687
            return Ok(LogicalRect::new(padding_box_pos, padding_box_size));
7
        }
7
        current_parent_idx = parent_node.parent;
    }
    // +spec:positioning:3d88c9 - abspos available space is always definite (viewport or positioned ancestor padding box)
    // No positioned ancestor found: fall back to initial containing block (viewport)
    // +spec:containing-block:141dcc - absolute element with no positioned ancestor uses initial containing block
    // +spec:containing-block:657f2f - containing block becomes initial containing block when no positioned ancestors
    // +spec:containing-block:7f5090 - if no ancestor establishes one, absolute positioning CB is initial containing block
    // +spec:containing-block:7f5090 - fallback to initial containing block when no positioned ancestor
    // +spec:containing-block:ad5ebc - no positioned ancestor: containing block becomes the initial containing block
    // +spec:display-property:813192 - abspos containing block falls back to initial containing block (viewport) when no positioned ancestor
68
    Ok(viewport)
2756
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
    use azul_core::dom::{Dom, FormattingContext, IdOrClass};
    use super::*;
    use crate::solver3::{
        geometry::{EdgeSizes, MarginAuto, PackedBoxProps, ResolvedBoxProps},
        layout_tree::{LayoutNodeCold, LayoutNodeHot, LayoutNodeWarm},
        pos_set, PositionVec, POSITION_UNSET,
    };
    // ==================================================================
    // Fixtures
    // ==================================================================
    fn close(a: f32, b: f32, eps: f32) -> bool {
        (a - b).abs() <= eps
    }
    fn viewport() -> LogicalRect {
        LogicalRect::new(
            LogicalPosition::new(0.0, 0.0),
            LogicalSize::new(800.0, 600.0),
        )
    }
    fn styled(dom: Dom, css_str: &str) -> StyledDom {
        let mut dom = dom;
        let (css, _warnings) = azul_css::parser2::new_from_str(css_str);
        StyledDom::create(&mut dom, css)
    }
    fn div_class(class: &str) -> Dom {
        Dom::create_div().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
    }
    fn body_class(class: &str) -> Dom {
        Dom::create_body().with_ids_and_classes(vec![IdOrClass::Class(class.into())].into())
    }
    /// Structural lookup — never hard-code `CompactDom` pre-order indices.
    fn node_by_class(sd: &StyledDom, class: &str) -> NodeId {
        let container = sd.node_data.as_container();
        for i in 0..sd.node_data.len() {
            let id = NodeId::new(i);
            let ids_and_classes = container[id].get_ids_and_classes();
            let hit = ids_and_classes
                .as_ref()
                .iter()
                .any(|ioc| matches!(ioc, IdOrClass::Class(c) if c.as_str() == class));
            if hit {
                return id;
            }
        }
        panic!("no node with class {class:?}");
    }
    fn edges(top: f32, right: f32, bottom: f32, left: f32) -> EdgeSizes {
        EdgeSizes {
            top,
            right,
            bottom,
            left,
        }
    }
    fn uniform(v: f32) -> EdgeSizes {
        edges(v, v, v, v)
    }
    fn bp(margin: EdgeSizes, padding: EdgeSizes, border: EdgeSizes) -> PackedBoxProps {
        PackedBoxProps::pack(&ResolvedBoxProps {
            margin,
            padding,
            border,
            margin_auto: MarginAuto::default(),
        })
    }
    fn bp_auto_margins(margin_auto: MarginAuto) -> PackedBoxProps {
        PackedBoxProps::pack(&ResolvedBoxProps {
            margin: uniform(0.0),
            padding: uniform(0.0),
            border: uniform(0.0),
            margin_auto,
        })
    }
    fn hot(parent: Option<usize>, dom_node_id: Option<NodeId>) -> LayoutNodeHot {
        LayoutNodeHot {
            box_props: PackedBoxProps::default(),
            dom_node_id,
            used_size: None,
            formatting_context: FormattingContext::Block {
                establishes_new_context: false,
            },
            parent,
        }
    }
    /// Hand-assembles a `LayoutTree` so the index / dangling-parent edge cases the
    /// real builder can never produce stay reachable.
    fn raw_tree(nodes: Vec<LayoutNodeHot>, child_lists: &[Vec<usize>]) -> LayoutTree {
        let n = nodes.len();
        let mut children_arena: Vec<usize> = Vec::new();
        let mut children_offsets: Vec<(u32, u32)> = Vec::with_capacity(n);
        for cl in child_lists {
            let start = u32::try_from(children_arena.len()).unwrap();
            children_arena.extend_from_slice(cl);
            children_offsets.push((start, u32::try_from(cl.len()).unwrap()));
        }
        while children_offsets.len() < n {
            children_offsets.push((0, 0));
        }
        LayoutTree {
            nodes,
            warm: vec![LayoutNodeWarm::default(); n],
            cold: vec![LayoutNodeCold::default(); n],
            root: 0,
            dom_to_layout: BTreeMap::new(),
            children_arena,
            children_offsets,
            subtree_needs_intrinsic: Vec::new(),
        }
    }
    /// `body.root > div.child`, both mirrored 1:1 into a two-node layout tree.
    fn two_level(css: &str) -> (StyledDom, LayoutTree) {
        let sd = styled(body_class("root").with_child(div_class("child")), css);
        let root = node_by_class(&sd, "root");
        let child = node_by_class(&sd, "child");
        let tree = raw_tree(
            vec![hot(None, Some(root)), hot(Some(0), Some(child))],
            &[vec![1], vec![]],
        );
        (sd, tree)
    }
    /// `body.root > div.mid > div.child`.
    fn three_level(css: &str) -> (StyledDom, LayoutTree) {
        let sd = styled(
            body_class("root").with_child(div_class("mid").with_child(div_class("child"))),
            css,
        );
        let root = node_by_class(&sd, "root");
        let mid = node_by_class(&sd, "mid");
        let child = node_by_class(&sd, "child");
        let tree = raw_tree(
            vec![
                hot(None, Some(root)),
                hot(Some(0), Some(mid)),
                hot(Some(1), Some(child)),
            ],
            &[vec![1], vec![2], vec![]],
        );
        (sd, tree)
    }
    fn positions(list: &[(f32, f32)]) -> PositionVec {
        list.iter()
            .map(|&(x, y)| LogicalPosition::new(x, y))
            .collect()
    }
    // ==================================================================
    // get_position_type (other / no-panic smoke + invariants)
    // ==================================================================
    #[test]
    fn get_position_type_none_dom_id_is_static() {
        let (sd, _tree) = two_level("");
        assert_eq!(get_position_type(&sd, None), LayoutPosition::Static);
    }
    #[test]
    fn get_position_type_unstyled_node_is_static() {
        let (sd, _tree) = two_level("");
        let child = node_by_class(&sd, "child");
        assert_eq!(get_position_type(&sd, Some(child)), LayoutPosition::Static);
    }
    #[test]
    fn get_position_type_reads_every_keyword() {
        let sd = styled(
            body_class("root")
                .with_child(div_class("st"))
                .with_child(div_class("rel"))
                .with_child(div_class("abs"))
                .with_child(div_class("fix"))
                .with_child(div_class("sticky")),
            ".st { position: static; } .rel { position: relative; } \
             .abs { position: absolute; } .fix { position: fixed; } \
             .sticky { position: sticky; }",
        );
        for (class, expected) in [
            ("st", LayoutPosition::Static),
            ("rel", LayoutPosition::Relative),
            ("abs", LayoutPosition::Absolute),
            ("fix", LayoutPosition::Fixed),
            ("sticky", LayoutPosition::Sticky),
        ] {
            let id = node_by_class(&sd, class);
            assert_eq!(get_position_type(&sd, Some(id)), expected, "class {class}");
        }
    }
    #[test]
    fn get_position_type_garbage_value_falls_back_to_static() {
        // An unparseable declaration must not leak a bogus enum — it is dropped
        // by the parser, so the cascade yields the initial value.
        let (sd, _tree) = two_level(".child { position: rubbish-42; }");
        let child = node_by_class(&sd, "child");
        assert_eq!(get_position_type(&sd, Some(child)), LayoutPosition::Static);
    }
    #[test]
    fn get_position_type_is_pure_and_stable_across_calls() {
        let (sd, _tree) = two_level(".child { position: sticky; }");
        let child = node_by_class(&sd, "child");
        let a = get_position_type(&sd, Some(child));
        let b = get_position_type(&sd, Some(child));
        assert_eq!(a, b);
        assert_eq!(a, LayoutPosition::Sticky);
        // The invariant the whole positioning pass leans on.
        assert!(a.is_positioned());
    }
    // ==================================================================
    // resolve_position_offsets (numeric)
    // ==================================================================
    #[test]
    fn resolve_position_offsets_none_dom_id_is_all_none() {
        let (sd, _tree) = two_level(".child { top: 10px; }");
        let o = resolve_position_offsets(
            &sd,
            None,
            LogicalSize::new(100.0, 100.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert!(o.top.is_none() && o.right.is_none() && o.bottom.is_none() && o.left.is_none());
    }
    #[test]
    fn resolve_position_offsets_unset_insets_are_none_not_zero() {
        // `auto` must stay distinguishable from `0px` — the entire abspos
        // constraint solver branches on it.
        let (sd, _tree) = two_level("");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(100.0, 100.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert!(o.top.is_none() && o.right.is_none() && o.bottom.is_none() && o.left.is_none());
    }
    #[test]
    fn resolve_position_offsets_zero_px_is_some_zero() {
        let (sd, _tree) = two_level(".child { top: 0px; left: 0px; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(0.0, 0.0),
        );
        assert_eq!(o.top, Some(0.0));
        assert_eq!(o.left, Some(0.0));
        assert!(o.right.is_none() && o.bottom.is_none());
    }
    #[test]
    fn resolve_position_offsets_px_values_round_trip() {
        let (sd, _tree) =
            two_level(".child { top: 11px; right: 22px; bottom: 33px; left: 44px; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(200.0, 100.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(11.0));
        assert_eq!(o.right, Some(22.0));
        assert_eq!(o.bottom, Some(33.0));
        assert_eq!(o.left, Some(44.0));
    }
    #[test]
    fn resolve_position_offsets_percent_uses_the_correct_axis() {
        // +spec:containing-block:d4b3b9 — top/bottom resolve against CB height,
        // left/right against CB width. Swapping the axes is the classic bug here.
        let (sd, _tree) =
            two_level(".child { top: 50%; bottom: 25%; left: 50%; right: 10%; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(400.0, 200.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(100.0), "50% of CB height 200");
        assert_eq!(o.bottom, Some(50.0), "25% of CB height 200");
        assert_eq!(o.left, Some(200.0), "50% of CB width 400");
        assert_eq!(o.right, Some(40.0), "10% of CB width 400");
    }
    #[test]
    fn resolve_position_offsets_percent_of_zero_containing_block_is_zero() {
        let (sd, _tree) = two_level(".child { top: 75%; left: 75%; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(0.0));
        assert_eq!(o.left, Some(0.0));
    }
    #[test]
    fn resolve_position_offsets_negative_values_stay_negative() {
        let (sd, _tree) = two_level(".child { top: -40px; left: -25%; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(400.0, 200.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(-40.0));
        assert_eq!(o.left, Some(-100.0), "-25% of CB width 400");
    }
    #[test]
    fn resolve_position_offsets_em_uses_element_font_size_rem_uses_root() {
        let sd = styled(
            body_class("root").with_child(div_class("child")),
            ".root { font-size: 10px; } .child { font-size: 20px; top: 2em; left: 3rem; }",
        );
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(400.0, 200.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(40.0), "2em of the element's own 20px font");
        assert_eq!(o.left, Some(30.0), "3rem of the 10px root font");
    }
    #[test]
    fn resolve_position_offsets_viewport_units_use_the_viewport_not_the_containing_block() {
        let (sd, _tree) = two_level(".child { top: 10vh; left: 10vw; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(50.0, 50.0), // deliberately not the viewport
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(60.0), "10vh of a 600px viewport");
        assert_eq!(o.left, Some(80.0), "10vw of an 800px viewport");
    }
    #[test]
    fn resolve_position_offsets_huge_px_bypasses_the_i16_compact_cache_intact() {
        // The compact cache encodes insets as i16 ×10 (±3276.7px) and emits a
        // sentinel outside that range. The sentinel MUST fall through to the slow
        // cascade path with the value intact — silently saturating to 3276.7px
        // (or wrapping to a negative!) would be the nasty failure here.
        let (sd, _tree) = two_level(".child { top: 100000px; left: -100000px; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(400.0, 200.0),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(100_000.0));
        assert_eq!(o.left, Some(-100_000.0));
    }
    #[test]
    fn resolve_position_offsets_around_the_i16_cache_boundary_agree_within_a_tenth_px() {
        // 3276.3px is the largest encodable value; 3276.4px trips the sentinel and
        // takes the slow path. Both paths must land on the authored value.
        let (sd, _tree) = two_level(".child { top: 3276.3px; bottom: 3276.4px; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(400.0, 200.0),
            LogicalSize::new(800.0, 600.0),
        );
        let top = o.top.expect("top is set");
        let bottom = o.bottom.expect("bottom is set");
        assert!(close(top, 3276.3, 0.1), "top was {top}");
        assert!(close(bottom, 3276.4, 0.1), "bottom was {bottom}");
    }
    #[test]
    fn resolve_position_offsets_sub_tenth_px_precision_loss_is_bounded() {
        // The i16 ×10 cache quantises to 0.1px. That is allowed — but it must not
        // drift further than that.
        let (sd, _tree) = two_level(".child { top: 10.567px; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(400.0, 200.0),
            LogicalSize::new(800.0, 600.0),
        );
        let top = o.top.expect("top is set");
        assert!(close(top, 10.567, 0.05), "top was {top}");
    }
    #[test]
    fn resolve_position_offsets_nan_containing_block_yields_nan_not_a_panic() {
        let (sd, _tree) = two_level(".child { top: 50%; left: 50%; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(f32::NAN, f32::NAN),
            LogicalSize::new(800.0, 600.0),
        );
        assert!(o.top.expect("top is set").is_nan());
        assert!(o.left.expect("left is set").is_nan());
    }
    #[test]
    fn resolve_position_offsets_infinite_containing_block_yields_infinity_not_a_panic() {
        let (sd, _tree) = two_level(".child { top: 50%; left: 50%; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(f32::INFINITY, f32::INFINITY),
            LogicalSize::new(800.0, 600.0),
        );
        assert_eq!(o.top, Some(f32::INFINITY));
        assert_eq!(o.left, Some(f32::INFINITY));
    }
    #[test]
    fn resolve_position_offsets_at_f32_max_containing_block_does_not_panic() {
        let (sd, _tree) = two_level(".child { top: 100%; left: 100%; }");
        let child = node_by_class(&sd, "child");
        let o = resolve_position_offsets(
            &sd,
            Some(child),
            LogicalSize::new(f32::MAX, f32::MAX),
            LogicalSize::new(f32::MAX, f32::MAX),
        );
        // 100% of MAX is MAX (the normalized 1.0 multiply is exact).
        assert_eq!(o.top, Some(f32::MAX));
        assert_eq!(o.left, Some(f32::MAX));
    }
    // ==================================================================
    // find_absolute_containing_block_rect (numeric)
    // ==================================================================
    #[test]
    fn find_absolute_cb_rect_root_without_parent_is_the_viewport() {
        let (sd, tree) = two_level(".root { position: relative; }");
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 0, &sd, &pos, viewport())
            .expect("root resolves to the initial CB");
        assert_eq!(got, viewport());
    }
    #[test]
    fn find_absolute_cb_rect_out_of_range_index_is_the_viewport_not_a_panic() {
        let (sd, tree) = two_level(".root { position: relative; }");
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 9_999, &sd, &pos, viewport())
            .expect("an out-of-range index falls back to the initial CB");
        assert_eq!(got, viewport());
    }
    #[test]
    fn find_absolute_cb_rect_dangling_parent_index_is_an_error_not_a_panic() {
        let (sd, mut tree) = two_level(".root { position: relative; }");
        tree.nodes[1].parent = Some(9_999); // corrupt the tree
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport());
        assert!(matches!(got, Err(LayoutError::InvalidTree)));
    }
    #[test]
    fn find_absolute_cb_rect_static_ancestors_fall_back_to_the_viewport() {
        let (sd, mut tree) = three_level("");
        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
        tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 100.0));
        let pos = positions(&[(0.0, 0.0), (10.0, 10.0), (20.0, 20.0)]);
        let got = find_absolute_containing_block_rect(&tree, 2, &sd, &pos, viewport())
            .expect("no positioned ancestor → initial CB");
        assert_eq!(got, viewport());
    }
    #[test]
    fn find_absolute_cb_rect_is_the_padding_box_of_the_positioned_ancestor() {
        // CSS 2.1 §10.1: padding box, i.e. margin-box origin + border, size - borders.
        let (sd, mut tree) = two_level(".root { position: relative; }");
        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
        tree.nodes[0].box_props = bp(uniform(0.0), uniform(5.0), uniform(10.0));
        let pos = positions(&[(20.0, 30.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
            .expect("relative parent is the CB");
        assert_eq!(got.origin, LogicalPosition::new(30.0, 40.0));
        assert_eq!(got.size, LogicalSize::new(380.0, 280.0));
    }
    #[test]
    fn find_absolute_cb_rect_accepts_every_positioned_ancestor_kind() {
        for keyword in ["relative", "absolute", "fixed", "sticky"] {
            let css = format!(".root {{ position: {keyword}; }}");
            let (sd, mut tree) = two_level(&css);
            tree.nodes[0].used_size = Some(LogicalSize::new(100.0, 100.0));
            let pos = positions(&[(5.0, 5.0), (0.0, 0.0)]);
            let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
                .expect("positioned ancestor resolves");
            assert_eq!(
                got,
                LogicalRect::new(
                    LogicalPosition::new(5.0, 5.0),
                    LogicalSize::new(100.0, 100.0)
                ),
                "position: {keyword}"
            );
        }
    }
    #[test]
    fn find_absolute_cb_rect_picks_the_nearest_positioned_ancestor() {
        let (sd, mut tree) = three_level(".root { position: relative; } .mid { position: absolute; }");
        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
        tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 100.0));
        let pos = positions(&[(0.0, 0.0), (50.0, 60.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 2, &sd, &pos, viewport())
            .expect("nearest positioned ancestor");
        assert_eq!(got.origin, LogicalPosition::new(50.0, 60.0), "mid, not root");
        assert_eq!(got.size, LogicalSize::new(200.0, 100.0));
    }
    #[test]
    fn find_absolute_cb_rect_saturating_borders_clamp_the_padding_box_to_zero() {
        // PackedBoxProps saturates each edge at 3276.7px. Two of those exceed a
        // 100px border box — the padding box must clamp to 0, never go negative.
        let (sd, mut tree) = two_level(".root { position: relative; }");
        tree.nodes[0].used_size = Some(LogicalSize::new(100.0, 100.0));
        tree.nodes[0].box_props = bp(uniform(0.0), uniform(0.0), uniform(1e30));
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
            .expect("saturated borders still resolve");
        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
        assert!(got.size.width >= 0.0 && got.size.height >= 0.0);
        assert!(got.origin.x.is_finite() && got.origin.y.is_finite());
    }
    #[test]
    fn find_absolute_cb_rect_unsized_ancestor_is_a_zero_sized_padding_box() {
        let (sd, tree) = two_level(".root { position: relative; }"); // used_size stays None
        let pos = positions(&[(7.0, 9.0), (0.0, 0.0)]);
        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
            .expect("an unsized ancestor still resolves");
        assert_eq!(got.origin, LogicalPosition::new(7.0, 9.0));
        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
    }
    #[test]
    fn find_absolute_cb_rect_missing_position_entry_defaults_to_the_origin() {
        let (sd, mut tree) = two_level(".root { position: relative; }");
        tree.nodes[0].used_size = Some(LogicalSize::new(100.0, 100.0));
        let pos: PositionVec = Vec::new(); // nothing laid out yet
        let got = find_absolute_containing_block_rect(&tree, 1, &sd, &pos, viewport())
            .expect("an empty position vec still resolves");
        assert_eq!(got.origin, LogicalPosition::new(0.0, 0.0));
        assert_eq!(got.size, LogicalSize::new(100.0, 100.0));
    }
    // ==================================================================
    // find_nearest_scrollport (numeric)
    // ==================================================================
    #[test]
    fn find_nearest_scrollport_without_a_scroll_ancestor_is_the_viewport() {
        let (sd, tree) = two_level("");
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        assert_eq!(
            find_nearest_scrollport(&tree, 1, &sd, &pos, viewport()),
            viewport()
        );
    }
    #[test]
    fn find_nearest_scrollport_out_of_range_index_is_the_viewport_not_a_panic() {
        let (sd, tree) = two_level(".root { overflow-y: scroll; }");
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        assert_eq!(
            find_nearest_scrollport(&tree, 9_999, &sd, &pos, viewport()),
            viewport()
        );
    }
    #[test]
    fn find_nearest_scrollport_returns_the_ancestor_content_box() {
        for css in [
            ".root { overflow-x: scroll; }",
            ".root { overflow-y: scroll; }",
            ".root { overflow-x: auto; }",
            ".root { overflow-y: auto; }",
        ] {
            let (sd, mut tree) = two_level(css);
            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 150.0));
            tree.nodes[0].box_props = bp(uniform(0.0), uniform(5.0), uniform(10.0));
            let pos = positions(&[(20.0, 30.0), (0.0, 0.0)]);
            let got = find_nearest_scrollport(&tree, 1, &sd, &pos, viewport());
            // content box = margin-box pos + border + padding, size - 2*(border+padding)
            assert_eq!(got.origin, LogicalPosition::new(35.0, 45.0), "{css}");
            assert_eq!(got.size, LogicalSize::new(170.0, 120.0), "{css}");
        }
    }
    #[test]
    fn find_nearest_scrollport_ignores_non_scrolling_overflow() {
        for css in [
            ".root { overflow-x: hidden; }",
            ".root { overflow-y: visible; }",
            ".root { overflow-x: clip; }",
        ] {
            let (sd, mut tree) = two_level(css);
            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 150.0));
            let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
            assert_eq!(
                find_nearest_scrollport(&tree, 1, &sd, &pos, viewport()),
                viewport(),
                "{css}"
            );
        }
    }
    #[test]
    fn find_nearest_scrollport_picks_the_nearest_of_two_scroll_ancestors() {
        let (sd, mut tree) =
            three_level(".root { overflow-y: scroll; } .mid { overflow-y: scroll; }");
        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
        tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 100.0));
        let pos = positions(&[(0.0, 0.0), (11.0, 12.0), (0.0, 0.0)]);
        let got = find_nearest_scrollport(&tree, 2, &sd, &pos, viewport());
        assert_eq!(got.origin, LogicalPosition::new(11.0, 12.0), "mid, not root");
        assert_eq!(got.size, LogicalSize::new(200.0, 100.0));
    }
    #[test]
    fn find_nearest_scrollport_walks_past_anonymous_boxes() {
        // An anonymous box (dom_node_id: None) has no style — it must be skipped,
        // not treated as the end of the ancestor chain.
        let (sd, mut tree) = three_level(".root { overflow-y: scroll; }");
        tree.nodes[1].dom_node_id = None; // .mid becomes anonymous
        tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
        let pos = positions(&[(1.0, 2.0), (0.0, 0.0), (0.0, 0.0)]);
        let got = find_nearest_scrollport(&tree, 2, &sd, &pos, viewport());
        assert_eq!(got.origin, LogicalPosition::new(1.0, 2.0));
        assert_eq!(got.size, LogicalSize::new(400.0, 300.0));
    }
    #[test]
    fn find_nearest_scrollport_clamps_the_content_box_to_zero_when_padding_exceeds_the_box() {
        let (sd, mut tree) = two_level(".root { overflow-y: scroll; }");
        tree.nodes[0].used_size = Some(LogicalSize::new(10.0, 10.0));
        tree.nodes[0].box_props = bp(uniform(0.0), uniform(1e30), uniform(1e30));
        let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
        let got = find_nearest_scrollport(&tree, 1, &sd, &pos, viewport());
        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
        assert!(got.size.width >= 0.0 && got.size.height >= 0.0);
    }
    #[test]
    fn find_nearest_scrollport_unsized_scrollport_is_zero_sized() {
        let (sd, tree) = two_level(".root { overflow-y: scroll; }"); // used_size None
        let pos: PositionVec = Vec::new();
        let got = find_nearest_scrollport(&tree, 1, &sd, &pos, viewport());
        assert_eq!(got.origin, LogicalPosition::new(0.0, 0.0));
        assert_eq!(got.size, LogicalSize::new(0.0, 0.0));
    }
    // ==================================================================
    // find_nearest_scroll_offset (numeric)
    // ==================================================================
    /// `container_origin` is the scroller's ABSOLUTE window position, `offset`
    /// is the scroll amount — the two are deliberately different everywhere
    /// below, because a fixture with a container at (0, 0) cannot tell the two
    /// `ScrollPosition` conventions apart.
    fn scroll_at(container_origin: (f32, f32), offset: (f32, f32)) -> ScrollPosition {
        ScrollPosition {
            parent_rect: LogicalRect::new(
                LogicalPosition::new(container_origin.0, container_origin.1),
                LogicalSize::new(100.0, 100.0),
            ),
            children_rect: LogicalRect::new(
                LogicalPosition::new(offset.0, offset.1),
                LogicalSize::new(100.0, 400.0),
            ),
        }
    }
    #[test]
    fn find_nearest_scroll_offset_empty_map_is_zero() {
        let (_sd, tree) = two_level("");
        let offsets: BTreeMap<NodeId, ScrollPosition> = BTreeMap::new();
        assert_eq!(
            find_nearest_scroll_offset(&tree, 1, &offsets),
            LogicalPosition::zero()
        );
    }
    #[test]
    fn find_nearest_scroll_offset_out_of_range_index_is_zero_not_a_panic() {
        let (sd, tree) = two_level("");
        let mut offsets = BTreeMap::new();
        offsets.insert(node_by_class(&sd, "root"), scroll_at((0.0, 120.0), (0.0, 50.0)));
        assert_eq!(
            find_nearest_scroll_offset(&tree, 9_999, &offsets),
            LogicalPosition::zero()
        );
    }
    #[test]
    fn find_nearest_scroll_offset_ignores_the_nodes_own_entry() {
        // The walk starts at the PARENT — a node's own scroll offset must not
        // shift the node itself.
        let (sd, tree) = two_level("");
        let mut offsets = BTreeMap::new();
        offsets.insert(
            node_by_class(&sd, "child"),
            scroll_at((0.0, 120.0), (0.0, 50.0)),
        );
        assert_eq!(
            find_nearest_scroll_offset(&tree, 1, &offsets),
            LogicalPosition::zero()
        );
    }
    #[test]
    fn find_nearest_scroll_offset_is_the_raw_offset_not_a_container_relative_one() {
        // CONVENTION PIN. `children_rect.origin` IS the scroll offset;
        // `parent_rect.origin` is an absolute window coordinate in a different
        // space. Subtracting the two (as this used to) reported the AzWriter
        // document view — a scroller at y = 120, unscrolled — as scrolled by
        // -120, which then shifted every sticky box inside it by a screenful.
        let (sd, tree) = two_level("");
        let mut offsets = BTreeMap::new();
        offsets.insert(
            node_by_class(&sd, "root"),
            scroll_at((10.0, 120.0), (0.0, 0.0)),
        );
        assert_eq!(
            find_nearest_scroll_offset(&tree, 1, &offsets),
            LogicalPosition::zero(),
            "an unscrolled container reports zero wherever it sits"
        );
        offsets.insert(
            node_by_class(&sd, "root"),
            scroll_at((10.0, 120.0), (5.0, 80.0)),
        );
        assert_eq!(
            find_nearest_scroll_offset(&tree, 1, &offsets),
            LogicalPosition::new(5.0, 80.0),
            "the offset is reported verbatim, not relative to the container"
        );
    }
    #[test]
    fn find_nearest_scroll_offset_picks_the_nearest_ancestor() {
        let (sd, tree) = three_level("");
        let mut offsets = BTreeMap::new();
        offsets.insert(node_by_class(&sd, "root"), scroll_at((0.0, 40.0), (0.0, 999.0)));
        offsets.insert(node_by_class(&sd, "mid"), scroll_at((0.0, 120.0), (0.0, 7.0)));
        assert_eq!(
            find_nearest_scroll_offset(&tree, 2, &offsets),
            LogicalPosition::new(0.0, 7.0),
            "mid wins over root"
        );
    }
    #[test]
    fn find_nearest_scroll_offset_walks_past_anonymous_ancestors() {
        let (sd, mut tree) = three_level("");
        tree.nodes[1].dom_node_id = None;
        let mut offsets = BTreeMap::new();
        offsets.insert(node_by_class(&sd, "root"), scroll_at((0.0, 120.0), (0.0, 30.0)));
        assert_eq!(
            find_nearest_scroll_offset(&tree, 2, &offsets),
            LogicalPosition::new(0.0, 30.0)
        );
    }
    #[test]
    fn find_nearest_scroll_offset_at_f32_extremes_stays_deterministic() {
        let (sd, tree) = two_level("");
        let mut offsets = BTreeMap::new();
        offsets.insert(
            node_by_class(&sd, "root"),
            scroll_at((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)),
        );
        let got = find_nearest_scroll_offset(&tree, 1, &offsets);
        // No arithmetic is performed any more, so an extreme offset passes
        // through as-is instead of overflowing to -inf. It must never be NaN
        // (which would poison every downstream sticky comparison silently).
        assert!(!got.x.is_nan() && !got.y.is_nan());
        assert_eq!(got.x, f32::MIN);
        assert_eq!(got.y, f32::MIN);
    }
    // ==================================================================
    // The three passes that need a LayoutContext (and therefore a FontManager).
    // ==================================================================
    #[cfg(all(feature = "text_layout", feature = "font_loading"))]
    mod with_ctx {
        use std::collections::HashMap;
        use azul_core::{dom::DomId, selection::TextSelection};
        use azul_css::props::basic::FontRef;
        use super::*;
        use crate::{
            font_traits::{FontManager, TextLayoutCache},
            solver3::{cache, LayoutContext},
        };
        /// Owns everything a `LayoutContext` borrows.
        struct Env {
            styled_dom: StyledDom,
            font_manager: FontManager<FontRef>,
            text_selections: BTreeMap<DomId, TextSelection>,
            counters: HashMap<(usize, String), i32>,
            image_cache: azul_core::resources::ImageCache,
            debug_messages: Option<Vec<LayoutDebugMessage>>,
        }
        impl Env {
            fn new(styled_dom: StyledDom) -> Self {
                Self {
                    styled_dom,
                    font_manager: FontManager::new(rust_fontconfig::FcFontCache::default())
                        .expect("FontManager over an empty font cache"),
                    text_selections: BTreeMap::new(),
                    counters: HashMap::new(),
                    image_cache: azul_core::resources::ImageCache::default(),
                    debug_messages: None,
                }
            }
            fn ctx(&mut self) -> LayoutContext<'_, FontRef> {
                LayoutContext {
            reflowed_ifcs: std::collections::BTreeSet::new(),
                    style_cache: Default::default(),
                    scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
                    styled_dom: &self.styled_dom,
                    font_manager: &self.font_manager,
                    text_selections: &self.text_selections,
                    debug_messages: &mut self.debug_messages,
                    counters: &mut self.counters,
                    viewport_size: LogicalSize::new(800.0, 600.0),
                    fragmentation_context: None,
                    cursor_is_visible: true,
                    cursor_locations: Vec::new(),
                    preedit_text: None,
                    cache_map: cache::LayoutCacheMap::default(),
                    image_cache: &self.image_cache,
                    content_overlay: None,
                    system_style: None,
                    get_system_time_fn: azul_core::task::GetSystemTimeCallback {
                        cb: azul_core::task::get_system_time_libstd,
                    },
                }
            }
        }
        /// `.root` = relative, 400×300 border box, 10px border + 5px padding, at (20,30).
        /// Its padding box — the CB every abspos child below resolves against — is
        /// therefore origin (30,40), size 380×280.
        fn abs_fixture(css: &str) -> (Env, LayoutTree, PositionVec) {
            let (sd, mut tree) = two_level(css);
            tree.nodes[0].used_size = Some(LogicalSize::new(400.0, 300.0));
            tree.nodes[0].box_props = bp(uniform(0.0), uniform(5.0), uniform(10.0));
            tree.nodes[1].used_size = Some(LogicalSize::new(50.0, 50.0));
            let pos = positions(&[(20.0, 30.0), (0.0, 0.0)]);
            (Env::new(sd), tree, pos)
        }
        fn run_oof(env: &mut Env, tree: &mut LayoutTree, pos: &mut PositionVec, vp: LogicalRect) {
            let mut text_cache = TextLayoutCache::default();
            let mut ctx = env.ctx();
            position_out_of_flow_elements(&mut ctx, tree, &mut text_cache, pos, vp);
        }
        // --------------------------------------------------------------
        // position_out_of_flow_elements
        // --------------------------------------------------------------
        #[test]
        fn out_of_flow_top_left_offset_from_the_ancestor_padding_box() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 25px; left: 15px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(45.0, 65.0));
        }
        #[test]
        fn out_of_flow_zero_insets_land_exactly_on_the_padding_box_origin() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } .child { position: absolute; top: 0px; left: 0px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(30.0, 40.0));
        }
        #[test]
        fn out_of_flow_all_auto_keeps_the_static_position() {
            // +spec:positioning:aab294 — both insets auto → static position.
            let (mut env, mut tree, mut pos) =
                abs_fixture(".root { position: relative; } .child { position: absolute; }");
            pos_set(&mut pos, 1, LogicalPosition::new(7.0, 9.0));
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(7.0, 9.0));
        }
        #[test]
        fn out_of_flow_fixed_resolves_against_the_viewport_not_the_ancestor() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } .child { position: fixed; top: 25px; left: 15px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(15.0, 25.0));
        }
        #[test]
        fn out_of_flow_over_constrained_ignores_the_end_insets_in_ltr() {
            // top/height/bottom and left/width/right all given: bottom/right lose.
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 10px; bottom: 10px; left: 10px; \
                          right: 10px; width: 50px; height: 50px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(40.0, 50.0));
        }
        #[test]
        fn out_of_flow_auto_margins_center_the_box_in_both_axes() {
            // +spec:height-calculation:5112a4 — both auto margins solve to equal values.
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 0px; bottom: 0px; left: 0px; \
                          right: 0px; width: 100px; height: 100px; }",
            );
            tree.nodes[1].used_size = Some(LogicalSize::new(100.0, 100.0));
            tree.nodes[1].box_props = bp_auto_margins(MarginAuto {
                top: true,
                bottom: true,
                left: true,
                right: true,
            });
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            // CB 380×280 at (30,40): (380-100)/2 = 140, (280-100)/2 = 90.
            assert_eq!(pos[1], LogicalPosition::new(170.0, 130.0));
        }
        #[test]
        fn out_of_flow_negative_free_space_with_auto_margins_pins_to_the_start_edge_in_ltr() {
            // +spec:writing-modes:9c3b40 — negative remaining space: start margin is 0.
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; left: 0px; right: 0px; width: 500px; }",
            );
            tree.nodes[1].used_size = Some(LogicalSize::new(500.0, 50.0));
            tree.nodes[1].box_props = bp_auto_margins(MarginAuto {
                left: true,
                right: true,
                top: false,
                bottom: false,
            });
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            // remaining = 380 - 0 - 500 - 0 = -120 → each margin < 0 → pin left.
            assert_eq!(pos[1].x, 30.0);
        }
        #[test]
        fn out_of_flow_over_constrained_ignores_the_left_inset_in_rtl() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; direction: rtl; } \
                 .child { position: absolute; left: 10px; right: 10px; width: 50px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            // RTL solves for left: 380 - 50 - 10 = 320 → 30 + 320.
            assert_eq!(pos[1].x, 350.0);
        }
        #[test]
        fn out_of_flow_auto_height_and_width_stretch_between_the_insets() {
            // +spec:intrinsic-sizing:566a43 — stretch-fit sizing on both axes.
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 10px; bottom: 20px; left: 30px; right: 40px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(60.0, 50.0));
            let used = tree.nodes[1].used_size.expect("size was resolved");
            assert_eq!(used, LogicalSize::new(310.0, 250.0));
        }
        #[test]
        fn out_of_flow_insets_larger_than_the_containing_block_clamp_the_size_to_zero() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 500px; bottom: 500px; \
                          left: 500px; right: 500px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            let used = tree.nodes[1].used_size.expect("size was resolved");
            assert_eq!(used, LogicalSize::new(0.0, 0.0), "never negative");
            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
        }
        #[test]
        fn out_of_flow_huge_insets_bypass_the_i16_cache_and_stay_finite() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 3300px; left: 100000px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(100_030.0, 3340.0));
            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
        }
        #[test]
        fn out_of_flow_negative_insets_move_the_box_outside_the_containing_block() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: -100px; left: -200px; }",
            );
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos[1], LogicalPosition::new(-170.0, -60.0));
        }
        #[test]
        fn out_of_flow_nan_viewport_clamps_the_stretch_height_to_zero_and_keeps_the_position_finite()
        {
            // f32::max(NaN, 0.0) == 0.0, so the stretch-fit height degrades to 0
            // rather than propagating NaN into the display list.
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: fixed; top: 10px; bottom: 20px; }",
            );
            let nan_vp = LogicalRect::new(
                LogicalPosition::new(0.0, 0.0),
                LogicalSize::new(f32::NAN, f32::NAN),
            );
            run_oof(&mut env, &mut tree, &mut pos, nan_vp);
            let used = tree.nodes[1].used_size.expect("size was resolved");
            assert_eq!(used.height, 0.0);
            assert_eq!(pos[1].y, 10.0);
            assert!(pos[1].y.is_finite());
        }
        #[test]
        fn out_of_flow_infinite_viewport_keeps_the_position_finite() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: fixed; top: 10px; bottom: 20px; }",
            );
            let inf_vp = LogicalRect::new(
                LogicalPosition::new(0.0, 0.0),
                LogicalSize::new(f32::INFINITY, f32::INFINITY),
            );
            run_oof(&mut env, &mut tree, &mut pos, inf_vp);
            assert_eq!(pos[1].y, 10.0);
            let used = tree.nodes[1].used_size.expect("size was resolved");
            assert!(used.height.is_infinite() && used.height > 0.0);
        }
        #[test]
        fn out_of_flow_every_auto_combination_of_top_height_bottom_is_panic_free() {
            // The rustdoc claims a panic when a resolved offset is None where both
            // edges are expected. Walk all 8 auto/non-auto combinations per axis and
            // prove every `unwrap()` in the constraint solver is actually guarded.
            for top in ["", "top: 10px;"] {
                for bottom in ["", "bottom: 20px;"] {
                    for height in ["", "height: 30px;"] {
                        for left in ["", "left: 10px;"] {
                            for right in ["", "right: 20px;"] {
                                for width in ["", "width: 30px;"] {
                                    let css = format!(
                                        ".root {{ position: relative; }} \
                                         .child {{ position: absolute; {top}{bottom}{height}\
                                         {left}{right}{width} }}"
                                    );
                                    let (mut env, mut tree, mut pos) = abs_fixture(&css);
                                    run_oof(&mut env, &mut tree, &mut pos, viewport());
                                    assert!(
                                        pos[1].x.is_finite() && pos[1].y.is_finite(),
                                        "non-finite position for {css}"
                                    );
                                }
                            }
                        }
                    }
                }
            }
        }
        #[test]
        fn out_of_flow_skips_children_of_flex_and_grid_parents() {
            // Taffy already placed those during flex/grid layout — re-positioning
            // here would double-apply the insets.
            for fc in [FormattingContext::Flex, FormattingContext::Grid] {
                let (mut env, mut tree, mut pos) = abs_fixture(
                    ".root { position: relative; } \
                     .child { position: absolute; top: 25px; left: 15px; }",
                );
                tree.nodes[0].formatting_context = fc;
                pos_set(&mut pos, 1, LogicalPosition::new(3.0, 4.0));
                run_oof(&mut env, &mut tree, &mut pos, viewport());
                assert_eq!(pos[1], LogicalPosition::new(3.0, 4.0), "{fc:?}");
            }
        }
        #[test]
        fn out_of_flow_leaves_static_and_relative_nodes_alone() {
            for keyword in ["static", "relative", "sticky"] {
                let css = format!(
                    ".root {{ position: relative; }} \
                     .child {{ position: {keyword}; top: 25px; left: 15px; }}"
                );
                let (mut env, mut tree, mut pos) = abs_fixture(&css);
                pos_set(&mut pos, 1, LogicalPosition::new(3.0, 4.0));
                run_oof(&mut env, &mut tree, &mut pos, viewport());
                assert_eq!(pos[1], LogicalPosition::new(3.0, 4.0), "{keyword}");
            }
        }
        #[test]
        fn out_of_flow_short_position_vec_grows_instead_of_panicking() {
            let (mut env, mut tree, _pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 25px; left: 15px; }",
            );
            let mut pos: PositionVec = Vec::new(); // nothing laid out yet
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert_eq!(pos.len(), 2, "pos_set grew the vec");
            // The CB origin now comes from a default (0,0) ancestor position.
            assert_eq!(pos[1], LogicalPosition::new(25.0, 35.0));
        }
        #[test]
        fn out_of_flow_unsized_node_is_sized_on_the_fly_without_panicking() {
            let (mut env, mut tree, mut pos) = abs_fixture(
                ".root { position: relative; } \
                 .child { position: absolute; top: 10px; left: 10px; }",
            );
            tree.nodes[1].used_size = None; // never sized by the main pass
            run_oof(&mut env, &mut tree, &mut pos, viewport());
            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
        }
        // --------------------------------------------------------------
        // adjust_relative_positions
        // --------------------------------------------------------------
        /// `.root` = 200×100 border box with 10px padding → 180×80 content box,
        /// which is the CB percentages resolve against for the relative child.
        fn rel_fixture(css: &str) -> (Env, LayoutTree, PositionVec) {
            let (sd, mut tree) = two_level(css);
            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 100.0));
            tree.nodes[0].box_props = bp(uniform(0.0), uniform(10.0), uniform(0.0));
            tree.nodes[1].used_size = Some(LogicalSize::new(50.0, 20.0));
            let pos = positions(&[(0.0, 0.0), (100.0, 100.0)]);
            (Env::new(sd), tree, pos)
        }
        fn run_rel(env: &mut Env, tree: &LayoutTree, pos: &mut PositionVec) {
            let mut ctx = env.ctx();
            adjust_relative_positions(&mut ctx, tree, pos, viewport());
        }
        #[test]
        fn relative_px_offsets_shift_from_the_static_position() {
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 10px; left: 5px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1], LogicalPosition::new(105.0, 110.0));
        }
        #[test]
        fn relative_percentages_resolve_against_the_parent_content_box() {
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 50%; left: 50%; }");
            run_rel(&mut env, &tree, &mut pos);
            // content box is 180×80 → +90 x, +40 y.
            assert_eq!(pos[1], LogicalPosition::new(190.0, 140.0));
        }
        #[test]
        fn relative_top_wins_over_bottom() {
            // +spec:positioning:e3727e — neither auto → bottom is ignored.
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 10px; bottom: 30px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1].y, 110.0);
        }
        #[test]
        fn relative_bottom_alone_is_the_negation_of_top() {
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; bottom: 30px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1].y, 70.0);
        }
        #[test]
        fn relative_right_alone_is_the_negation_of_left() {
            // +spec:overflow:fb426c — left auto → used value is minus right.
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; right: 20px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1].x, 80.0);
        }
        #[test]
        fn relative_left_wins_in_ltr_and_right_wins_in_rtl() {
            // +spec:containing-block:6d4fb1 — direction of the CONTAINING BLOCK decides.
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; left: 5px; right: 20px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1].x, 105.0, "ltr: left wins");
            let (mut env, tree, mut pos) = rel_fixture(
                ".root { direction: rtl; } \
                 .child { position: relative; left: 5px; right: 20px; }",
            );
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1].x, 80.0, "rtl: right wins → -20");
        }
        #[test]
        fn relative_zero_offsets_are_a_no_op() {
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 0px; left: 0px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1], LogicalPosition::new(100.0, 100.0));
        }
        #[test]
        fn relative_leaves_static_absolute_and_fixed_nodes_untouched() {
            for keyword in ["static", "absolute", "fixed"] {
                let css =
                    format!(".child {{ position: {keyword}; top: 10px; left: 5px; }}");
                let (mut env, tree, mut pos) = rel_fixture(&css);
                run_rel(&mut env, &tree, &mut pos);
                assert_eq!(pos[1], LogicalPosition::new(100.0, 100.0), "{keyword}");
            }
        }
        #[test]
        fn relative_also_offsets_sticky_boxes() {
            // Sticky deliberately shares the relative path (the pre-scroll offset);
            // adjust_sticky_positions then clamps it. Pinning this so the two passes
            // can't silently start disagreeing.
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 10px; }");
            run_rel(&mut env, &tree, &mut pos);
            let relative_y = pos[1].y;
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: sticky; top: 10px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1].y, relative_y);
        }
        #[test]
        fn relative_is_undefined_for_table_cells_and_captions_so_they_are_skipped() {
            for display in ["table-cell", "table-caption", "table-column"] {
                let css = format!(
                    ".child {{ position: relative; display: {display}; top: 10px; left: 5px; }}"
                );
                let (mut env, tree, mut pos) = rel_fixture(&css);
                run_rel(&mut env, &tree, &mut pos);
                assert_eq!(pos[1], LogicalPosition::new(100.0, 100.0), "{display}");
            }
        }
        #[test]
        fn relative_table_rows_drag_their_whole_subtree() {
            // +spec:table-layout:ec2600 — the shift affects all contents of the row.
            let (sd, mut tree) = three_level(
                ".mid { position: relative; display: table-row; top: 10px; left: 5px; } \
                 .child { display: table-cell; }",
            );
            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 100.0));
            tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 50.0));
            tree.nodes[2].used_size = Some(LogicalSize::new(100.0, 50.0));
            let mut pos = positions(&[(0.0, 0.0), (10.0, 20.0), (10.0, 20.0)]);
            let mut env = Env::new(sd);
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1], LogicalPosition::new(15.0, 30.0), "the row itself");
            assert_eq!(pos[2], LogicalPosition::new(15.0, 30.0), "the cell follows");
        }
        #[test]
        fn relative_short_position_vec_is_skipped_not_panicked_on() {
            let (mut env, tree, _pos) =
                rel_fixture(".child { position: relative; top: 10px; left: 5px; }");
            let mut pos: PositionVec = Vec::new();
            run_rel(&mut env, &tree, &mut pos);
            assert!(pos.is_empty(), "nothing to shift, nothing added");
        }
        #[test]
        fn relative_huge_and_negative_offsets_stay_finite() {
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 100000px; left: -100000px; }");
            run_rel(&mut env, &tree, &mut pos);
            assert_eq!(pos[1], LogicalPosition::new(-99_900.0, 100_100.0));
            assert!(pos[1].x.is_finite() && pos[1].y.is_finite());
        }
        #[test]
        fn relative_unset_sentinel_position_is_not_silently_shifted_into_a_real_one() {
            // POSITION_UNSET is f32::MIN. Adding a finite delta to it must stay
            // absurdly negative (it must NOT round into a plausible coordinate) —
            // a caller can still detect the node was never laid out.
            let (mut env, tree, mut pos) =
                rel_fixture(".child { position: relative; top: 10px; left: 5px; }");
            pos[1] = POSITION_UNSET;
            run_rel(&mut env, &tree, &mut pos);
            assert!(pos[1].x < -1e30 && pos[1].y < -1e30);
        }
        // --------------------------------------------------------------
        // adjust_sticky_positions
        // --------------------------------------------------------------
        /// `.root` = a 200×200 scrollport at (0,0); `.child` = 50×20 sticky box at (0,0).
        fn sticky_fixture(css: &str) -> (Env, LayoutTree, PositionVec) {
            let (sd, mut tree) = two_level(css);
            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 200.0));
            tree.nodes[1].used_size = Some(LogicalSize::new(50.0, 20.0));
            let pos = positions(&[(0.0, 0.0), (0.0, 0.0)]);
            (Env::new(sd), tree, pos)
        }
        fn run_sticky(
            env: &mut Env,
            tree: &LayoutTree,
            pos: &mut PositionVec,
            offsets: &BTreeMap<NodeId, ScrollPosition>,
        ) {
            let mut ctx = env.ctx();
            adjust_sticky_positions(&mut ctx, tree, pos, offsets, viewport());
        }
        #[test]
        fn sticky_top_inset_pins_the_box_to_the_scrollport_edge() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
            );
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            assert_eq!(pos[1], LogicalPosition::new(0.0, 10.0));
        }
        #[test]
        fn sticky_without_insets_does_not_move() {
            let (mut env, tree, mut pos) =
                sticky_fixture(".root { overflow-y: scroll; } .child { position: sticky; }");
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0));
        }
        #[test]
        fn sticky_ignores_non_sticky_positions() {
            for keyword in ["static", "relative", "absolute", "fixed"] {
                let css = format!(
                    ".root {{ overflow-y: scroll; }} \
                     .child {{ position: {keyword}; top: 10px; }}"
                );
                let (mut env, tree, mut pos) = sticky_fixture(&css);
                run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
                assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0), "{keyword}");
            }
        }
        #[test]
        fn sticky_edge_moves_with_the_scroll_offset_of_the_nearest_container() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
            );
            let root = node_by_class(&env.styled_dom, "root");
            let mut offsets = BTreeMap::new();
            // The container sits 120px down the window (the AzWriter document
            // view under the ribbon) — its absolute origin must not leak into
            // the scroll amount. Scrolled by 50, it must behave exactly like a
            // container at the window origin scrolled by 50.
            offsets.insert(root, scroll_at((0.0, 120.0), (0.0, 50.0)));
            run_sticky(&mut env, &tree, &mut pos, &offsets);
            // sticky edge = scrollport.y (0) + scroll (50) + inset (10).
            assert_eq!(pos[1].y, 60.0);
        }
        #[test]
        fn sticky_does_not_move_when_a_low_container_is_unscrolled() {
            // REGRESSION: `find_nearest_scroll_offset` used to return
            // `children_rect.origin - parent_rect.origin`, so this container —
            // at y = 120 with the scroll offset still at zero — reported -120
            // and pushed the sticky box a screenful out of its scrollport.
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
            );
            let root = node_by_class(&env.styled_dom, "root");
            let mut offsets = BTreeMap::new();
            offsets.insert(root, scroll_at((0.0, 120.0), (0.0, 0.0)));
            run_sticky(&mut env, &tree, &mut pos, &offsets);
            assert_eq!(pos[1].y, 10.0, "unscrolled: only the inset applies");
        }
        #[test]
        fn sticky_percentage_inset_resolves_against_the_scrollport() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10%; }",
            );
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            assert_eq!(pos[1].y, 20.0, "10% of the 200px scrollport");
        }
        #[test]
        fn sticky_bottom_inset_pulls_the_box_back_up_into_the_scrollport() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; bottom: 10px; }",
            );
            pos_set(&mut pos, 1, LogicalPosition::new(0.0, 250.0));
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            // bottom edge must sit at 200 - 10 = 190 → top = 190 - 20.
            assert_eq!(pos[1].y, 170.0);
        }
        #[test]
        fn sticky_shift_is_clamped_by_the_containing_block() {
            // +spec:box-model:af9af8 — the margin box must stay inside the CB, even
            // when the scrollport would let the box travel further.
            let (sd, mut tree) = three_level(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
            );
            tree.nodes[0].used_size = Some(LogicalSize::new(200.0, 200.0));
            tree.nodes[1].used_size = Some(LogicalSize::new(200.0, 25.0)); // short CB
            tree.nodes[2].used_size = Some(LogicalSize::new(50.0, 20.0));
            let mut pos = positions(&[(0.0, 0.0), (0.0, 0.0), (0.0, 0.0)]);
            let mut env = Env::new(sd);
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            // Unclamped the shift would be 10 (bottom = 30 > CB bottom 25) → 5.
            assert_eq!(pos[2].y, 5.0);
        }
        #[test]
        fn sticky_huge_inset_clamps_to_the_containing_block_instead_of_flying_away() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 100000px; }",
            );
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            // The margin box is pushed back until its bottom sits on the CB bottom
            // (200) → top = 200 - 20 = 180.
            assert_eq!(pos[1].y, 180.0);
            assert!(pos[1].y.is_finite());
        }
        #[test]
        fn sticky_negative_inset_is_deterministic_and_finite() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: -50px; }",
            );
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            // sticky edge = -50, border top = 0, already past it → no shift.
            assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0));
        }
        #[test]
        fn sticky_without_a_scroll_ancestor_falls_back_to_the_viewport() {
            let (mut env, tree, mut pos) =
                sticky_fixture(".child { position: sticky; top: 10px; }"); // .root does not scroll
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            // Scrollport = viewport (0,0,800×600); CB = the parent's 200×200 content
            // box, which comfortably contains the 10px shift.
            assert_eq!(pos[1].y, 10.0);
        }
        #[test]
        fn sticky_left_and_right_insets_shift_the_inline_axis() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-x: scroll; } .child { position: sticky; left: 15px; }",
            );
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            assert_eq!(pos[1].x, 15.0);
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-x: scroll; } .child { position: sticky; right: 10px; }",
            );
            pos_set(&mut pos, 1, LogicalPosition::new(300.0, 0.0));
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            // right edge pinned at 200 - 10 = 190 → x = 190 - 50.
            assert_eq!(pos[1].x, 140.0);
        }
        #[test]
        fn sticky_short_position_vec_is_skipped_not_panicked_on() {
            let (mut env, tree, _pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
            );
            let mut pos: PositionVec = Vec::new();
            run_sticky(&mut env, &tree, &mut pos, &BTreeMap::new());
            assert!(pos.is_empty());
        }
        #[test]
        fn sticky_nan_scroll_offset_never_panics() {
            let (mut env, tree, mut pos) = sticky_fixture(
                ".root { overflow-y: scroll; } .child { position: sticky; top: 10px; }",
            );
            let root = node_by_class(&env.styled_dom, "root");
            let mut offsets = BTreeMap::new();
            offsets.insert(root, scroll_at((f32::NAN, f32::NAN), (f32::NAN, f32::NAN)));
            run_sticky(&mut env, &tree, &mut pos, &offsets);
            // NaN comparisons are all false → no shift is ever applied.
            assert_eq!(pos[1], LogicalPosition::new(0.0, 0.0));
        }
    }
}