Skip to main content

orinium_browser/engine/layouter/builder/
mod.rs

1//! Layout builder, which transforms a DOM tree into a UI layout.
2
3use crate::engine::animation::{AnimationInputs, ElementAnimationState, parse as anim_parse};
4use crate::engine::css::values::CssValue;
5
6mod args;
7#[macro_use]
8mod munch;
9#[macro_use]
10mod parse_args;
11mod anchor;
12mod apply;
13mod background;
14mod color;
15mod css_resolve;
16mod layout_fix;
17#[cfg(test)]
18mod tests;
19
20pub use anchor::{parse_anchor_spec, resolve_anchor_positioning};
21pub use apply::{apply_declaration, blockify_out_of_flow_positioned};
22pub use layout_fix::{
23    constrain_auto_grid_track_items, correct_atomic_inline_spacing,
24    correct_atomic_inline_spacing_with_info, is_block_layout_child, is_collapsible_whitespace_info,
25    refresh_missing_text_layout_results,
26};
27
28#[allow(unused_imports)]
29pub use background::{
30    apply_background_shorthand_geometry, parse_background_position, parse_background_repeat,
31    parse_background_shorthand, parse_background_size, parse_gradient,
32};
33pub use color::resolve_css_color;
34pub use css_resolve::{
35    CalcValue, extract_font_families, one_or_two_values, parse_grid_line, parse_grid_line_end,
36    parse_grid_placement, parse_grid_template_areas, parse_grid_tracks, resolve_calc_value,
37    resolve_css_len, resolve_css_len_auto, resolve_font_size_px,
38};
39
40use crate::{perf_scope, profile_log};
41
42use crate::engine::bridge::audio::AudioSinkFactory;
43use crate::engine::bridge::text::{self, GlyphCluster};
44use crate::engine::css::{
45    matcher::{ElementChain, ElementInfo},
46    values::Unit,
47};
48use crate::engine::html::util::split_html_space;
49use crate::engine::html::{HtmlNodeType, ScriptingMode};
50use crate::engine::layouter::css_resolver::{
51    DeclarationResolver, Properties, PseudoElement, resolve_inline_value,
52};
53use crate::engine::layouter::dom_snapshot::{DomSnapshot, NodeId};
54use crate::engine::layouter::types::WhiteSpace;
55use crate::engine::renderer_model::Image;
56use crate::engine::svg::{SvgContent, collect_svg};
57use crate::engine::tree::NodeRef;
58use crate::engine::ui::custom_node::CustomNode;
59use crate::engine::ui::custom_node_bridge::CustomNodeBridge;
60use crate::engine::ui::registry::{ComponentRegistry, CustomNodeContext, DomWriteBack};
61
62use std::borrow::Cow;
63use std::collections::{HashMap, HashSet};
64use std::sync::Arc;
65
66#[allow(unused_imports)]
67use ui_layout::{
68    AlignContent, AlignItems, AutoSizeBehavior, BoxSizing, Clear as LayoutClear, Display,
69    FlexDirection, FlexWrap, Float as LayoutFloat, GridPlacement, GridPlacementEnd, GridRepeat,
70    GridTrack, InnerDisplay, ItemFragment, JustifyContent, JustifyItems, LayoutChild, LayoutNode,
71    Length, LengthOrAuto, OuterDisplay, Position, Style,
72};
73
74use super::css_resolver::{
75    MediaEnvironment, ResolvedStyles, RuleSet, resolve_inline_style, set_inline_custom_property,
76};
77use super::match_cache::SelectorMatchCache;
78use super::text_layouter::TextFlowLayouter;
79#[allow(unused_imports)]
80use super::types::{
81    Background, BackgroundDimension, BackgroundOffset, BackgroundPosition, BackgroundPositionAxis,
82    BackgroundRepeat, BackgroundSize, BorderRadius, BorderStyle, Color, ColorScheme, ColorStop,
83    ContainerRole, ContainerStyle, CornerRadius, CssClear, CssFloat, FontStyle, FontWeight,
84    Gradient, GradientKind, InfoNode, InteractionState, LineHeight, NodeKind, Overflow,
85    RadialShape, RadialSizeKind, TextAlign, TextDecoration, TextFlowStyle, TextStyle,
86    TextTransform,
87};
88
89pub(crate) const DEFAULT_LINE_FACTOR: f32 = 1.2;
90
91const GRID_LINE_TO_END: GridPlacementEnd = GridPlacementEnd::Line(usize::MAX);
92
93pub(crate) fn element_info(html_node: &HtmlNodeType) -> Option<ElementInfo> {
94    let HtmlNodeType::Element {
95        tag_name,
96        attributes,
97        ..
98    } = html_node
99    else {
100        return None;
101    };
102    Some(ElementInfo {
103        tag_name: tag_name.as_str().into(),
104        id: attributes
105            .iter()
106            .find(|attribute| attribute.name == "id")
107            .map(|attribute| Arc::from(attribute.value.as_str())),
108        classes: attributes
109            .iter()
110            .find(|attribute| attribute.name == "class")
111            .map(|attribute| {
112                split_html_space(&attribute.value)
113                    .map(Arc::<str>::from)
114                    .collect()
115            })
116            .unwrap_or_default(),
117        attributes: attributes
118            .iter()
119            .map(|attribute| {
120                (
121                    Arc::<str>::from(attribute.name.as_str()),
122                    Arc::<str>::from(attribute.value.as_str()),
123                )
124            })
125            .collect(),
126        element_index: 1,
127        element_count: 1,
128        type_index: 1,
129        type_count: 1,
130        previous_siblings: ElementChain::default(),
131        // A lone node carries no tree context, so both are filled in by the
132        // callers that hold a snapshot. `has_parent` is optimistic here because
133        // the structural pseudo-classes only use it to reject a tree root,
134        // which a snapshot-less caller never describes.
135        has_parent: true,
136        is_empty: false,
137        lang: None,
138        checked: false,
139        hovered: false,
140        active: false,
141    })
142}
143
144/// Structural pseudo-classes need a *parent element*; a document or shadow
145/// root above the tree root does not count, which is why the root element must
146/// not satisfy `:first-child`.
147pub(crate) fn is_element_node(snapshot: &DomSnapshot, id: NodeId) -> bool {
148    matches!(snapshot.node(id).kind, HtmlNodeType::Element { .. })
149}
150
151/// Read an element's own `lang` attribute, if any.
152pub(crate) fn own_lang(snapshot: &DomSnapshot, id: NodeId) -> Option<Arc<str>> {
153    snapshot
154        .node(id)
155        .kind
156        .get_attr("lang")
157        .map(str::trim)
158        .filter(|value| !value.is_empty())
159        .map(Arc::<str>::from)
160}
161
162/// The Selectors 4 `:empty` test: no element children, and no text node whose
163/// data is non-empty. Comments and processing instructions are ignored, as are
164/// empty text nodes.
165pub(crate) fn is_empty_element(snapshot: &DomSnapshot, id: NodeId) -> bool {
166    snapshot
167        .children(id)
168        .iter()
169        .all(|&child| match &snapshot.node(child).kind {
170            HtmlNodeType::Text(text) => text.is_empty(),
171            HtmlNodeType::Element { .. } => false,
172            _ => true,
173        })
174}
175
176pub(crate) fn element_sibling_infos(
177    snapshot: &DomSnapshot,
178    children: &[NodeId],
179    parent_lang: Option<&str>,
180    interaction: &InteractionState,
181) -> Vec<Option<ElementInfo>> {
182    let mut type_counts = HashMap::<Arc<str>, usize>::new();
183    for &child in children {
184        if let Some(tag_name) = snapshot.node(child).kind.tag_name() {
185            *type_counts.entry(Arc::from(tag_name)).or_default() += 1;
186        }
187    }
188    let element_count = type_counts.values().sum();
189
190    let mut seen_types = HashMap::<Arc<str>, usize>::new();
191    let mut previous_siblings = ElementChain::default();
192    let mut element_index = 0;
193    children
194        .iter()
195        .map(|&child| {
196            let mut info = match element_info(&snapshot.node(child).kind) {
197                Some(info) => info,
198                None => return None,
199            };
200            element_index += 1;
201            let seen = seen_types.entry(Arc::clone(&info.tag_name)).or_default();
202            *seen += 1;
203            info.element_index = element_index;
204            info.element_count = element_count;
205            info.type_index = *seen;
206            info.type_count = type_counts[&info.tag_name];
207            info.previous_siblings = previous_siblings.clone();
208            info.is_empty = is_empty_element(snapshot, child);
209            info.checked = snapshot.node(child).checked;
210            // `:lang()` sees the element's own tag, or the one inherited from
211            // the nearest ancestor that declares one.
212            info.lang = own_lang(snapshot, child).or_else(|| parent_lang.map(Arc::<str>::from));
213            info.hovered = interaction.hovered_path.contains(&child);
214            info.active = interaction.active_path.contains(&child);
215
216            let mut sibling = info.clone();
217            sibling.previous_siblings = ElementChain::default();
218            previous_siblings = previous_siblings.prepend(Some(sibling));
219            Some(info)
220        })
221        .collect()
222}
223
224/// Inherited values from parent, passed down through the tree.
225///
226/// `text_style` carries all inherited text/line-height values.
227/// `color_scheme` carries the element's used color scheme (resolved from the
228/// `color-scheme` property and the system preference).
229/// Add new fields here when additional deferred-resolution properties arise.
230#[derive(Clone, Default)]
231pub struct InheritedCss {
232    /// Inherited CSS custom properties, shared copy-on-write: descendants that
233    /// do not introduce new `--var` values reuse the parent's map via `Arc`
234    /// instead of deep-cloning it per element.
235    pub custom_props: Arc<Properties>,
236    pub text_style: TextStyle,
237    pub text_flow_style: TextFlowStyle,
238    pub color_scheme: ColorScheme,
239}
240
241/// Convert a resolved `Length` to an absolute pixel value for `LineHeight::Px`.
242pub(super) fn length_to_px(len: &Length, font_size: f32) -> f32 {
243    match len {
244        Length::Px(v) => *v,
245        Length::Percent(v) => v * font_size / 100.0,
246        Length::Add(a, b) => length_to_px(a, font_size) + length_to_px(b, font_size),
247        Length::Sub(a, b) => length_to_px(a, font_size) - length_to_px(b, font_size),
248        Length::Mul(a, f) => length_to_px(a, font_size) * f,
249        Length::Div(a, f) => length_to_px(a, font_size) / f,
250        _ => font_size * DEFAULT_LINE_FACTOR,
251    }
252}
253
254struct StackFrame {
255    dom: NodeId,
256    /// The language tag in effect for this node, so that descendants inherit
257    /// `:lang()` correctly.
258    lang: Option<Arc<str>>,
259    chain: ElementChain,
260    child: Arc<InheritedCss>,
261    kind: Option<NodeKind>,
262    style: Option<Style>,
263    parent_style: Style,
264    parent_container_style: ContainerStyle,
265    child_slots: Vec<ChildSlot>,
266    element_children: Vec<NodeId>,
267    /// Inherited CSS counter state: counter name -> current value. Cloned from
268    /// the parent and updated with this element's `counter-reset` /
269    /// `counter-increment` before being passed to children.
270    counter_state: HashMap<String, i32>,
271}
272
273enum ChildSlot {
274    Inline(LayoutChild, Box<InfoNode>),
275    Element(usize),
276}
277
278/// Builds the layout and render trees for a parsed HTML document.
279///
280/// The argument set mirrors [`build_layout_and_info_with_images`]; the
281/// document-facing entry point forwards to it with no images present.
282#[allow(clippy::too_many_arguments)]
283pub fn build_layout_and_info(
284    dom: &NodeRef<HtmlNodeType>,
285    resolved_styles: &ResolvedStyles,
286    measurer: Arc<dyn text::TextMeasurer>,
287    audio_sinks: Arc<dyn AudioSinkFactory>,
288    parent: InheritedCss,
289    chain: ElementChain,
290    system_color_scheme: ColorScheme,
291    scripting_mode: ScriptingMode,
292    viewport: (f32, f32),
293) -> (LayoutNode, InfoNode) {
294    build_layout_and_info_with_images(
295        dom,
296        resolved_styles,
297        measurer,
298        audio_sinks,
299        parent,
300        chain,
301        system_color_scheme,
302        scripting_mode,
303        viewport,
304        &HashMap::new(),
305    )
306}
307
308/// Builds layout and render trees with decoded images keyed by their `src` value.
309///
310/// `viewport` supplies the viewport size used to evaluate `@media` queries while
311/// resolving the style cascade (e.g. `@media (min-width: 768px)`).
312#[allow(clippy::too_many_arguments)]
313pub fn build_layout_and_info_with_images(
314    dom: &NodeRef<HtmlNodeType>,
315    resolved_styles: &ResolvedStyles,
316    measurer: Arc<dyn text::TextMeasurer>,
317    audio_sinks: Arc<dyn AudioSinkFactory>,
318    parent: InheritedCss,
319    chain: ElementChain,
320    system_color_scheme: ColorScheme,
321    scripting_mode: ScriptingMode,
322    viewport: (f32, f32),
323    images: &HashMap<String, Image>,
324) -> (LayoutNode, InfoNode) {
325    let (snapshot, _dom_refs) = DomSnapshot::from_tree(dom);
326    let media_environment = MediaEnvironment::new(viewport, system_color_scheme);
327    let rule_set = RuleSet::from_declarations(resolved_styles, &media_environment);
328    let (layout, info, _) = build_layout_and_info_from_snapshot(
329        &snapshot,
330        snapshot.roots()[0],
331        &rule_set,
332        measurer,
333        audio_sinks,
334        parent,
335        chain,
336        system_color_scheme,
337        scripting_mode,
338        images,
339        &HashMap::new(),
340        None,
341        None,
342        &InteractionState::default(),
343        &HashMap::new(),
344        &HashMap::new(),
345    );
346    (layout, info)
347}
348
349/// Builds layout and render trees from a [`DomSnapshot`].
350///
351/// `write_back_sender` (when set) is cloned per text input so value changes
352/// are reported as `(node id, value)` on the channel instead of mutating the
353/// DOM directly, which allows this function to run off the UI thread.
354///
355/// `system_color_scheme` seeds the root element's used color scheme and is
356/// used to resolve `color-scheme: light dark` and `light-dark()` values.
357///
358/// `match_cache` (when set) memoizes selector match results across builds that
359/// share the same DOM snapshot and rule set. The caller is responsible for
360/// clearing it when either input changes.
361#[allow(clippy::too_many_arguments)]
362pub fn build_layout_and_info_from_snapshot(
363    snapshot: &DomSnapshot,
364    root: NodeId,
365    rule_set: &RuleSet,
366    measurer: Arc<dyn text::TextMeasurer>,
367    audio_sinks: Arc<dyn AudioSinkFactory>,
368    parent: InheritedCss,
369    mut chain: ElementChain,
370    system_color_scheme: ColorScheme,
371    scripting_mode: ScriptingMode,
372    images: &HashMap<String, Image>,
373    audio: &HashMap<String, Arc<[u8]>>,
374    write_back_sender: Option<DomWriteBack>,
375    mut match_cache: Option<&mut SelectorMatchCache>,
376    interaction: &InteractionState,
377    existing_custom_nodes: &HashMap<u32, Arc<dyn CustomNode>>,
378    animated_overrides: &HashMap<NodeId, HashMap<String, CssValue>>,
379) -> (
380    LayoutNode,
381    InfoNode,
382    crate::engine::animation::AnimationInputs,
383) {
384    perf_scope!(total);
385
386    #[cfg(any(feature = "profile", debug_assertions))]
387    let mut css_match_time = std::time::Duration::ZERO;
388    #[cfg(any(feature = "profile", debug_assertions))]
389    let mut apply_decl_time = std::time::Duration::ZERO;
390    #[cfg(any(feature = "profile", debug_assertions))]
391    let mut custom_node_time = std::time::Duration::ZERO;
392    #[cfg(any(feature = "profile", debug_assertions))]
393    let mut text_layout_time = std::time::Duration::ZERO;
394    #[cfg(any(feature = "profile", debug_assertions))]
395    let mut exit_phase_time = std::time::Duration::ZERO;
396    #[cfg(any(feature = "profile", debug_assertions))]
397    let mut sibling_info_time = std::time::Duration::ZERO;
398    #[cfg(any(feature = "profile", debug_assertions))]
399    let mut whitespace_keep_time = std::time::Duration::ZERO;
400    #[cfg(any(feature = "profile", debug_assertions))]
401    let mut enter_prep_time = std::time::Duration::ZERO;
402    #[cfg(any(feature = "profile", debug_assertions))]
403    let mut child_slot_build_time = std::time::Duration::ZERO;
404    #[cfg(any(feature = "profile", debug_assertions))]
405    let mut leaf_assemble_time = std::time::Duration::ZERO;
406    #[cfg(any(feature = "profile", debug_assertions))]
407    let mut push_children_time = std::time::Duration::ZERO;
408    #[cfg(any(feature = "profile", debug_assertions))]
409    let mut exit_setup_time = std::time::Duration::ZERO;
410    #[cfg(any(feature = "profile", debug_assertions))]
411    let mut exit_build_time = std::time::Duration::ZERO;
412    #[cfg(any(feature = "profile", debug_assertions))]
413    let mut node_count = 0u64;
414
415    #[cfg(any(feature = "profile", debug_assertions))]
416    let mut cand_stats = CandidateMetrics::default();
417
418    let registry = ComponentRegistry::new();
419    /*
420     * Build the initial element chain for the root node.
421     */
422    if let Some(mut info) = element_info(&snapshot.node(root).kind) {
423        // The snapshot root has no parent, so it must not satisfy
424        // `:first-child` and friends.
425        info.has_parent = false;
426        info.is_empty = is_empty_element(snapshot, root);
427        info.hovered = interaction.hovered_path.contains(&root);
428        info.active = interaction.active_path.contains(&root);
429        chain = chain.prepend(Some(info));
430    }
431
432    // ── Explicit post-order stack (index-based to avoid borrow conflicts) ──
433
434    let mut stack: Vec<StackFrame> = Vec::new();
435    stack.push(StackFrame {
436        dom: root,
437        lang: own_lang(snapshot, root),
438        chain,
439        child: Arc::new(parent),
440        kind: None,
441        style: None,
442        parent_style: Style::default(),
443        parent_container_style: ContainerStyle::default(),
444        child_slots: Vec::new(),
445        element_children: Vec::new(),
446        counter_state: HashMap::new(),
447    });
448
449    let mut results: HashMap<NodeId, (LayoutNode, InfoNode)> = HashMap::new();
450    let mut animation_inputs: AnimationInputs = HashMap::new();
451
452    // We use an index instead of .last_mut() so that push/pop don't conflict
453    // with the mutable reference to the current frame.
454    while let Some(top_idx) = {
455        if stack.is_empty() {
456            None
457        } else {
458            Some(stack.len() - 1)
459        }
460    } {
461        // Phase check must happen BEFORE borrowing stack[top_idx] mutably.
462        let is_entered = stack[top_idx].kind.is_some();
463
464        if !is_entered {
465            // ── ENTER phase ──────────────────────────────────────────────
466            // Read frame state we need before taking any mutable action.
467            let chain_for_css = stack[top_idx].chain.clone();
468            let child_css = Arc::clone(&stack[top_idx].child);
469
470            let html_node = &snapshot.node(stack[top_idx].dom).kind;
471            let node_id = stack[top_idx].dom;
472            let mut text_style = child_css.text_style.clone();
473            let mut text_flow_style = child_css.text_flow_style;
474            let mut container_style = ContainerStyle::default();
475            if let HtmlNodeType::Element { tag_name, .. } = html_node {
476                container_style.tag = Some(tag_name.clone());
477            }
478            let mut style = Style::default();
479            let mut overflow = Overflow::default();
480            let parent_style = stack[top_idx].parent_style.clone();
481            let parent_container_style = stack[top_idx].parent_container_style.clone();
482            let parent_text_style = text_style.clone();
483            let parent_text_flow_style = text_flow_style;
484
485            // Inherit container visibility
486            container_style.visibility = parent_container_style.visibility;
487
488            // Collect CSS candidates.
489            perf_scope!(css_match);
490            let (candidates, custom_property_candidates, pseudo_candidates) =
491                if let HtmlNodeType::Element { .. } = html_node {
492                    let (candidates, custom_property_candidates, pseudo_candidates) =
493                        collect_candidates(
494                            rule_set,
495                            &chain_for_css,
496                            node_id,
497                            match_cache.as_deref_mut(),
498                            #[cfg(any(feature = "profile", debug_assertions))]
499                            &mut cand_stats,
500                        );
501                    (
502                        Some(candidates),
503                        Some(custom_property_candidates),
504                        pseudo_candidates,
505                    )
506                } else {
507                    (None, None, HashMap::new())
508                };
509            #[cfg(any(feature = "profile", debug_assertions))]
510            {
511                css_match_time += css_match.elapsed();
512            }
513
514            perf_scope!(enter_prep);
515            // Inherit custom properties by sharing the parent's map unless this
516            // element introduces new `--var` values (copy-on-write applies
517            // cascade-discovered custom properties lazily).
518            let mut custom_properties = Arc::clone(&child_css.custom_props);
519            if let Some(own) = custom_property_candidates
520                && !own.is_empty()
521            {
522                Arc::make_mut(&mut custom_properties).extend(own);
523            }
524
525            // Resolve the used color scheme for this element. `light-dark()`
526            // and system colors resolve against it, and it is inherited by
527            // descendants that do not set `color-scheme` themselves.
528            let used_color_scheme = {
529                let declaration = candidates
530                    .as_ref()
531                    .and_then(|c| c.get("color-scheme"))
532                    .and_then(|d| {
533                        DeclarationResolver::resolve_var(
534                            &d.value,
535                            &custom_properties,
536                            &mut HashSet::new(),
537                        )
538                    });
539                resolve_used_color_scheme(
540                    declaration.as_ref(),
541                    child_css.color_scheme,
542                    system_color_scheme,
543                )
544            };
545            #[cfg(any(feature = "profile", debug_assertions))]
546            {
547                enter_prep_time += enter_prep.elapsed();
548            }
549
550            // Apply presentational HTML attribute sizing (width/height
551            // attributes). Presentational hints have the lowest priority in the
552            // CSS cascade, below author stylesheets and inline declarations, so
553            // they are applied first and can be overridden by CSS.
554            for attr in ["width", "height"] {
555                if let Some(value) = html_node.get_attr(attr)
556                    && let Some(mut value) = resolve_inline_value(value)
557                {
558                    if let CssValue::Number(v) = value {
559                        value = CssValue::Length(v, Unit::Px);
560                    }
561                    apply_declaration(
562                        attr,
563                        &value,
564                        &mut style,
565                        &mut container_style,
566                        &mut text_style,
567                        &mut text_flow_style,
568                        &parent_style,
569                        &parent_container_style,
570                        &parent_text_style,
571                        &parent_text_flow_style,
572                        &mut overflow,
573                        used_color_scheme,
574                    );
575                }
576            }
577
578            // Apply CSS declarations.
579            perf_scope!(apply_decl);
580            let mut anim_effective: std::collections::HashMap<String, CssValue> =
581                std::collections::HashMap::new();
582            let mut anim_transition = anim_parse::TransitionInputs::default();
583            let mut anim_animation = anim_parse::AnimationInputs::default();
584            // Counter operations collected during declaration application.
585            let mut counter_resets: Vec<(String, i32)> = Vec::new();
586            let mut counter_increments: Vec<(String, i32)> = Vec::new();
587            if let Some(candidates) = &candidates {
588                // The candidates map dedupes per property name (cascade winner),
589                // but a shorthand and its longhand (e.g. `padding` and
590                // `padding-top`) are distinct keys. Iterating a HashMap applies
591                // them in random order, so re-sort by source order to make the
592                // cascade deterministic.
593                let mut candidates: Vec<_> = candidates.values().collect();
594                candidates.sort_by_key(|declaration| declaration.order);
595
596                for declaration in candidates {
597                    if declaration.name.starts_with("--") {
598                        continue;
599                    }
600                    let Some(value) = DeclarationResolver::resolve_var(
601                        &declaration.value,
602                        &custom_properties,
603                        &mut HashSet::new(),
604                    ) else {
605                        continue;
606                    };
607
608                    // Counter state mutations are tracked separately and
609                    // applied after the cascade loop.
610                    if declaration.name == "counter-reset" {
611                        counter_resets = parse_counter_reset(&value);
612                        continue;
613                    }
614                    if declaration.name == "counter-increment" {
615                        counter_increments = parse_counter_increment(&value);
616                        continue;
617                    }
618
619                    apply_declaration(
620                        &declaration.name,
621                        &value,
622                        &mut style,
623                        &mut container_style,
624                        &mut text_style,
625                        &mut text_flow_style,
626                        &parent_style,
627                        &parent_container_style,
628                        &parent_text_style,
629                        &parent_text_flow_style,
630                        &mut overflow,
631                        used_color_scheme,
632                    );
633                    collect_animation_decl(
634                        &declaration.name,
635                        &value,
636                        &mut anim_effective,
637                        &mut anim_transition,
638                        &mut anim_animation,
639                    );
640                }
641            }
642
643            // Compute counter state: start from inherited state, apply resets
644            // (set to specified value), then increments (add to value).
645            let mut counter_state = stack[top_idx].counter_state.clone();
646            for (name, val) in &counter_resets {
647                counter_state.insert(name.clone(), *val);
648            }
649            for (name, amount) in &counter_increments {
650                *counter_state.entry(name.clone()).or_insert(0) += amount;
651            }
652            // Persist updated counter state on the frame so children inherit it.
653            stack[top_idx].counter_state = counter_state.clone();
654
655            // Apply the element's inline `style` attribute. Inline styles are
656            // author-origin declarations with the highest specificity, so they
657            // override stylesheet rules — unless the stylesheet rule was
658            // `!important`, which still wins over a non-`!important` inline
659            // declaration.
660            if let Some(style_attr) = html_node.get_attr("style") {
661                let inline_declarations = resolve_inline_style(style_attr);
662                for (name, value, important) in &inline_declarations {
663                    if name.starts_with("--") {
664                        let stylesheet_important = candidates
665                            .as_ref()
666                            .and_then(|c| c.get(name))
667                            .is_some_and(|declaration| declaration.important);
668                        if *important || !stylesheet_important {
669                            set_inline_custom_property(
670                                Arc::make_mut(&mut custom_properties),
671                                name.clone(),
672                                value.clone(),
673                                *important,
674                            );
675                        }
676                    }
677                }
678                for (name, value, important) in inline_declarations {
679                    if name.starts_with("--") {
680                        continue;
681                    }
682                    let stylesheet_important = candidates
683                        .as_ref()
684                        .and_then(|c| c.get(&name))
685                        .is_some_and(|declaration| declaration.important);
686                    if !important && stylesheet_important {
687                        continue;
688                    }
689                    let Some(value) = DeclarationResolver::resolve_var(
690                        &value,
691                        &custom_properties,
692                        &mut HashSet::new(),
693                    ) else {
694                        continue;
695                    };
696                    apply_declaration(
697                        &name,
698                        &value,
699                        &mut style,
700                        &mut container_style,
701                        &mut text_style,
702                        &mut text_flow_style,
703                        &parent_style,
704                        &parent_container_style,
705                        &parent_text_style,
706                        &parent_text_flow_style,
707                        &mut overflow,
708                        used_color_scheme,
709                    );
710                    collect_animation_decl(
711                        &name,
712                        &value,
713                        &mut anim_effective,
714                        &mut anim_transition,
715                        &mut anim_animation,
716                    );
717                }
718            }
719            #[cfg(any(feature = "profile", debug_assertions))]
720            {
721                apply_decl_time += apply_decl.elapsed();
722            }
723
724            // Record animation inputs for this element (base values + resolved specs).
725            if matches!(html_node, HtmlNodeType::Element { .. }) {
726                let transitions = anim_parse::build_transition_specs(&anim_transition);
727                let animations = anim_parse::build_animation_specs(&anim_animation);
728                let has_animation = animations
729                    .iter()
730                    .any(|a| a.name != "none" && !a.name.is_empty());
731                if !transitions.is_empty() || has_animation {
732                    let mut base_values = std::collections::HashMap::new();
733                    for key in [
734                        "opacity",
735                        "width",
736                        "height",
737                        "top",
738                        "right",
739                        "bottom",
740                        "left",
741                        "margin-top",
742                        "margin-right",
743                        "margin-bottom",
744                        "margin-left",
745                        "padding-top",
746                        "padding-right",
747                        "padding-bottom",
748                        "padding-left",
749                        "font-size",
750                        "line-height",
751                        "color",
752                        "transform",
753                        "background-color",
754                        "border-top-color",
755                        "border-right-color",
756                        "border-bottom-color",
757                        "border-left-color",
758                    ] {
759                        if let Some(v) = anim_effective.get(key) {
760                            base_values.insert(key.to_string(), v.clone());
761                        }
762                    }
763
764                    // Fallback/computed base values to improve transition detection.
765                    base_values
766                        .entry("opacity".to_string())
767                        .or_insert(CssValue::Number(container_style.opacity));
768                    base_values.entry("color".to_string()).or_insert_with(|| {
769                        CssValue::Color(format!(
770                            "{:02x}{:02x}{:02x}{:02x}",
771                            text_style.color.0,
772                            text_style.color.1,
773                            text_style.color.2,
774                            text_style.color.3
775                        ))
776                    });
777                    base_values
778                        .entry("transform".to_string())
779                        .or_insert(CssValue::Keyword("none".into()));
780                    if let Some(color) = container_style.background.color {
781                        base_values
782                            .entry("background-color".to_string())
783                            .or_insert_with(|| {
784                                CssValue::Color(format!(
785                                    "{:02x}{:02x}{:02x}{:02x}",
786                                    color.0, color.1, color.2, color.3
787                                ))
788                            });
789                    }
790
791                    animation_inputs.insert(
792                        node_id,
793                        ElementAnimationState {
794                            transitions,
795                            animations,
796                            base_values,
797                        },
798                    );
799                }
800            }
801
802            // Apply animated overrides (runtime overlay) on top of base/inline styles.
803            if let Some(overrides) = animated_overrides.get(&stack[top_idx].dom) {
804                for (name, value) in overrides {
805                    let Some(value) = DeclarationResolver::resolve_var(
806                        value,
807                        &custom_properties,
808                        &mut HashSet::new(),
809                    ) else {
810                        continue;
811                    };
812                    apply_declaration(
813                        name,
814                        &value,
815                        &mut style,
816                        &mut container_style,
817                        &mut text_style,
818                        &mut text_flow_style,
819                        &parent_style,
820                        &parent_container_style,
821                        &parent_text_style,
822                        &parent_text_flow_style,
823                        &mut overflow,
824                        used_color_scheme,
825                    );
826                }
827            }
828
829            for layer in &mut container_style.background.layers {
830                if let Some(crate::engine::layouter::types::BackgroundImage::Image {
831                    source,
832                    image,
833                }) = &mut layer.image
834                {
835                    *image = images.get(source).cloned();
836                }
837            }
838
839            // `float` and `clear` only apply to in-flow boxes; out-of-flow
840            // (absolutely positioned) boxes ignore both.
841            if !style.position.kind.is_out_of_flow() {
842                style.float = match container_style.css_float {
843                    CssFloat::None => LayoutFloat::None,
844                    CssFloat::Left => LayoutFloat::Left,
845                    CssFloat::Right => LayoutFloat::Right,
846                };
847                style.clear = match container_style.css_clear {
848                    CssClear::None => LayoutClear::None,
849                    CssClear::Left => LayoutClear::Left,
850                    CssClear::Right => LayoutClear::Right,
851                    CssClear::Both => LayoutClear::Both,
852                };
853
854                if style.float != LayoutFloat::None {
855                    style.size.auto_behavior = AutoSizeBehavior::ShrinkToFit;
856                    if matches!(
857                        style.display.inner(),
858                        Some(InnerDisplay::Flow) | Some(InnerDisplay::FlowRoot)
859                    ) {
860                        style.display = Display::OutsideInner {
861                            outer: OuterDisplay::Inline,
862                            inner: InnerDisplay::FlowRoot,
863                        };
864                    }
865                }
866            }
867
868            // Absolutely positioned boxes are blockified before layout. The
869            // inner display type remains unchanged.
870            blockify_out_of_flow_positioned(&mut style);
871
872            // Resolve line-height.
873            style.line_height = match text_flow_style.line_height {
874                LineHeight::Number(factor) => Length::Px(text_flow_style.font_size * factor),
875                LineHeight::Normal => Length::Px(text_flow_style.font_size * DEFAULT_LINE_FACTOR),
876                LineHeight::Px(px) => Length::Px(px),
877            };
878            container_style.text_align = text_flow_style.text_align;
879
880            // ── Replaced <iframe> box ──
881            // An iframe renders its content document (grafted into the DOM as
882            // its children) inside a fixed 300×150 viewport that clips overflow,
883            // per the CSS default for the element. Width/height attributes or
884            // CSS override these defaults when supplied.
885            if html_node.tag_name() == Some("iframe") {
886                overflow.x = true;
887                overflow.y = true;
888                if matches!(style.size.width, LengthOrAuto::Auto) {
889                    style.size.width = LengthOrAuto::Length(Length::Px(300.0));
890                }
891                if matches!(style.size.height, LengthOrAuto::Auto) {
892                    style.size.height = LengthOrAuto::Length(Length::Px(150.0));
893                }
894            }
895
896            // ── Inline SVG ──
897            // An `<svg>` is atomic: its vector content rides on the layout
898            // node instead of being laid out as child boxes. Collect the
899            // viewBox and its single-color `<path>` subtree up front.
900            let svg_content = if html_node.tag_name() == Some("svg") {
901                collect_svg(
902                    snapshot,
903                    stack[top_idx].dom,
904                    used_color_scheme,
905                    text_style.color,
906                )
907            } else {
908                None
909            };
910
911            // Per CSS-SIZING the auto height of an atomic SVG with a definite
912            // width and an intrinsic aspect ratio (its viewBox) is the width
913            // divided by that ratio (used when only a width is specified,
914            // e.g. `svg { width: 100px }` with no height).
915            if let Some(SvgContent {
916                view_box: (_, _, vbw, vbh),
917                ..
918            }) = svg_content.as_ref()
919                && *vbw > 0.0
920                && *vbh > 0.0
921                && matches!(style.size.height, LengthOrAuto::Auto)
922                && let Some(width) = style.size.width.clone().length()
923                && let Length::Px(width_px) = width
924                && width_px > 0.0
925            {
926                style.size.height = LengthOrAuto::Length(Length::Px(width_px * *vbh / *vbw));
927            }
928
929            // An `<svg>` has no layout children; capture this early so it can
930            // survive the move of `svg_content` into the kind construction.
931            let is_svg_element = svg_content.is_some();
932
933            let child = Arc::new(InheritedCss {
934                custom_props: custom_properties,
935                text_style: text_style.clone(),
936                text_flow_style,
937                color_scheme: used_color_scheme,
938            });
939
940            if let HtmlNodeType::Text(_) = html_node {
941                unreachable!();
942            }
943
944            // ── Custom / replaced element (leaf) ──
945            if let Some(tag) = html_node.tag_name()
946                && registry.tags().contains(&tag)
947            {
948                perf_scope!(custom_node);
949                // Replaced elements (button/img/input) size by their intrinsic
950                // content when auto-sized, not by filling the containing block.
951                style.size.auto_behavior = AutoSizeBehavior::ShrinkToFit;
952                let media_source = html_node.get_attr("src").map(str::to_string).or_else(|| {
953                    snapshot
954                        .children(stack[top_idx].dom)
955                        .iter()
956                        .find_map(|&child| {
957                            let child = &snapshot.node(child).kind;
958                            (child.tag_name() == Some("source"))
959                                .then(|| child.get_attr("src").map(str::to_string))
960                                .flatten()
961                        })
962                });
963                let reused = existing_custom_nodes.get(&stack[top_idx].dom).cloned();
964                if let Some(node) = &reused {
965                    let checked = snapshot.node(stack[top_idx].dom).checked;
966                    node.set_checked(checked);
967                }
968                let node = reused.unwrap_or_else(|| {
969                    registry
970                        .create(&CustomNodeContext {
971                            tag,
972                            media_source: media_source.as_deref(),
973                            container_style: &container_style,
974                            text_style: &text_style,
975                            measurer: Arc::clone(&measurer),
976                            audio_sinks: &*audio_sinks,
977                            images,
978                            audio,
979                            get_attr: &|name| html_node.get_attr(name).map(str::to_string),
980                            write_back: write_back_sender
981                                .as_ref()
982                                .map(|sender| (sender.clone(), stack[top_idx].dom)),
983                            dom_snapshot: snapshot,
984                            dom_id: stack[top_idx].dom,
985                        })
986                        .expect("registry must handle every tag it reports")
987                });
988
989                let bridge = CustomNodeBridge::new_content(Arc::clone(&node), &style);
990                let container_opacity = container_style.opacity;
991                let kind = NodeKind::Custom {
992                    node,
993                    scroll_x: overflow.x,
994                    scroll_y: overflow.y,
995                    scroll_offset_x: 0.0,
996                    scroll_offset_y: 0.0,
997                    style: container_style,
998                    layout_style: style.clone(),
999                    text_style: text_style.clone(),
1000                    text_flow_style,
1001                };
1002
1003                // ::before / ::after generated content for custom/replaced
1004                // elements, mirroring the element path: the pseudo slots flow
1005                // inline around the element's intrinsic box.
1006                let mut layout_children: Vec<LayoutChild> = Vec::new();
1007                let mut info_children: Vec<InfoNode> = Vec::new();
1008                let add_pseudo_slot =
1009                    |pseudo: &Properties,
1010                     layout_children: &mut Vec<LayoutChild>,
1011                     info_children: &mut Vec<InfoNode>| {
1012                        let dom_node = &snapshot.node(stack[top_idx].dom);
1013                        let get_attr = |name: &str| dom_node.kind.get_attr(name).map(|s| s.to_string());
1014                        if let Some(ChildSlot::Inline(fragment, pseudo_info)) = build_pseudo_slot(
1015                            pseudo,
1016                            &child_css.custom_props,
1017                            &text_style,
1018                            text_flow_style,
1019                            used_color_scheme,
1020                            &*measurer,
1021                            images,
1022                            &get_attr,
1023                            &stack[top_idx].counter_state,
1024                        ) {
1025                            layout_children.push(fragment);
1026                            info_children.push(*pseudo_info);
1027                        }
1028                    };
1029                let hidden = style.display == Display::None;
1030                if !hidden && let Some(pseudo) = pseudo_candidates.get(&PseudoElement::Before) {
1031                    add_pseudo_slot(pseudo, &mut layout_children, &mut info_children);
1032                }
1033                layout_children.push((style.clone(), bridge).into());
1034                // The intrinsic box is drawn by the custom node itself, but it
1035                // still owns a layout slot; mirror it with an empty info entry
1036                // so the following pseudo slots line up with their siblings.
1037                info_children.push(InfoNode {
1038                    kind: NodeKind::Container {
1039                        scroll_x: false,
1040                        scroll_y: false,
1041                        scroll_offset_x: 0.0,
1042                        scroll_offset_y: 0.0,
1043                        style: ContainerStyle::default(),
1044                        role: ContainerRole::Normal,
1045                    },
1046                    children: Vec::new(),
1047                    dom_id: None,
1048                });
1049                if !hidden && let Some(pseudo) = pseudo_candidates.get(&PseudoElement::After) {
1050                    add_pseudo_slot(pseudo, &mut layout_children, &mut info_children);
1051                }
1052
1053                let layout = LayoutNode::with_children(style.clone(), layout_children);
1054                let info = InfoNode {
1055                    kind,
1056                    children: info_children,
1057                    dom_id: Some(stack[top_idx].dom),
1058                };
1059                let ptr = stack[top_idx].dom;
1060                // Ensure animation inputs exist for custom/replaced elements.
1061                if let std::collections::hash_map::Entry::Vacant(e) = animation_inputs.entry(ptr) {
1062                    let transitions = anim_parse::build_transition_specs(&anim_transition);
1063                    let animations = anim_parse::build_animation_specs(&anim_animation);
1064                    let has_animation = animations
1065                        .iter()
1066                        .any(|a| a.name != "none" && !a.name.is_empty());
1067                    if !transitions.is_empty() || has_animation {
1068                        let mut base_values = std::collections::HashMap::new();
1069                        for key in [
1070                            "opacity",
1071                            "width",
1072                            "height",
1073                            "top",
1074                            "right",
1075                            "bottom",
1076                            "left",
1077                            "margin-top",
1078                            "margin-right",
1079                            "margin-bottom",
1080                            "margin-left",
1081                            "padding-top",
1082                            "padding-right",
1083                            "padding-bottom",
1084                            "padding-left",
1085                            "font-size",
1086                            "line-height",
1087                            "color",
1088                            "transform",
1089                            "background-color",
1090                            "border-top-color",
1091                            "border-right-color",
1092                            "border-bottom-color",
1093                            "border-left-color",
1094                        ] {
1095                            if let Some(v) = anim_effective.get(key) {
1096                                base_values.insert(key.to_string(), v.clone());
1097                            }
1098                        }
1099                        base_values
1100                            .entry("opacity".to_string())
1101                            .or_insert(CssValue::Number(container_opacity));
1102                        base_values.entry("color".to_string()).or_insert_with(|| {
1103                            CssValue::Color(format!(
1104                                "{:02x}{:02x}{:02x}{:02x}",
1105                                text_style.color.0,
1106                                text_style.color.1,
1107                                text_style.color.2,
1108                                text_style.color.3
1109                            ))
1110                        });
1111                        base_values
1112                            .entry("transform".to_string())
1113                            .or_insert(CssValue::Keyword("none".into()));
1114                        e.insert(ElementAnimationState {
1115                            transitions,
1116                            animations,
1117                            base_values,
1118                        });
1119                    }
1120                }
1121                results.insert(ptr, (layout, info));
1122                #[cfg(any(feature = "profile", debug_assertions))]
1123                {
1124                    custom_node_time += custom_node.elapsed();
1125                    node_count += 1;
1126                }
1127                stack.pop();
1128                continue;
1129            }
1130
1131            // ── Element node ──
1132            let is_link = html_node.tag_name() == Some("a") && html_node.get_attr("href").is_some();
1133
1134            let role = match html_node.tag_name() {
1135                Some("table") => ContainerRole::Table,
1136                Some("thead" | "tbody" | "tfoot") => ContainerRole::TableRowGroup,
1137                Some("tr") => ContainerRole::TableRow,
1138                Some("td" | "th") => ContainerRole::TableCell,
1139                Some("caption") => ContainerRole::TableCaption,
1140                _ if is_link => ContainerRole::Link {
1141                    href: html_node.get_attr("href").unwrap().to_string(),
1142                },
1143                _ => ContainerRole::Normal,
1144            };
1145
1146            let kind = if let Some(SvgContent {
1147                view_box, shapes, ..
1148            }) = svg_content
1149            {
1150                NodeKind::Svg {
1151                    view_box,
1152                    shapes,
1153                    scroll_x: overflow.x,
1154                    scroll_y: overflow.y,
1155                    scroll_offset_x: 0.0,
1156                    scroll_offset_y: 0.0,
1157                    style: container_style,
1158                }
1159            } else {
1160                NodeKind::Container {
1161                    scroll_x: overflow.x,
1162                    scroll_y: overflow.y,
1163                    scroll_offset_x: 0.0,
1164                    scroll_offset_y: 0.0,
1165                    style: container_style,
1166                    role,
1167                }
1168            };
1169
1170            // Table → flex overrides
1171            if let Some(tag) = html_node.tag_name() {
1172                match tag {
1173                    "table" | "tbody" | "thead" | "tfoot" => {
1174                        style.display = Display::OutsideInner {
1175                            outer: OuterDisplay::Block,
1176                            inner: InnerDisplay::Flex,
1177                        };
1178                        style.flex_direction = FlexDirection::Column;
1179                    }
1180                    "tr" => {
1181                        style.display = Display::OutsideInner {
1182                            outer: OuterDisplay::Block,
1183                            inner: InnerDisplay::Flex,
1184                        };
1185                        style.flex_direction = FlexDirection::Row;
1186                    }
1187                    _ => {}
1188                }
1189            }
1190
1191            let mut child_slots: Vec<ChildSlot> = Vec::new();
1192            let mut element_kids: Vec<NodeId> = Vec::new();
1193
1194            perf_scope!(child_slot_build);
1195            // `<svg>` is atomic: its markup (path/text/whitespace) never forms
1196            // layout boxes, so no children are slotted at all.
1197            if style.display != Display::None && !is_svg_element {
1198                // ── ::before generated content ──
1199                if let Some(pseudo) = pseudo_candidates.get(&PseudoElement::Before) {
1200                    let dom_node = &snapshot.node(stack[top_idx].dom);
1201                    let get_attr = |name: &str| dom_node.kind.get_attr(name).map(|s| s.to_string());
1202                    if let Some(slot) = build_pseudo_slot(
1203                        pseudo,
1204                        &child_css.custom_props,
1205                        &text_style,
1206                        text_flow_style,
1207                        used_color_scheme,
1208                        &*measurer,
1209                        images,
1210                        &get_attr,
1211                        &stack[top_idx].counter_state,
1212                    )
1213                    {
1214                        child_slots.push(slot);
1215                        #[cfg(any(feature = "profile", debug_assertions))]
1216                        {
1217                            node_count += 1;
1218                        }
1219                    }
1220                }
1221                let parent_tag_name = snapshot.node(stack[top_idx].dom).kind.tag_name();
1222                for &child in snapshot.children(stack[top_idx].dom) {
1223                    let child_node = &snapshot.node(child).kind;
1224                    if let HtmlNodeType::Text(t) = child_node {
1225                        let t = if parent_tag_name == Some("pre") {
1226                            let t = t.strip_prefix('\n').unwrap_or(t);
1227                            normalize_whitespace(t, text_flow_style.white_space)
1228                        } else if t.chars().all(is_css_newline)
1229                            && matches!(
1230                                text_flow_style.white_space,
1231                                WhiteSpace::Normal | WhiteSpace::Nowrap
1232                            )
1233                        {
1234                            continue;
1235                        } else {
1236                            normalize_whitespace(t, text_flow_style.white_space)
1237                        };
1238
1239                        let t = match text_style.text_transform {
1240                            TextTransform::None => t.into_owned(),
1241                            TextTransform::Uppercase => t.to_ascii_uppercase(),
1242                            TextTransform::Lowercase => t.to_ascii_lowercase(),
1243                        };
1244                        perf_scope!(text_layout);
1245                        let (layouter, kind) =
1246                            create_text_node(t, text_style.clone(), text_flow_style, &*measurer);
1247                        #[cfg(any(feature = "profile", debug_assertions))]
1248                        {
1249                            text_layout_time += text_layout.elapsed();
1250                            node_count += 1;
1251                        }
1252                        let mut inline_style = style.clone();
1253                        inline_style.display = Display::OutsideInner {
1254                            outer: OuterDisplay::Inline,
1255                            inner: InnerDisplay::Flow,
1256                        };
1257                        child_slots.push(ChildSlot::Inline(
1258                            (inline_style, layouter).into(),
1259                            Box::new(InfoNode {
1260                                kind,
1261                                children: Vec::new(),
1262                                dom_id: Some(child),
1263                            }),
1264                        ));
1265                    } else if child_node.tag_name() == Some("br") {
1266                        child_slots.push(ChildSlot::Inline(
1267                            ItemFragment::LineBreak.into(),
1268                            Box::new(InfoNode {
1269                                kind: NodeKind::LineBreak,
1270                                children: Vec::new(),
1271                                dom_id: Some(child),
1272                            }),
1273                        ));
1274                    } else if child_node.tag_name() == Some("noscript")
1275                        && scripting_mode == ScriptingMode::Enabled
1276                    {
1277                        // Skip
1278                    } else if child_node.tag_name() == Some("template")
1279                        || matches!(child_node, HtmlNodeType::DocumentFragment)
1280                    {
1281                        // Skip: template contents are inert and never rendered.
1282                    } else {
1283                        child_slots.push(ChildSlot::Element(element_kids.len()));
1284                        element_kids.push(child);
1285                    }
1286                }
1287
1288                // ── ::after generated content ──
1289                if let Some(pseudo) = pseudo_candidates.get(&PseudoElement::After) {
1290                    let dom_node = &snapshot.node(stack[top_idx].dom);
1291                    let get_attr = |name: &str| dom_node.kind.get_attr(name).map(|s| s.to_string());
1292                    if let Some(slot) = build_pseudo_slot(
1293                        pseudo,
1294                        &child_css.custom_props,
1295                        &text_style,
1296                        text_flow_style,
1297                        used_color_scheme,
1298                        &*measurer,
1299                        images,
1300                        &get_attr,
1301                        &stack[top_idx].counter_state,
1302                    )
1303                    {
1304                        child_slots.push(slot);
1305                        #[cfg(any(feature = "profile", debug_assertions))]
1306                        {
1307                            node_count += 1;
1308                        }
1309                    }
1310                }
1311            }
1312            #[cfg(any(feature = "profile", debug_assertions))]
1313            {
1314                child_slot_build_time += child_slot_build.elapsed();
1315            }
1316
1317            if element_kids.is_empty() {
1318                // ── No element children → leaf, build immediately ──
1319                perf_scope!(leaf_assemble);
1320                perf_scope!(whitespace_keep);
1321                let keep = compute_whitespace_keep(&child_slots, &[]);
1322                #[cfg(any(feature = "profile", debug_assertions))]
1323                {
1324                    whitespace_keep_time += whitespace_keep.elapsed();
1325                }
1326                let (layout_children, info_children): (Vec<_>, Vec<_>) = child_slots
1327                    .into_iter()
1328                    .enumerate()
1329                    .filter_map(|(i, slot)| {
1330                        if !keep[i] {
1331                            return None;
1332                        }
1333                        match slot {
1334                            ChildSlot::Inline(layout, info) => Some((layout, *info)),
1335                            ChildSlot::Element(_) => None,
1336                        }
1337                    })
1338                    .unzip();
1339                let layout = LayoutNode::with_children(style.clone(), layout_children);
1340                let info = InfoNode {
1341                    kind,
1342                    children: info_children,
1343                    dom_id: Some(stack[top_idx].dom),
1344                };
1345                let ptr = stack[top_idx].dom;
1346                results.insert(ptr, (layout, info));
1347                #[cfg(any(feature = "profile", debug_assertions))]
1348                {
1349                    leaf_assemble_time += leaf_assemble.elapsed();
1350                    node_count += 1;
1351                }
1352                stack.pop();
1353            } else {
1354                // ── Has element children → save state, push children ──
1355                perf_scope!(push_children);
1356                let parent_chain = stack[top_idx].chain.clone();
1357                let parent_container = match &kind {
1358                    NodeKind::Container { style, .. } => style.clone(),
1359                    _ => ContainerStyle::default(),
1360                };
1361                stack[top_idx].kind = Some(kind);
1362                stack[top_idx].parent_style = style.clone();
1363                stack[top_idx].parent_container_style = parent_container;
1364                stack[top_idx].style = Some(style);
1365                stack[top_idx].child = child;
1366                stack[top_idx].child_slots = child_slots;
1367                stack[top_idx].element_children = element_kids;
1368
1369                // Build child chains and push frames.
1370                // Clone element_kids before the immutable borrow below
1371                // so we don't hold &mut stack[] while pushing.
1372                let kids_for_push: Vec<_> = {
1373                    let f = &stack[top_idx];
1374                    f.element_children.clone()
1375                };
1376                let child_css = Arc::clone(&stack[top_idx].child);
1377                perf_scope!(sibling_info);
1378                let parent_lang = stack[top_idx]
1379                    .lang
1380                    .clone()
1381                    .or_else(|| own_lang(snapshot, stack[top_idx].dom));
1382                let mut kid_infos = element_sibling_infos(
1383                    snapshot,
1384                    &kids_for_push,
1385                    parent_lang.as_deref(),
1386                    interaction,
1387                );
1388                if !is_element_node(snapshot, stack[top_idx].dom) {
1389                    for info in kid_infos.iter_mut().flatten() {
1390                        info.has_parent = false;
1391                    }
1392                }
1393                #[cfg(any(feature = "profile", debug_assertions))]
1394                {
1395                    sibling_info_time += sibling_info.elapsed();
1396                }
1397                let parent_style_for_children = stack[top_idx].parent_style.clone();
1398                let parent_container_for_children = stack[top_idx].parent_container_style.clone();
1399                for (&kid, info) in kids_for_push.iter().zip(kid_infos).rev() {
1400                    let kid_lang = info.as_ref().and_then(|info| info.lang.clone());
1401                    stack.push(StackFrame {
1402                        dom: kid,
1403                        lang: kid_lang,
1404                        chain: parent_chain.prepend(info),
1405                        child: Arc::clone(&child_css),
1406                        kind: None,
1407                        style: None,
1408                        parent_style: parent_style_for_children.clone(),
1409                        parent_container_style: parent_container_for_children.clone(),
1410                        child_slots: Vec::new(),
1411                        element_children: Vec::new(),
1412                        counter_state: stack[top_idx].counter_state.clone(),
1413                    });
1414                }
1415                #[cfg(any(feature = "profile", debug_assertions))]
1416                {
1417                    push_children_time += push_children.elapsed();
1418                }
1419            }
1420        } else {
1421            // ── EXIT phase ────────────────────────────────────────────────
1422            // Take ownership of frame data for building results.
1423            perf_scope!(exit_phase);
1424            perf_scope!(exit_setup);
1425
1426            let frame = stack.swap_remove(top_idx);
1427
1428            let style = frame.style.as_ref().unwrap().clone();
1429            let kind = frame.kind.as_ref().unwrap().clone();
1430
1431            // Collect element children results.
1432            let mut element_results: Vec<(LayoutChild, InfoNode)> = Vec::new();
1433
1434            for &kid in &frame.element_children {
1435                if let Some((child_layout, child_info)) = results.remove(&kid) {
1436                    element_results.push((child_layout.into(), child_info));
1437                }
1438            }
1439
1440            // Handle html→body background inheritance.
1441            let mut final_kind = kind;
1442            if snapshot.node(frame.dom).kind.tag_name() == Some("html") {
1443                let should_inherit = final_kind.is_container_with_transparent_bg();
1444                if should_inherit {
1445                    for (_, child_info) in &element_results {
1446                        if child_info
1447                            .dom_id
1448                            .and_then(|id| snapshot.node(id).kind.tag_name())
1449                            == Some("body")
1450                        {
1451                            let child_bg = child_info.kind.container_bg();
1452                            if let Some(bg) = child_bg
1453                                && let NodeKind::Container { ref mut style, .. } = final_kind
1454                            {
1455                                style.background = bg.clone();
1456                            }
1457                        }
1458                    }
1459                }
1460            }
1461
1462            #[cfg(any(feature = "profile", debug_assertions))]
1463            {
1464                exit_setup_time += exit_setup.elapsed();
1465            }
1466
1467            perf_scope!(exit_build);
1468            let mut element_results: Vec<_> = element_results.into_iter().map(Some).collect();
1469
1470            // Whitespace-only text nodes between two block-level siblings, or adjacent
1471            // to a `<br>`, would otherwise create stray inline boxes and spurious line
1472            // boxes in block, flex, and grid containers. Drop them now that every
1473            // sibling's display is resolved.
1474            perf_scope!(whitespace_keep);
1475            let keep = compute_whitespace_keep(&frame.child_slots, &element_results);
1476            #[cfg(any(feature = "profile", debug_assertions))]
1477            {
1478                whitespace_keep_time += whitespace_keep.elapsed();
1479            }
1480
1481            let mut all_layout: Vec<LayoutChild> = Vec::with_capacity(frame.child_slots.len());
1482            let mut all_info: Vec<InfoNode> = Vec::with_capacity(frame.child_slots.len());
1483
1484            for (i, slot) in frame.child_slots.into_iter().enumerate() {
1485                if !keep[i] {
1486                    continue;
1487                }
1488                let (lc, ic) = match slot {
1489                    ChildSlot::Inline(layout, info) => (layout, *info),
1490                    ChildSlot::Element(index) => element_results[index]
1491                        .take()
1492                        .expect("element child result must exist"),
1493                };
1494                all_layout.push(lc);
1495                all_info.push(ic);
1496            }
1497
1498            // Collapsible whitespace at a block boundary does not create an
1499            // anonymous line box. Keeping indentation-only DOM text here made
1500            // a block such as Scratch's `.page` start one default line-height
1501            // below the viewport.
1502            let keep: Vec<bool> = (0..all_layout.len())
1503                .map(|index| {
1504                    let white_space = match &all_info[index].kind {
1505                        NodeKind::Text { flow_style, .. } => flow_style.white_space,
1506                        _ => WhiteSpace::Normal,
1507                    };
1508                    let collapsible = is_collapsible_whitespace_info(&all_info[index], white_space);
1509                    let next_to_block = index == 0
1510                        || index + 1 == all_layout.len()
1511                        || index
1512                            .checked_sub(1)
1513                            .is_some_and(|previous| is_block_layout_child(&all_layout[previous]))
1514                        || all_layout.get(index + 1).is_some_and(is_block_layout_child);
1515                    !(collapsible && next_to_block)
1516                })
1517                .collect();
1518            all_layout = all_layout
1519                .into_iter()
1520                .zip(&keep)
1521                .filter_map(|(layout, keep)| keep.then_some(layout))
1522                .collect();
1523            all_info = all_info
1524                .into_iter()
1525                .zip(keep)
1526                .filter_map(|(info, keep)| keep.then_some(info))
1527                .collect();
1528
1529            // Grid and flex items are blockified by CSS Display. Keeping an
1530            // inline direct child makes its text-flow coordinates remain in
1531            // the parent's inline space, so item placement cannot move the
1532            // text with its box.
1533            if matches!(
1534                style.display.inner(),
1535                Some(InnerDisplay::Grid | InnerDisplay::Flex)
1536            ) {
1537                if style.display.inner() == Some(InnerDisplay::Grid) {
1538                    let columns = explicit_grid_track_count(&style.grid_template_columns);
1539                    let rows = explicit_grid_track_count(&style.grid_template_rows);
1540                    for child in &mut all_layout {
1541                        if let LayoutChild::Node(child) = child {
1542                            resolve_named_grid_area(child, &style.grid_template_areas);
1543                            resolve_grid_end_line(&mut child.style.grid_column, columns);
1544                            resolve_grid_end_line(&mut child.style.grid_row, rows);
1545                        }
1546                    }
1547                }
1548                for child in &mut all_layout {
1549                    if let LayoutChild::Node(child) = child
1550                        && child.style.display.outer() == Some(OuterDisplay::Inline)
1551                        && !child.style.position.kind.is_out_of_flow()
1552                        && let Display::OutsideInner { inner, .. } = child.style.display
1553                    {
1554                        child.style.display = Display::OutsideInner {
1555                            outer: OuterDisplay::Block,
1556                            inner,
1557                        };
1558                    }
1559                }
1560            }
1561
1562            let layout = LayoutNode::with_children(style, all_layout);
1563            let info = InfoNode {
1564                kind: final_kind,
1565                children: all_info,
1566                dom_id: Some(frame.dom),
1567            };
1568            let ptr = frame.dom;
1569            results.insert(ptr, (layout, info));
1570            #[cfg(any(feature = "profile", debug_assertions))]
1571            {
1572                exit_build_time += exit_build.elapsed();
1573                exit_phase_time += exit_phase.elapsed();
1574                node_count += 1;
1575            }
1576        }
1577    }
1578
1579    profile_log!(
1580        target: "LayoutRun",
1581        log::Level::Info,
1582        "[LayoutMetrics] total: {:?} (nodes: {})",
1583        total.elapsed(),
1584        node_count,
1585    );
1586    profile_log!(
1587        target: "LayoutRun",
1588        log::Level::Info,
1589        "[LayoutMetrics] css_match: {:?} | apply_decl: {:?}",
1590        css_match_time,
1591        apply_decl_time,
1592    );
1593    profile_log!(
1594        target: "LayoutRun",
1595        log::Level::Info,
1596        "[LayoutCandidates] elements: {} | examined: {} | matched: {}",
1597        cand_stats.elements_checked,
1598        cand_stats.candidates_examined,
1599        cand_stats.selectors_matched,
1600    );
1601    profile_log!(
1602        target: "LayoutRun",
1603        log::Level::Info,
1604        "[LayoutCandidates] query_time: {:?} | sel_match_time: {:?} | insert_time: {:?}",
1605        cand_stats.query_candidates_time,
1606        cand_stats.selector_match_time,
1607        cand_stats.cascade_insert_time,
1608    );
1609    profile_log!(
1610        target: "LayoutRun",
1611        log::Level::Info,
1612        "[LayoutMetrics] custom_node: {:?} | text_layout: {:?} | exit_phase: {:?}",
1613        custom_node_time,
1614        text_layout_time,
1615        exit_phase_time,
1616    );
1617    profile_log!(
1618        target: "LayoutRun",
1619        log::Level::Info,
1620        "[LayoutMetrics] sibling_info: {:?} | whitespace_keep: {:?}",
1621        sibling_info_time,
1622        whitespace_keep_time,
1623    );
1624    profile_log!(
1625        target: "LayoutRun",
1626        log::Level::Info,
1627        "[LayoutMetrics] enter_prep: {:?} | child_slot_build: {:?} | leaf_assemble: {:?}",
1628        enter_prep_time,
1629        child_slot_build_time,
1630        leaf_assemble_time,
1631    );
1632    profile_log!(
1633        target: "LayoutRun",
1634        log::Level::Info,
1635        "[LayoutMetrics] push_children: {:?} | exit_setup: {:?} | exit_build: {:?}",
1636        push_children_time,
1637        exit_setup_time,
1638        exit_build_time,
1639    );
1640    profile_log!(
1641        target: "LayoutRun",
1642        log::Level::Info,
1643        "[LayoutMetrics] rest: {:?}",
1644        total.elapsed().saturating_sub(
1645            css_match_time
1646                + apply_decl_time
1647                + custom_node_time
1648                + enter_prep_time
1649                + child_slot_build_time
1650                + leaf_assemble_time
1651                + push_children_time
1652                + exit_setup_time
1653                + exit_build_time
1654        ),
1655    );
1656
1657    let (layout, info_node) = results
1658        .remove(&root)
1659        .expect("root must have been processed");
1660    (layout, info_node, animation_inputs)
1661}
1662
1663fn collect_animation_decl(
1664    name: &str,
1665    value: &CssValue,
1666    effective: &mut std::collections::HashMap<String, CssValue>,
1667    transition: &mut anim_parse::TransitionInputs,
1668    animation: &mut anim_parse::AnimationInputs,
1669) {
1670    let lname = name.to_ascii_lowercase();
1671    match lname.as_str() {
1672        "transition-property" => transition.property = Some(value.clone()),
1673        "transition-duration" => transition.duration = Some(value.clone()),
1674        "transition-delay" => transition.delay = Some(value.clone()),
1675        "transition-timing-function" => transition.timing_function = Some(value.clone()),
1676        "transition" => {
1677            // MVP shorthand: per-comma group parse property, times, easing.
1678            let groups = split_css_comma_groups(value);
1679            let mut props: Vec<CssValue> = Vec::new();
1680            let mut durs: Vec<CssValue> = Vec::new();
1681            let mut delays: Vec<CssValue> = Vec::new();
1682            let mut easings: Vec<CssValue> = Vec::new();
1683            for group in groups {
1684                let mut prop: Option<CssValue> = None;
1685                let mut times: Vec<CssValue> = Vec::new();
1686                let mut easing: Option<CssValue> = None;
1687                for item in flatten_css_value_items(&group) {
1688                    if anim_parse::parse_time_ms(&item).is_some() {
1689                        if times.len() < 2 {
1690                            times.push(item);
1691                        }
1692                        continue;
1693                    }
1694                    if easing.is_none() && anim_parse::parse_easing(&item).is_some() {
1695                        easing = Some(item);
1696                        continue;
1697                    }
1698                    if prop.is_none()
1699                        && let CssValue::Keyword(_) | CssValue::String(_) = item
1700                    {
1701                        prop = Some(item);
1702                    }
1703                }
1704                props.push(prop.unwrap_or_else(|| CssValue::Keyword("all".into())));
1705                durs.push(
1706                    times
1707                        .first()
1708                        .cloned()
1709                        .unwrap_or(CssValue::Length(0.0, Unit::S)),
1710                );
1711                delays.push(
1712                    times
1713                        .get(1)
1714                        .cloned()
1715                        .unwrap_or(CssValue::Length(0.0, Unit::S)),
1716                );
1717                easings.push(easing.unwrap_or_else(|| CssValue::Keyword("ease".into())));
1718            }
1719            if !props.is_empty() {
1720                transition.property = Some(join_css_comma_groups(props));
1721                transition.duration = Some(join_css_comma_groups(durs));
1722                transition.delay = Some(join_css_comma_groups(delays));
1723                transition.timing_function = Some(join_css_comma_groups(easings));
1724            }
1725        }
1726        "animation-name" => animation.name = Some(value.clone()),
1727        "animation-duration" => animation.duration = Some(value.clone()),
1728        "animation-delay" => animation.delay = Some(value.clone()),
1729        "animation-timing-function" => animation.timing_function = Some(value.clone()),
1730        "animation-iteration-count" => animation.iteration_count = Some(value.clone()),
1731        "animation-direction" => animation.direction = Some(value.clone()),
1732        "animation-fill-mode" => animation.fill_mode = Some(value.clone()),
1733        "animation-play-state" => animation.play_state = Some(value.clone()),
1734        "animation" => {
1735            // MVP shorthand: parse common tokens per comma group.
1736            let groups = split_css_comma_groups(value);
1737            let mut names: Vec<CssValue> = Vec::new();
1738            let mut durs: Vec<CssValue> = Vec::new();
1739            let mut delays: Vec<CssValue> = Vec::new();
1740            let mut easings: Vec<CssValue> = Vec::new();
1741            let mut iters: Vec<CssValue> = Vec::new();
1742            let mut dirs: Vec<CssValue> = Vec::new();
1743            let mut fills: Vec<CssValue> = Vec::new();
1744            let mut states: Vec<CssValue> = Vec::new();
1745            for group in groups {
1746                let mut name: Option<CssValue> = None;
1747                let mut times: Vec<CssValue> = Vec::new();
1748                let mut easing: Option<CssValue> = None;
1749                let mut iter: Option<CssValue> = None;
1750                let mut dir: Option<CssValue> = None;
1751                let mut fill: Option<CssValue> = None;
1752                let mut state: Option<CssValue> = None;
1753                for item in flatten_css_value_items(&group) {
1754                    if anim_parse::parse_time_ms(&item).is_some() && times.len() < 2 {
1755                        times.push(item);
1756                        continue;
1757                    }
1758                    if easing.is_none() && anim_parse::parse_easing(&item).is_some() {
1759                        easing = Some(item);
1760                        continue;
1761                    }
1762                    if iter.is_none() && anim_parse::parse_iteration_count(&item).is_some() {
1763                        iter = Some(item);
1764                        continue;
1765                    }
1766                    if dir.is_none() && anim_parse::parse_direction(&item).is_some() {
1767                        dir = Some(item);
1768                        continue;
1769                    }
1770                    if fill.is_none() && anim_parse::parse_fill_mode(&item).is_some() {
1771                        fill = Some(item);
1772                        continue;
1773                    }
1774                    if state.is_none() && anim_parse::parse_play_state(&item).is_some() {
1775                        state = Some(item);
1776                        continue;
1777                    }
1778                    if name.is_none()
1779                        && let CssValue::Keyword(_) | CssValue::String(_) = item
1780                    {
1781                        name = Some(item);
1782                    }
1783                }
1784                names.push(name.unwrap_or_else(|| CssValue::Keyword("none".into())));
1785                durs.push(
1786                    times
1787                        .first()
1788                        .cloned()
1789                        .unwrap_or(CssValue::Length(0.0, Unit::S)),
1790                );
1791                delays.push(
1792                    times
1793                        .get(1)
1794                        .cloned()
1795                        .unwrap_or(CssValue::Length(0.0, Unit::S)),
1796                );
1797                easings.push(easing.unwrap_or_else(|| CssValue::Keyword("ease".into())));
1798                iters.push(iter.unwrap_or(CssValue::Number(1.0)));
1799                dirs.push(dir.unwrap_or_else(|| CssValue::Keyword("normal".into())));
1800                fills.push(fill.unwrap_or_else(|| CssValue::Keyword("none".into())));
1801                states.push(state.unwrap_or_else(|| CssValue::Keyword("running".into())));
1802            }
1803            if !names.is_empty() {
1804                animation.name = Some(join_css_comma_groups(names));
1805                animation.duration = Some(join_css_comma_groups(durs));
1806                animation.delay = Some(join_css_comma_groups(delays));
1807                animation.timing_function = Some(join_css_comma_groups(easings));
1808                animation.iteration_count = Some(join_css_comma_groups(iters));
1809                animation.direction = Some(join_css_comma_groups(dirs));
1810                animation.fill_mode = Some(join_css_comma_groups(fills));
1811                animation.play_state = Some(join_css_comma_groups(states));
1812            }
1813        }
1814        _ => {}
1815    }
1816
1817    if matches!(
1818        lname.as_str(),
1819        "opacity"
1820            | "width"
1821            | "height"
1822            | "top"
1823            | "right"
1824            | "bottom"
1825            | "left"
1826            | "margin-top"
1827            | "margin-right"
1828            | "margin-bottom"
1829            | "margin-left"
1830            | "padding-top"
1831            | "padding-right"
1832            | "padding-bottom"
1833            | "padding-left"
1834            | "font-size"
1835            | "line-height"
1836            | "color"
1837            | "transform"
1838            | "background-color"
1839            | "border-top-color"
1840            | "border-right-color"
1841            | "border-bottom-color"
1842            | "border-left-color"
1843    ) {
1844        effective.insert(lname, value.clone());
1845    }
1846}
1847
1848fn split_css_comma_groups(value: &CssValue) -> Vec<Vec<CssValue>> {
1849    match value {
1850        CssValue::List(items) => {
1851            let mut groups: Vec<Vec<CssValue>> = vec![Vec::new()];
1852            for item in items {
1853                if matches!(item, CssValue::Keyword(k) if k == ",") {
1854                    groups.push(Vec::new());
1855                } else {
1856                    groups.last_mut().unwrap().push(item.clone());
1857                }
1858            }
1859            if groups.is_empty() {
1860                vec![vec![]]
1861            } else {
1862                groups
1863            }
1864        }
1865        other => vec![vec![other.clone()]],
1866    }
1867}
1868
1869fn flatten_css_value_items(items: &[CssValue]) -> Vec<CssValue> {
1870    let mut out = Vec::new();
1871    for item in items {
1872        match item {
1873            CssValue::List(inner) => out.extend(inner.clone()),
1874            _ => out.push(item.clone()),
1875        }
1876    }
1877    out
1878}
1879
1880fn join_css_comma_groups(groups: Vec<CssValue>) -> CssValue {
1881    if groups.len() <= 1 {
1882        return groups
1883            .into_iter()
1884            .next()
1885            .unwrap_or(CssValue::Keyword("none".into()));
1886    }
1887    let mut out: Vec<CssValue> = Vec::new();
1888    for (i, g) in groups.into_iter().enumerate() {
1889        if i > 0 {
1890            out.push(CssValue::Keyword(",".into()));
1891        }
1892        out.push(g);
1893    }
1894    CssValue::List(out)
1895}
1896
1897// ── Whitespace helpers ──────────────────────────────────────────────────────
1898
1899fn is_css_whitespace(c: char) -> bool {
1900    matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c')
1901}
1902
1903fn is_css_newline(c: char) -> bool {
1904    matches!(c, '\n' | '\r')
1905}
1906
1907/// True when an inline child is a whitespace-only text node that renders as
1908/// nothing more than a collapsible space.
1909fn is_collapsible_whitespace_text(info: &InfoNode) -> bool {
1910    matches!(
1911        &info.kind,
1912        NodeKind::Text {
1913            text, flow_style, ..
1914        } if text.chars().all(is_css_whitespace)
1915            && matches!(
1916                flow_style.white_space,
1917                WhiteSpace::Normal | WhiteSpace::Nowrap
1918            )
1919    )
1920}
1921
1922/// Classification of the nearest layout-participating sibling of a slot.
1923#[derive(PartialEq)]
1924enum Neighbour {
1925    /// No participating sibling (container edge) or only `display:none` boxes.
1926    None,
1927    /// A block-level element box.
1928    Block,
1929    /// A `<br>` line break.
1930    LineBreak,
1931    /// Any other inline content (text, inline element, replaced, …).
1932    Inline,
1933}
1934
1935/// Inspects the nearest layout-participating sibling of `slot_index` in the
1936/// given `step` direction (`-1` = previous, `+1` = next).
1937///
1938/// Collapsible-whitespace-only text siblings are always skipped. `<br>`
1939/// siblings are skipped when `skip_line_break` is set, so the caller can tell
1940/// "adjacent to a `<br>`" apart from "the nearest block beyond a `<br>`".
1941fn neighbour(
1942    slots: &[ChildSlot],
1943    element_results: &[Option<(LayoutChild, InfoNode)>],
1944    slot_index: usize,
1945    step: isize,
1946    skip_line_break: bool,
1947) -> Neighbour {
1948    let mut idx = slot_index;
1949
1950    loop {
1951        let Some(next) = (if step < 0 {
1952            idx.checked_sub(1)
1953        } else {
1954            idx.checked_add(1)
1955        }) else {
1956            return Neighbour::None;
1957        };
1958        if next >= slots.len() {
1959            return Neighbour::None;
1960        }
1961        idx = next;
1962
1963        match &slots[idx] {
1964            ChildSlot::Element(element_index) => {
1965                let Some((child, _)) = element_results[*element_index].as_ref() else {
1966                    continue;
1967                };
1968
1969                // display:none elements do not participate in layout.
1970                if matches!(
1971                    child,
1972                    LayoutChild::Node(node)
1973                        if node.style.display == Display::None
1974                ) {
1975                    continue;
1976                }
1977
1978                return if matches!(
1979                    child,
1980                    LayoutChild::Node(node)
1981                        if node.style.display.outer() == Some(OuterDisplay::Block)
1982                ) {
1983                    Neighbour::Block
1984                } else {
1985                    Neighbour::Inline
1986                };
1987            }
1988            ChildSlot::Inline(_, info) => {
1989                if matches!(info.kind, NodeKind::LineBreak) {
1990                    if !skip_line_break {
1991                        return Neighbour::LineBreak;
1992                    }
1993                } else if !is_collapsible_whitespace_text(info) {
1994                    return Neighbour::Inline;
1995                }
1996            }
1997        }
1998    }
1999}
2000
2001/// Per-child keep decision for whitespace-only text nodes.
2002///
2003/// A whitespace-only text node is dropped when it is adjacent on *either*
2004/// side to a block-level sibling or to a `<br>`; otherwise it would become a
2005/// stray inline box that creates a spurious line box in block containers.
2006fn compute_whitespace_keep(
2007    slots: &[ChildSlot],
2008    element_results: &[Option<(LayoutChild, InfoNode)>],
2009) -> Vec<bool> {
2010    (0..slots.len())
2011        .map(|i| match &slots[i] {
2012            ChildSlot::Inline(_, info) => {
2013                if !is_collapsible_whitespace_text(info) {
2014                    return true;
2015                }
2016                // Drop if either side is a block-level box or a `<br>` (a line
2017                // break starts a new line, so adjacent whitespace is spurious).
2018                let side_drops = |step: isize| {
2019                    neighbour(slots, element_results, i, step, true) == Neighbour::Block
2020                        || neighbour(slots, element_results, i, step, false) == Neighbour::LineBreak
2021                };
2022                !(side_drops(-1) || side_drops(1))
2023            }
2024            ChildSlot::Element(_) => true,
2025        })
2026        .collect()
2027}
2028
2029// ── Grid layout helpers ─────────────────────────────────────────────────────
2030
2031fn explicit_grid_track_count(tracks: &[GridTrack]) -> usize {
2032    tracks
2033        .iter()
2034        .map(|track| match track {
2035            GridTrack::Repeat(GridRepeat::Count(count), pattern) => {
2036                count.saturating_mul(explicit_grid_track_count(pattern))
2037            }
2038            GridTrack::Repeat(GridRepeat::AutoFit | GridRepeat::AutoFill, _) => 0,
2039            _ => 1,
2040        })
2041        .sum()
2042}
2043
2044fn resolve_grid_end_line(placement: &mut GridPlacement, track_count: usize) {
2045    if !matches!(placement.end, GRID_LINE_TO_END) || track_count == 0 {
2046        return;
2047    }
2048
2049    placement.end = GridPlacementEnd::Line(track_count + 1);
2050}
2051
2052fn resolve_named_grid_area(node: &mut LayoutNode, areas: &[Vec<String>]) {
2053    let Some(name) = node.style.grid_area.as_deref() else {
2054        return;
2055    };
2056    let mut min_column = usize::MAX;
2057    let mut max_column = 0;
2058    let mut min_row = usize::MAX;
2059    let mut max_row = 0;
2060    for (row, names) in areas.iter().enumerate() {
2061        for (column, area) in names.iter().enumerate() {
2062            if area == name {
2063                min_column = min_column.min(column);
2064                max_column = max_column.max(column);
2065                min_row = min_row.min(row);
2066                max_row = max_row.max(row);
2067            }
2068        }
2069    }
2070    if min_column == usize::MAX {
2071        return;
2072    }
2073    node.style.grid_column = GridPlacement {
2074        start: Some(min_column + 1),
2075        end: GridPlacementEnd::Line(max_column + 2),
2076    };
2077    node.style.grid_row = GridPlacement {
2078        start: Some(min_row + 1),
2079        end: GridPlacementEnd::Line(max_row + 2),
2080    };
2081    node.style.grid_area = None;
2082}
2083
2084// ── Text normalization ──────────────────────────────────────────────────────
2085
2086/// Normalizes segment breaks and collapsible whitespace according to the
2087/// element's `white-space`.
2088///
2089/// Returns [`Cow::Borrowed`] when `text` is already in normalized form, which
2090/// is the common case for the many short text nodes whose content has no
2091/// segment breaks, repeated spaces or leading/trailing whitespace. Only the
2092/// text that actually needs rewriting is copied.
2093pub fn normalize_whitespace(text: &str, white_space: WhiteSpace) -> Cow<'_, str> {
2094    // `pre`-like modes only rewrite segment breaks (`\r\n`, `\r`, `\x0c` are
2095    // all replaced by `\n`); every other rune is preserved verbatim.
2096    let is_pre_like = matches!(
2097        white_space,
2098        WhiteSpace::Pre | WhiteSpace::PreWrap | WhiteSpace::BreakSpaces
2099    );
2100    let has_segment_break = text.as_bytes().iter().any(|&b| b == b'\r' || b == b'\x0c');
2101
2102    if is_pre_like {
2103        if !has_segment_break {
2104            return Cow::Borrowed(text);
2105        }
2106        // Only segment breaks change; avoid the throwaway second `replace`.
2107        let mut result = String::with_capacity(text.len());
2108        let mut chars = text.chars().peekable();
2109        while let Some(c) = chars.next() {
2110            match c {
2111                '\r' => {
2112                    if chars.peek() == Some(&'\n') {
2113                        chars.next();
2114                    }
2115                    result.push('\n');
2116                }
2117                '\x0c' => result.push('\n'),
2118                _ => result.push(c),
2119            }
2120        }
2121        return Cow::Owned(result);
2122    }
2123
2124    // `Normal`, `Nowrap` and `PreLine` all collapse runs of CSS whitespace:
2125    // every whitespace rune is mapped to a single `' '`. The text therefore
2126    // only needs rewriting when a segment break is present, when a whitespace
2127    // rune other than `' '` appears (tab, newline, …), or when two whitespace
2128    // runes are adjacent (the second would be dropped).
2129    let needs_rewrite = has_segment_break || {
2130        let mut prev_was_space = false;
2131        let mut rewrite = false;
2132        for c in text.chars() {
2133            let is_space = is_css_whitespace(c);
2134            if is_space && (prev_was_space || c != ' ') {
2135                rewrite = true;
2136                break;
2137            }
2138            prev_was_space = is_space;
2139        }
2140        rewrite
2141    };
2142
2143    if !needs_rewrite {
2144        return Cow::Borrowed(text);
2145    }
2146
2147    let text = text.replace("\r\n", "\n");
2148    let text = text.replace(['\r', '\x0c'], "\n");
2149
2150    let mut result = String::new();
2151    let mut prev_was_space = false;
2152
2153    for c in text.chars() {
2154        match white_space {
2155            WhiteSpace::Normal | WhiteSpace::Nowrap => {
2156                if is_css_whitespace(c) {
2157                    if !prev_was_space {
2158                        result.push(' ');
2159                    }
2160                    prev_was_space = true;
2161                } else {
2162                    result.push(c);
2163                    prev_was_space = false;
2164                }
2165            }
2166
2167            WhiteSpace::Pre | WhiteSpace::PreWrap | WhiteSpace::BreakSpaces => {
2168                result.push(c);
2169                prev_was_space = false;
2170            }
2171
2172            WhiteSpace::PreLine => {
2173                if c == '\n' {
2174                    result.push('\n');
2175                    prev_was_space = false;
2176                } else if is_css_whitespace(c) {
2177                    if !prev_was_space {
2178                        result.push(' ');
2179                    }
2180                    prev_was_space = true;
2181                } else {
2182                    result.push(c);
2183                    prev_was_space = false;
2184                }
2185            }
2186        }
2187    }
2188
2189    Cow::Owned(result)
2190}
2191
2192/// Measure text and create a [`TextFlowLayouter`] + [`NodeKind::Text`].
2193///
2194/// Falls back to unshaped measurement when shaped measurement fails.
2195fn create_text_node(
2196    text: String,
2197    text_style: TextStyle,
2198    text_flow_style: TextFlowStyle,
2199    measurer: &dyn text::TextMeasurer,
2200) -> (TextFlowLayouter, NodeKind) {
2201    perf_scope!(measure);
2202    let request = text::TextMeasureRequest {
2203        text: text.clone(),
2204        attribute: text::TextAttribute {
2205            style: text_style.clone(),
2206            flow_style: text_flow_style,
2207        },
2208    };
2209    let clusters = measurer.measure_shaped(&request).unwrap_or_else(|_| {
2210        measurer
2211            .measure(&request)
2212            .map(|ms| {
2213                let mut offset = 0usize;
2214                ms.into_iter()
2215                    .map(|f| {
2216                        // find this fragment's byte offset in the original text
2217                        let pos = text[offset..]
2218                            .find(&f.text)
2219                            .map(|p| offset + p)
2220                            .unwrap_or(offset);
2221                        offset = pos + f.text.len();
2222                        GlyphCluster {
2223                            byte_offset: pos,
2224                            width: f.width,
2225                            break_allowed: true,
2226                        }
2227                    })
2228                    .collect()
2229            })
2230            .unwrap_or_default()
2231    });
2232    profile_log!(
2233        target: "Layouter",
2234        log::Level::Info,
2235        "measure_shaped: text={:?} len={} took={:?}",
2236        crate::profile::text_preview(&text),
2237        text.len(),
2238        measure.elapsed(),
2239    );
2240
2241    let layouter = TextFlowLayouter::new(text.clone(), text_flow_style, clusters);
2242    let kind = NodeKind::Text {
2243        text,
2244        style: text_style,
2245        flow_style: text_flow_style,
2246        text_id: layouter.id,
2247    };
2248    (layouter, kind)
2249}
2250
2251/// Builds the generated-content box for a `::before`/`::after` rule block.
2252///
2253/// The pseudo-element box is an inline flow-root styled by the cascaded
2254/// declarations. `content` supplies generated text (`none` or an empty string
2255/// produces no box unless the pseudo has a background/border/dimensions, which
2256/// pure-CSS icons such as the Google logo rely on). Returns `None` when the
2257/// pseudo generates nothing to render.
2258#[allow(clippy::too_many_arguments)]
2259fn build_pseudo_slot(
2260    declarations: &Properties,
2261    custom_properties: &Properties,
2262    inherited_text_style: &TextStyle,
2263    inherited_text_flow_style: TextFlowStyle,
2264    color_scheme: ColorScheme,
2265    measurer: &dyn text::TextMeasurer,
2266    images: &HashMap<String, Image>,
2267    get_attr: &dyn Fn(&str) -> Option<String>,
2268    counter_state: &HashMap<String, i32>,
2269) -> Option<ChildSlot> {
2270    let mut style = Style::default();
2271    let mut container_style = ContainerStyle::default();
2272    let mut text_style = inherited_text_style.clone();
2273    let mut text_flow_style = inherited_text_flow_style;
2274    let mut overflow = Overflow::default();
2275
2276    // The cascade map is already ordered per-property; re-sort by source order
2277    // so shorthand/longhand interactions apply deterministically.
2278    let mut cascade: Vec<_> = declarations.values().collect();
2279    cascade.sort_by_key(|declaration| declaration.order);
2280
2281    let mut content: Option<CssValue> = None;
2282    for declaration in cascade {
2283        if declaration.name.starts_with("--") {
2284            continue;
2285        }
2286        if declaration.name == "content" {
2287            content = Some(declaration.value.clone());
2288            continue;
2289        }
2290        let Some(value) = DeclarationResolver::resolve_var(
2291            &declaration.value,
2292            custom_properties,
2293            &mut HashSet::new(),
2294        ) else {
2295            continue;
2296        };
2297        apply_declaration(
2298            &declaration.name,
2299            &value,
2300            &mut style,
2301            &mut container_style,
2302            &mut text_style,
2303            &mut text_flow_style,
2304            &Style::default(),
2305            &ContainerStyle::default(),
2306            inherited_text_style,
2307            &inherited_text_flow_style,
2308            &mut overflow,
2309            color_scheme,
2310        );
2311    }
2312
2313    // Generated content: resolve `content` value to text, or `none`/`normal`/omitted.
2314    let content_text = match &content {
2315        None => String::new(),
2316        Some(CssValue::Keyword(k))
2317            if k.eq_ignore_ascii_case("none") || k.eq_ignore_ascii_case("normal") =>
2318        {
2319            String::new()
2320        }
2321        Some(CssValue::Keyword(k)) => match k.to_ascii_lowercase().as_str() {
2322            "open-quote" => "\"".to_string(),
2323            "close-quote" => "\"".to_string(),
2324            "no-open-quote" | "no-close-quote" => String::new(),
2325            _ => String::new(),
2326        },
2327        Some(CssValue::String(s)) => s.clone(),
2328        Some(CssValue::Function(fn_name, args)) => {
2329            resolve_content_function(fn_name, args, get_attr, counter_state)
2330        }
2331        Some(CssValue::List(items)) => {
2332            // `content: "a" attr(data-label) "b"` concatenates strings and
2333            // resolved function values.
2334            let mut joined = String::new();
2335            for item in items {
2336                match item {
2337                    CssValue::String(s) => joined.push_str(s.as_str()),
2338                    CssValue::Keyword(k) => match k.to_ascii_lowercase().as_str() {
2339                        "open-quote" => joined.push_str("\""),
2340                        "close-quote" => joined.push_str("\""),
2341                        "no-open-quote" | "no-close-quote" => {}
2342                        _ => {}
2343                    },
2344                    CssValue::Function(fn_name, args) => {
2345                        let resolved =
2346                            resolve_content_function(fn_name, args, get_attr, counter_state);
2347                        if !resolved.is_empty() {
2348                            joined.push_str(&resolved);
2349                        }
2350                    }
2351                    _ => {}
2352                }
2353            }
2354            joined
2355        }
2356        Some(_) => String::new(),
2357    };
2358
2359    // `content: url(...)` renders the image as a background layer on the
2360    // pseudo-element, so it paints even when there is no text content.
2361    if let Some(CssValue::Function(fn_name, args)) = &content
2362        && fn_name.eq_ignore_ascii_case("url")
2363    {
2364        for arg_group in args.iter().flatten() {
2365            if let CssValue::String(source) = arg_group {
2366                container_style.background.layers.push(
2367                    crate::engine::layouter::types::BackgroundLayer {
2368                        image: Some(
2369                            crate::engine::layouter::types::BackgroundImage::Image {
2370                                source: source.clone(),
2371                                image: None,
2372                            },
2373                        ),
2374                        position: Default::default(),
2375                        size: Default::default(),
2376                        repeat: Default::default(),
2377                    },
2378                );
2379                break;
2380            }
2381        }
2382    }
2383
2384    let has_styling = container_style.background.color.is_some_and(|c| c.3 > 0)
2385        || container_style
2386            .background
2387            .layers
2388            .iter()
2389            .any(|layer| layer.image.is_some())
2390        || matches!(
2391            (&style.size.width, &style.size.height),
2392            (LengthOrAuto::Length(l), _) | (_, LengthOrAuto::Length(l))
2393                if !matches!(l, Length::Px(0.0))
2394        )
2395        || [
2396            &style.spacing.padding_top,
2397            &style.spacing.padding_right,
2398            &style.spacing.padding_bottom,
2399            &style.spacing.padding_left,
2400            &style.spacing.border_top,
2401            &style.spacing.border_right,
2402            &style.spacing.border_bottom,
2403            &style.spacing.border_left,
2404        ]
2405        .iter()
2406        .any(|p| !matches!(p, Length::Px(0.0)));
2407
2408    if content_text.is_empty() && !has_styling {
2409        return None;
2410    }
2411
2412    // Resolve line-height from the pseudo's own text-flow style.
2413    style.line_height = match text_flow_style.line_height {
2414        LineHeight::Number(factor) => Length::Px(text_flow_style.font_size * factor),
2415        LineHeight::Normal => Length::Px(text_flow_style.font_size * DEFAULT_LINE_FACTOR),
2416        LineHeight::Px(px) => Length::Px(px),
2417    };
2418
2419    // Attach the decoded background image, mirroring element handling.
2420    for layer in &mut container_style.background.layers {
2421        if let Some(crate::engine::layouter::types::BackgroundImage::Image { source, image }) =
2422            &mut layer.image
2423        {
2424            *image = images.get(source).cloned();
2425        }
2426    }
2427
2428    // The generated box is an inline-level flow root (inline-block): it sits
2429    // on the originating element's line and shrink-wraps its content.
2430    style.display = Display::OutsideInner {
2431        outer: OuterDisplay::Inline,
2432        inner: InnerDisplay::FlowRoot,
2433    };
2434
2435    let mut inline_style = style.clone();
2436    let mut slot_children: Vec<LayoutChild> = Vec::new();
2437    let mut slot_info: Vec<InfoNode> = Vec::new();
2438
2439    if !content_text.is_empty() {
2440        let content_text = normalize_whitespace(&content_text, text_flow_style.white_space);
2441        let content_text = match text_style.text_transform {
2442            TextTransform::None => content_text.into_owned(),
2443            TextTransform::Uppercase => content_text.to_ascii_uppercase(),
2444            TextTransform::Lowercase => content_text.to_ascii_lowercase(),
2445        };
2446        let (layouter, kind) =
2447            create_text_node(content_text, text_style.clone(), text_flow_style, measurer);
2448        inline_style.display = Display::OutsideInner {
2449            outer: OuterDisplay::Inline,
2450            inner: InnerDisplay::Flow,
2451        };
2452        slot_children.push((inline_style, layouter).into());
2453        slot_info.push(InfoNode {
2454            kind,
2455            children: Vec::new(),
2456            dom_id: None,
2457        });
2458    }
2459
2460    let layout = LayoutNode::with_children(style, slot_children);
2461    let info = InfoNode {
2462        kind: NodeKind::Container {
2463            scroll_x: overflow.x,
2464            scroll_y: overflow.y,
2465            scroll_offset_x: 0.0,
2466            scroll_offset_y: 0.0,
2467            style: container_style,
2468            role: ContainerRole::Normal,
2469        },
2470        children: slot_info,
2471        dom_id: None,
2472    };
2473    Some(ChildSlot::Inline(layout.into(), Box::new(info)))
2474}
2475
2476/// Resolves a `content()` function value to a string.
2477///
2478/// Supported functions:
2479/// - `attr(attr-name)` — the element's attribute value, or empty if absent.
2480/// - `counter(counter-name)` / `counter(counter-name, style)` — current counter value.
2481/// - `counters(counter-name, separator)` — current counter value (simple impl).
2482/// - `url(...)` — image source; handled separately as a replaced element.
2483fn resolve_content_function(
2484    fn_name: &str,
2485    args: &[Vec<CssValue>],
2486    get_attr: &dyn Fn(&str) -> Option<String>,
2487    counter_state: &HashMap<String, i32>,
2488) -> String {
2489    let flat: Vec<&CssValue> = args.iter().flatten().collect();
2490
2491    match fn_name {
2492        "attr" => {
2493            if let Some(CssValue::String(attr_name)) = flat.first() {
2494                get_attr(attr_name).unwrap_or_default()
2495            } else if let Some(CssValue::Keyword(attr_name)) = flat.first() {
2496                get_attr(attr_name).unwrap_or_default()
2497            } else {
2498                String::new()
2499            }
2500        }
2501        "counter" => {
2502            if let Some(CssValue::Keyword(name)) = flat.first() {
2503                counter_state
2504                    .get(name.as_str())
2505                    .map(|v| v.to_string())
2506                    .unwrap_or_else(|| "0".to_string())
2507            } else {
2508                "0".to_string()
2509            }
2510        }
2511        "counters" => {
2512            if let Some(CssValue::Keyword(name)) = flat.first() {
2513                counter_state
2514                    .get(name.as_str())
2515                    .map(|v| v.to_string())
2516                    .unwrap_or_else(|| "0".to_string())
2517            } else {
2518                "0".to_string()
2519            }
2520        }
2521        "url" => {
2522            String::new()
2523        }
2524        _ => String::new(),
2525    }
2526}
2527
2528/// Parse `counter-reset` value into a list of (name, value) pairs.
2529///
2530/// Examples:
2531/// - `counter-reset: section` → [("section", 0)]
2532/// - `counter-reset: section 5` → [("section", 5)]
2533/// - `counter-reset: section 5 subsection 0` → [("section", 5), ("subsection", 0)]
2534/// - `counter-reset: none` → []
2535fn parse_counter_reset(value: &CssValue) -> Vec<(String, i32)> {
2536    match value {
2537        CssValue::Keyword(k) if k.eq_ignore_ascii_case("none") => Vec::new(),
2538        CssValue::List(items) => {
2539            let mut result = Vec::new();
2540            let mut iter = items.iter();
2541            while let Some(name_val) = iter.next() {
2542                let name = match name_val {
2543                    CssValue::Keyword(k) => k.to_string(),
2544                    _ => continue,
2545                };
2546                let val = match iter.next() {
2547                    Some(CssValue::Number(n)) => *n as i32,
2548                    _ => 0,
2549                };
2550                result.push((name, val));
2551            }
2552            result
2553        }
2554        CssValue::Keyword(k) => {
2555            if k.eq_ignore_ascii_case("none") {
2556                Vec::new()
2557            } else {
2558                vec![(k.to_string(), 0)]
2559            }
2560        }
2561        _ => Vec::new(),
2562    }
2563}
2564
2565/// Parse `counter-increment` value into a list of (name, amount) pairs.
2566///
2567/// Examples:
2568/// - `counter-increment: section` → [("section", 1)]
2569/// - `counter-increment: section 2` → [("section", 2)]
2570/// - `counter-increment: section 2 subsection -1` → [("section", 2), ("subsection", -1)]
2571fn parse_counter_increment(value: &CssValue) -> Vec<(String, i32)> {
2572    match value {
2573        CssValue::Keyword(k) if k.eq_ignore_ascii_case("none") => Vec::new(),
2574        CssValue::List(items) => {
2575            let mut result = Vec::new();
2576            let mut iter = items.iter();
2577            while let Some(name_val) = iter.next() {
2578                let name = match name_val {
2579                    CssValue::Keyword(k) => k.to_string(),
2580                    _ => continue,
2581                };
2582                let val = match iter.next() {
2583                    Some(CssValue::Number(n)) => *n as i32,
2584                    _ => 1,
2585                };
2586                result.push((name, val));
2587            }
2588            result
2589        }
2590        CssValue::Keyword(k) => {
2591            if k.eq_ignore_ascii_case("none") {
2592                Vec::new()
2593            } else {
2594                vec![(k.to_string(), 1)]
2595            }
2596        }
2597        _ => Vec::new(),
2598    }
2599}
2600
2601// ── Color scheme resolution ─────────────────────────────────────────────────
2602
2603/// How an element's `color-scheme` property constrains its used color scheme.
2604enum ColorSchemePref {
2605    /// `normal` or unset: fall back to the inherited (or system) scheme.
2606    Normal,
2607    Light,
2608    Dark,
2609    /// Both `light` and `dark` listed: follow the system preference.
2610    Both,
2611}
2612
2613/// Parses the winning `color-scheme` declaration into a preference.
2614fn color_scheme_pref(value: Option<&CssValue>) -> ColorSchemePref {
2615    let Some(value) = value else {
2616        return ColorSchemePref::Normal;
2617    };
2618    let mut light = false;
2619    let mut dark = false;
2620    let mut has = false;
2621    let mut push = |keyword: &str| {
2622        has = true;
2623        match keyword {
2624            "light" => light = true,
2625            "dark" => dark = true,
2626            // `only`, `normal`, unknown keywords are ignored here.
2627            _ => {}
2628        }
2629    };
2630    match value {
2631        CssValue::Keyword(k) => push(k),
2632        CssValue::List(items) => {
2633            for item in items {
2634                if let CssValue::Keyword(k) = item {
2635                    push(k);
2636                }
2637            }
2638        }
2639        _ => {}
2640    }
2641    if !has {
2642        ColorSchemePref::Normal
2643    } else if light && dark {
2644        ColorSchemePref::Both
2645    } else if light {
2646        ColorSchemePref::Light
2647    } else if dark {
2648        ColorSchemePref::Dark
2649    } else {
2650        ColorSchemePref::Normal
2651    }
2652}
2653
2654/// Computes the used color scheme of an element from its `color-scheme`
2655/// declaration, the inherited scheme, and the system preference.
2656fn resolve_used_color_scheme(
2657    declaration: Option<&CssValue>,
2658    inherited: ColorScheme,
2659    system: ColorScheme,
2660) -> ColorScheme {
2661    match color_scheme_pref(declaration) {
2662        ColorSchemePref::Normal => inherited,
2663        ColorSchemePref::Light => ColorScheme::Light,
2664        ColorSchemePref::Dark => ColorScheme::Dark,
2665        ColorSchemePref::Both => system,
2666    }
2667}
2668
2669// ── CSS candidate collection ────────────────────────────────────────────────
2670
2671/// Aggregated CSS candidate-matching statistics for a single layout build.
2672#[cfg(any(feature = "profile", debug_assertions))]
2673#[derive(Default)]
2674struct CandidateMetrics {
2675    elements_checked: u64,
2676    candidates_examined: u64,
2677    selectors_matched: u64,
2678    query_candidates_time: std::time::Duration,
2679    selector_match_time: std::time::Duration,
2680    cascade_insert_time: std::time::Duration,
2681}
2682
2683fn collect_candidates(
2684    rule_set: &RuleSet,
2685    chain: &ElementChain,
2686    node_id: NodeId,
2687    mut match_cache: Option<&mut SelectorMatchCache>,
2688    #[cfg(any(feature = "profile", debug_assertions))] stats: &mut CandidateMetrics,
2689) -> (Properties, Properties, HashMap<PseudoElement, Properties>) {
2690    let mut properties = HashMap::new();
2691    let mut custom_properties = HashMap::new();
2692    let mut pseudo_properties: HashMap<PseudoElement, Properties> = HashMap::new();
2693
2694    let element = match chain.first() {
2695        Some(el) => el,
2696        None => return (properties, custom_properties, pseudo_properties),
2697    };
2698
2699    #[cfg(any(feature = "profile", debug_assertions))]
2700    {
2701        stats.elements_checked += 1;
2702    }
2703
2704    // The candidate iterator is lazy; under profiling it is materialized so
2705    // query time is measured separately from selector matching.
2706    perf_scope!(query);
2707    #[cfg(any(feature = "profile", debug_assertions))]
2708    let candidates_iter: Vec<_> = rule_set.query_candidates(element).collect();
2709    #[cfg(not(any(feature = "profile", debug_assertions)))]
2710    let candidates_iter = rule_set.query_candidates(element);
2711    #[cfg(any(feature = "profile", debug_assertions))]
2712    {
2713        stats.query_candidates_time += query.elapsed();
2714    }
2715
2716    for group in candidates_iter {
2717        #[cfg(any(feature = "profile", debug_assertions))]
2718        {
2719            stats.candidates_examined += 1;
2720        }
2721
2722        // Declarations sharing an identical selector are grouped, so the
2723        // (comparatively expensive) selector walk happens once per group.
2724        perf_scope!(sel_match);
2725        let matches_sel = match match_cache.as_deref_mut() {
2726            Some(cache) => {
2727                cache.get_or_insert(&group.selector, node_id, || group.selector.matches(chain))
2728            }
2729            None => group.selector.matches(chain),
2730        };
2731        #[cfg(any(feature = "profile", debug_assertions))]
2732        {
2733            stats.selector_match_time += sel_match.elapsed();
2734            if matches_sel {
2735                stats.selectors_matched += 1;
2736            }
2737        }
2738        if !matches_sel {
2739            continue;
2740        }
2741
2742        for &decl_idx in &group.decls {
2743            let decl = &rule_set.declarations()[decl_idx];
2744
2745            let target: &mut Properties = if decl.pseudo != PseudoElement::None {
2746                pseudo_properties.entry(decl.pseudo).or_default()
2747            } else if decl.name.starts_with("--") {
2748                &mut custom_properties
2749            } else {
2750                &mut properties
2751            };
2752
2753            perf_scope!(cascade);
2754            let should_replace = match target.get(&decl.name) {
2755                Some(current) => decl.outranks(current),
2756                None => true,
2757            };
2758
2759            if should_replace {
2760                target.insert(decl.name.clone(), decl.clone());
2761            }
2762
2763            #[cfg(any(feature = "profile", debug_assertions))]
2764            {
2765                stats.cascade_insert_time += cascade.elapsed();
2766            }
2767        }
2768    }
2769
2770    (properties, custom_properties, pseudo_properties)
2771}