Skip to main content

ratatui_style/
cascade.rs

1//! The cascade engine — turns a [`Stylesheet`] + a [`StyledNode`] into a fully
2//! resolved [`ComputedStyle`].
3//!
4//! Pipeline per element:
5//! 1. Collect matching rules.
6//! 2. Sort ascending by `(origin, specificity, source_order)`.
7//! 3. Fold declarations via [`CssStyle::overlay`] (later = higher priority).
8//! 4. Fold explicit `inherit` keywords and auto-inherited properties from the
9//!    parent [`ComputedStyle`].
10//! 5. Resolve `var()` references against the token table.
11
12use ratatui::{
13    layout::{Alignment, Constraint, Rect},
14    style::Style as RStyle,
15    widgets::Block,
16};
17
18use crate::box_model::{BorderStyle, BorderStyleValue, BoxEdges, BoxEdgesValue, Length};
19use crate::cache::{node_signature, ComputeCache};
20use crate::color::Color;
21use crate::media::MediaContext;
22use crate::node::{Classes, StyledNode};
23use crate::selector::NodeIdentity;
24use crate::style::CssStyle;
25use crate::stylesheet::Stylesheet;
26use crate::token::{self, ThemeTokens};
27
28/// A fully-resolved style: all `var()`s turned into literals, inheritable
29/// properties filled from the parent. Project onto ratatui via the delegate
30/// methods.
31#[derive(Debug, Clone, Default, PartialEq)]
32pub struct ComputedStyle {
33    pub style: CssStyle,
34}
35
36impl ComputedStyle {
37    pub fn new(style: CssStyle) -> Self {
38        Self { style }
39    }
40    pub fn to_style(&self) -> RStyle {
41        self.style.to_style()
42    }
43    pub fn to_block(&self) -> Block<'_> {
44        self.style.to_block()
45    }
46    pub fn apply_margin(&self, area: Rect) -> Rect {
47        self.style.apply_margin(area)
48    }
49    pub fn constraints(&self) -> Option<(Constraint, Constraint)> {
50        self.style.constraints()
51    }
52    pub fn alignment(&self) -> Option<Alignment> {
53        self.style.alignment()
54    }
55
56    /// Layer a single inline declaration on top of the computed style in place.
57    ///
58    /// Because `Origin::Inline` is applied last in the cascade, an inline
59    /// declaration *wins* over every rule regardless of specificity. This
60    /// method reproduces that post-compute: it overlays `inline` via
61    /// [`CssStyle::overlay`], so any field `Some` in `inline` replaces the
62    /// matching computed field.
63    ///
64    /// Note: colors passed here should already be literals — `compute` has
65    /// already resolved `var()` against the token table, and this method does
66    /// not re-resolve.
67    pub fn apply_inline(&mut self, inline: &CssStyle) {
68        self.style.overlay(inline);
69    }
70
71    /// Consuming builder form of [`apply_inline`](Self::apply_inline): overlay
72    /// an inline declaration and return `self`.
73    pub fn with_inline(mut self, inline: &CssStyle) -> Self {
74        self.apply_inline(inline);
75        self
76    }
77
78    /// One-shot box-model projection: resolve the full `margin → block →
79    /// block.inner → content style` sequence in a single call.
80    ///
81    /// Returns `(block, content_style, inner_area)` where:
82    /// - `block` is [`ComputedStyle::to_block`] (borders/padding/background, no
83    ///   margin — margin is applied to the area first);
84    /// - `content_style` is [`ComputedStyle::to_style`] (the foreground
85    ///   decoration to apply to the inner widget);
86    /// - `inner_area` is the area left for content after margin shrink *and*
87    ///   the block's padding/borders, i.e. it equals
88    ///   `to_block().inner(apply_margin(area))`.
89    ///
90    /// This matches the hand-written sequence downstream widgets previously had
91    /// to thread themselves:
92    ///
93    /// ```text
94    /// let shrunk = computed.apply_margin(area);
95    /// let block  = computed.to_block();
96    /// let inner  = block.inner(shrunk);
97    /// let style  = computed.to_style();
98    /// ```
99    ///
100    /// Box-model order is `margin (outer) → border → padding → content`, so the
101    /// margin shrink happens *outside* the block and `block.inner` only removes
102    /// padding/borders — never the margin.
103    pub fn layout(&self, area: Rect) -> (Block<'_>, RStyle, Rect) {
104        let shrunk = self.apply_margin(area);
105        self.layout_with_shrunk(shrunk)
106    }
107
108    /// The margin-free half of [`layout`](Self::layout): given an already
109    /// margin-shrunk area, build the `(block, content_style, inner)` triple.
110    ///
111    /// This exists so [`render_computed`] can call `apply_margin` exactly once
112    /// and reuse the result for both the block render and the inner-area
113    /// computation, instead of calling it twice (once in `render_computed`,
114    /// once inside `layout`). The public [`layout`](Self::layout) delegates
115    /// here after shrinking, so the two share one code path.
116    fn layout_with_shrunk(&self, shrunk: Rect) -> (Block<'_>, RStyle, Rect) {
117        let block = self.to_block();
118        let inner = block.inner(shrunk);
119        let style = self.to_style();
120        (block, style, inner)
121    }
122}
123
124/// Render a computed style's full box model in one shot.
125///
126/// Resolves `(block, content_style, inner)` via [`ComputedStyle::layout`],
127/// renders the `block` into the margin-shrunk area, then renders the widget
128/// returned by `make(inner, content_style)` into the block's inner area. The
129/// closure receives the inner `Rect` and the foreground `Style` and is expected
130/// to apply the style to the widget it builds (this mirrors how most ratatui
131/// widgets carry a `.style(...)`).
132///
133/// Use this to collapse the `margin → block → content` boilerplate into a
134/// single call.
135///
136/// ```rust,ignore
137/// use ratatui::widgets::Paragraph;
138/// use ratatui_style::{ComputedStyle, render_computed};
139///
140/// render_computed(frame, &computed, area, |inner, style| {
141///     Paragraph::new("hello").style(style)
142/// });
143/// ```
144pub fn render_computed<W, F>(
145    frame: &mut ratatui::Frame<'_>,
146    computed: &ComputedStyle,
147    area: Rect,
148    make: F,
149) where
150    F: FnOnce(Rect, RStyle) -> W,
151    W: ratatui::widgets::Widget,
152{
153    let shrunk = computed.apply_margin(area);
154    let (block, style, inner) = computed.layout_with_shrunk(shrunk);
155    frame.render_widget(block, shrunk);
156    frame.render_widget(make(inner, style), inner);
157}
158
159/// A cascade tree-walker: holds a [`Stylesheet`] reference, a reusable
160/// [`ComputeScratch`], and a parent [`ComputedStyle`] stack.
161///
162/// `enter(node)` computes the node's style using the current stack top (if any)
163/// as its parent, pushes the result, and returns an owned copy; `leave()` pops
164/// it. This lets a downstream component-tree traversal inherit styles
165/// automatically without the caller manually threading `Some(&parent)` into
166/// every child's `compute` call.
167///
168/// # Why `enter` returns an owned value
169///
170/// `enter` returns an *owned* [`ComputedStyle`] rather than `&ComputedStyle`.
171/// Returning a borrow would lock `&mut self` for the returned value's lifetime,
172/// making it impossible to nest a second `enter` for a child while holding the
173/// parent's style. The owned return avoids that entirely — the caller can hold
174/// the parent's computed style freely and still call `enter` for children.
175///
176/// # Pushed clone is stack-only memcpy
177///
178/// After `compute`, a [`ComputedStyle`] holds only `Literal`/`Reset`/`Copy`
179/// fields — every `var()` has been resolved against the token table, so no
180/// [`Color::Var`] (the only [`Color`] variant carrying a heap `String` / `Box`)
181/// survives. Every other field (`BoxEdges`, `BorderSpec`, `Weight`, `Length`,
182/// …) is a fixed-size, stack-resident enum/struct. The `computed.clone()` that
183/// backs the internal stack is therefore a plain stack memcpy with no heap
184/// allocation, and is cheap to ignore.
185///
186/// # Example — walking a three-level tree
187///
188/// ```rust,ignore
189/// use ratatui_style::{CascadeContext, OwnedNode, Stylesheet};
190///
191/// let sheet: Stylesheet = /* … */;
192/// let mut ctx = CascadeContext::new(&sheet);
193///
194/// // Root
195/// let root = ctx.enter(&OwnedNode::new("Root"));
196/// // …render root…
197///
198/// // Panel (child of Root)
199/// let panel = ctx.enter(&OwnedNode::new("Panel"));
200/// // …render panel…
201///
202/// // Text (child of Panel) — inherits Panel's color automatically
203/// let text = ctx.enter(&OwnedNode::new("Text"));
204/// // …render text…
205/// ctx.leave(); // back to Panel context
206///
207/// ctx.leave(); // back to Root context
208/// ctx.leave(); // done
209/// ```
210pub struct CascadeContext<'s> {
211    sheet: &'s Stylesheet,
212    scratch: ComputeScratch,
213    stack: Vec<ComputedStyle>,
214    /// Snapshot of the selector-relevant fields of each `enter`ed node, kept
215    /// ONLY when `sheet.has_combinators()`. Mirrors `stack` 1:1 (pushed in
216    /// `enter`, popped in `leave`) so combinator selectors can match against
217    /// the ancestor chain. Empty and untouched for combinator-free stylesheets,
218    /// so that path stays allocation-free.
219    identity_stack: Vec<NodeIdentity>,
220    /// Previous-sibling identities keyed by tree depth, kept ONLY when
221    /// `sheet.has_combinators()`. `siblings[D]` is the list of prior siblings
222    /// of a node at depth `D` (number of ancestors = D), within the current
223    /// parent, oldest-first. Cleared at `siblings[D+1]` on `enter` (a node's
224    /// children start with no prior siblings) and appended to at `siblings[D]`
225    /// on `leave` (the departed node becomes a prior sibling for the next one).
226    /// Empty and untouched for combinator-free stylesheets.
227    siblings: Vec<Vec<NodeIdentity>>,
228    /// The active terminal context used to gate `@media` rules. Defaults to
229    /// all-zero / all-false (no media info), in which case media-gated rules
230    /// with any condition do NOT match. Set via [`set_media`](Self::set_media)
231    /// / [`with_media`](Self::with_media) before `enter`ing nodes whose rules
232    /// depend on it.
233    media: MediaContext,
234    /// Opt-in compute cache. `None` (the default) means no caching: `enter` /
235    /// `leave` behave byte-for-byte identically to the uncached baseline. When
236    /// `Some`, every `enter` consults the cache and stores the result; the
237    /// cache auto-invalidates on stylesheet mutation via the generation check.
238    cache: Option<ComputeCache>,
239    /// The signature of each `enter`ed node, mirroring `stack` 1:1. Maintained
240    /// ONLY when `cache.is_some()` — used as the next child's `parent_sig` so
241    /// the ancestor chain is transitively folded into each child's signature.
242    sig_stack: Vec<u64>,
243}
244
245impl<'s> CascadeContext<'s> {
246    /// Build a walker over `sheet` with an empty parent stack and a fresh
247    /// reusable scratch buffer.
248    pub fn new(sheet: &'s Stylesheet) -> Self {
249        Self {
250            sheet,
251            scratch: ComputeScratch::new(),
252            stack: Vec::new(),
253            identity_stack: Vec::new(),
254            siblings: Vec::new(),
255            media: MediaContext::default(),
256            cache: None,
257            sig_stack: Vec::new(),
258        }
259    }
260
261    /// Set the active [`MediaContext`] used to gate `@media` rules, returning
262    /// `&mut Self` for chaining. Call before `enter`ing nodes whose rules
263    /// depend on terminal size / color capability.
264    pub fn set_media(&mut self, media: MediaContext) -> &mut Self {
265        self.media = media;
266        self
267    }
268
269    /// Consuming builder form of [`set_media`](Self::set_media).
270    pub fn with_media(mut self, media: MediaContext) -> Self {
271        self.media = media;
272        self
273    }
274
275    /// Attach an opt-in [`ComputeCache`] with the given hard capacity, enabling
276    /// memoization across `enter` calls. `capacity == 0` attaches a cache that
277    /// never stores (effectively disabled, but `cache` is `Some`); the typical
278    /// choice is a small bound sized to the tree's working set.
279    ///
280    /// Once attached, every `enter` consults the cache before computing and
281    /// stores the result on a miss. The cache auto-invalidates on stylesheet
282    /// mutation: a `sheet.add(...)` / `tokens_mut()` / etc. bumps the sheet's
283    /// generation, which the cache detects on its next access and clears.
284    ///
285    /// **Combinator handling**: when the stylesheet `has_combinators()`, the
286    /// cached path uses the ancestors-aware compute ([combinators match]).
287    /// When it does not, the cheaper one-shot path is used. Both paths share
288    /// the same cache, so caching works regardless.
289    ///
290    /// [combinators match]: Stylesheet::compute_cached_ancestors
291    pub fn with_cache(mut self, capacity: usize) -> Self {
292        self.cache = Some(ComputeCache::new(capacity));
293        self
294    }
295
296    /// A reference to the attached cache, if any. Useful for tests that assert
297    /// on cache state (e.g. that a warm walk populated entries).
298    pub fn cache(&self) -> Option<&ComputeCache> {
299        self.cache.as_ref()
300    }
301
302    /// The currently active [`MediaContext`].
303    pub fn media(&self) -> &MediaContext {
304        &self.media
305    }
306
307    /// Compute `node`'s style using the current stack top as its parent, push
308    /// the result onto the stack, and return an owned copy.
309    ///
310    /// When the stylesheet `has_combinators()`, this also snapshots `node`'s
311    /// selector-relevant fields onto an ancestor-identity stack so descendant
312    /// (`A B`) and child (`A > B`) selectors can match against the chain. The
313    /// identity stack is only maintained in that case — combinator-free
314    /// stylesheets pay no added cost here.
315    ///
316    /// `@media` rules are gated against [`media`](Self::media); set it via
317    /// [`set_media`](Self::set_media) / [`with_media`](Self::with_media) before
318    /// entering nodes whose rules depend on it.
319    pub fn enter(&mut self, node: &dyn StyledNode) -> ComputedStyle {
320        let parent = self.stack.last();
321        let has_comb = self.sheet.has_combinators();
322        // D = the depth this node will sit at once pushed.
323        let depth = self.stack.len();
324
325        let (computed, sig) = if let Some(cache) = self.cache.as_mut() {
326            // Cached path. Build the parent's signature from the sig stack (the
327            // last pushed sig is this node's parent's sig, transitive).
328            let parent_sig = self.sig_stack.last().copied();
329            if has_comb {
330                let prev_sibs: &[NodeIdentity] = self
331                    .siblings
332                    .get(depth)
333                    .map(Vec::as_slice)
334                    .unwrap_or(&[]);
335                self.sheet.compute_cached_ancestors(
336                    node,
337                    parent,
338                    parent_sig,
339                    &self.identity_stack,
340                    prev_sibs,
341                    &self.media,
342                    &mut self.scratch,
343                    cache,
344                )
345            } else {
346                self.sheet.compute_cached(
347                    node,
348                    parent,
349                    parent_sig,
350                    &self.media,
351                    &mut self.scratch,
352                    cache,
353                )
354            }
355        } else {
356            // Uncached path: byte-for-byte identical to the pre-cache baseline.
357            let c = if has_comb {
358                let prev_sibs: &[NodeIdentity] = self
359                    .siblings
360                    .get(depth)
361                    .map(Vec::as_slice)
362                    .unwrap_or(&[]);
363                self.sheet.compute_with_ancestors_media(
364                    node,
365                    parent,
366                    &mut self.scratch,
367                    &self.identity_stack,
368                    prev_sibs,
369                    &self.media,
370                )
371            } else {
372                self.sheet
373                    .compute_with_media(node, parent, &mut self.scratch, &self.media)
374            };
375            (c, 0)
376        };
377
378        self.stack.push(computed.clone());
379        if self.cache.is_some() {
380            self.sig_stack.push(sig);
381        }
382        if has_comb {
383            self.identity_stack.push(NodeIdentity::from_node(node));
384            // The node's children start with no previous siblings: ensure the
385            // slot at depth+1 exists and clear it.
386            let child_depth = depth + 1;
387            if self.siblings.len() <= child_depth {
388                self.siblings.resize_with(child_depth + 1, Vec::new);
389            }
390            self.siblings[child_depth].clear();
391        }
392        computed
393    }
394
395    /// Pop the most recently `enter`ed node (leaving its subtree).
396    pub fn leave(&mut self) -> Option<ComputedStyle> {
397        // Keep the stacks in sync: pop the identity stack iff it is
398        // maintained (i.e. only when the stylesheet has combinators); pop the
399        // sig stack iff caching is on.
400        if self.sheet.has_combinators() && !self.identity_stack.is_empty() {
401            let popped = self.identity_stack.pop().expect("identity stack non-empty");
402            // The departed node sat at depth D == self.stack.len() AFTER the
403            // style-stack pop below; but we read it before popping the style
404            // stack, so its depth is the current stack length minus 1. Record
405            // it as a previous sibling for the NEXT sibling at the same depth.
406            let depth = self.stack.len() - 1;
407            if self.siblings.len() <= depth {
408                self.siblings.resize_with(depth + 1, Vec::new);
409            }
410            self.siblings[depth].push(popped);
411        }
412        if self.cache.is_some() && !self.sig_stack.is_empty() {
413            self.sig_stack.pop();
414        }
415        self.stack.pop()
416    }
417
418    /// The current stack top — the style a subsequent `enter` will inherit
419    /// from. `None` at the root (depth 0).
420    pub fn current(&self) -> Option<&ComputedStyle> {
421        self.stack.last()
422    }
423
424    /// Number of nodes currently on the stack (the tree depth).
425    pub fn depth(&self) -> usize {
426        self.stack.len()
427    }
428
429    /// The backing stylesheet.
430    pub fn sheet(&self) -> &Stylesheet {
431        self.sheet
432    }
433}
434
435/// A reusable cascade scratch buffer.
436///
437/// Held across many [`Stylesheet::compute_with`] calls, it retains its
438/// capacity so the per-`compute` matching buffer stops allocating once it has
439/// warmed up. It stores **rule indices** (`Vec<usize>`), not references, so it
440/// carries no lifetime parameter and can be owned long-term by the caller
441/// without borrowing the stylesheet.
442///
443/// ```rust,ignore
444/// let mut scratch = ComputeScratch::new();
445/// // reuse across the whole draw loop:
446/// for node in &nodes {
447///     let style = sheet.compute_with(node, parent, &mut scratch);
448/// }
449/// ```
450pub struct ComputeScratch {
451    matching: Vec<usize>,
452}
453
454impl ComputeScratch {
455    pub fn new() -> Self {
456        Self {
457            matching: Vec::new(),
458        }
459    }
460}
461
462impl Default for ComputeScratch {
463    fn default() -> Self {
464        Self::new()
465    }
466}
467
468impl Stylesheet {
469    /// Compute the resolved style for `node`, optionally inheriting from
470    /// `parent`.
471    ///
472    /// **Combinator limitation**: this is a one-shot API with no ancestor
473    /// context, so rules whose selector carries a descendant (`A B`) or child
474    /// (`A > B`) combinator will **not match** here — they require an ancestor
475    /// stack, which only a [`CascadeContext`] supplies. Use `CascadeContext`
476    /// to evaluate combinator selectors against a real ancestor chain.
477    ///
478    /// **`@media` limitation**: this one-shot path uses a default
479    /// [`MediaContext`] (all-zero / no media info), so media-gated rules with
480    /// any condition will NOT apply here. Use [`compute_with_media`](Self::compute_with_media)
481    /// or a [`CascadeContext`] with a non-default context to evaluate them.
482    ///
483    /// Thin wrapper over [`compute_with`](Self::compute_with) with a fresh
484    /// [`ComputeScratch`]. Behavior is identical to `compute_with` — this
485    /// exists for one-shot callers and backwards compatibility.
486    ///
487    /// [`CascadeContext`]: crate::CascadeContext
488    pub fn compute(&self, node: &dyn StyledNode, parent: Option<&ComputedStyle>) -> ComputedStyle {
489        let mut scratch = ComputeScratch::new();
490        self.compute_with(node, parent, &mut scratch)
491    }
492
493    /// Compute using a caller-provided [`ComputeScratch`] so the matching
494    /// buffer is reused across calls (zero allocation once warmed up).
495    ///
496    /// This is the allocation-conscious entry point for the draw loop. Three
497    /// per-frame allocations are eliminated relative to `compute`:
498    ///
499    /// 1. **Classes** are fetched from the node exactly once (hoisted out of
500    ///    the rule loop) and matched via [`Selector::matches_values`], so the
501    ///    R-rules × 1-node cost is one `Classes` materialization, not R.
502    /// 2. **Matching buffer** lives in `scratch` and is `clear()`-ed, not
503    ///    re-allocated.
504    /// 3. When the node is a [`NodeRef`](crate::node::NodeRef), the classes
505    ///    materialization itself is zero-allocation.
506    ///
507    /// **Combinator limitation**: like [`compute`](Self::compute), this one-shot
508    /// path has no ancestor context, so combinator selectors do not match
509    /// here. Use a [`CascadeContext`] for combinator support.
510    ///
511    /// **`@media` limitation**: uses a default [`MediaContext`], so media-gated
512    /// rules do not apply. Use [`compute_with_media`](Self::compute_with_media).
513    ///
514    /// [`CascadeContext`]: crate::CascadeContext
515    pub fn compute_with(
516        &self,
517        node: &dyn StyledNode,
518        parent: Option<&ComputedStyle>,
519        scratch: &mut ComputeScratch,
520    ) -> ComputedStyle {
521        // Default media context: media-gated rules with any condition will NOT
522        // match (a default context carries no terminal info).
523        self.compute_with_media(node, parent, scratch, &MediaContext::default())
524    }
525
526    /// Media-aware compute: like [`compute_with`](Self::compute_with) but
527    /// evaluates `@media`-gated rules against the supplied [`MediaContext`].
528    ///
529    /// A rule tagged with a query matches only when
530    /// [`MediaQuery::matches`](crate::media::MediaQuery::matches) the context;
531    /// untagged (`media: None`) rules always apply. The media check is a cheap
532    /// `Option::is_some()` fast-path on the no-media hot path — rules with no
533    /// query pay no added cost.
534    ///
535    /// Use this in a draw loop that tracks terminal size (and optionally color
536    /// capability) so width-/color-conditional rules apply per frame:
537    ///
538    /// ```rust,ignore
539    /// let media = MediaContext { cols: size.width, rows: size.height, ..Default::default() };
540    /// let style = sheet.compute_with_media(&node, parent, &mut scratch, &media);
541    /// ```
542    pub fn compute_with_media(
543        &self,
544        node: &dyn StyledNode,
545        parent: Option<&ComputedStyle>,
546        scratch: &mut ComputeScratch,
547        media: &MediaContext,
548    ) -> ComputedStyle {
549        // `None` → cheap raw-args matching path (no NodeIdentity allocation).
550        // Combinator selectors never match here; they need a CascadeContext.
551        self.compute_inner(node, parent, scratch, None, None, media)
552    }
553
554    /// Combinator- + media-aware compute: the full-featured entry point used by
555    /// [`CascadeContext::enter`] when the stylesheet `has_combinators()`.
556    /// Evaluates selectors against `ancestors` and `siblings` (for `+`/`~`)
557    /// and `@media` rules against `media`.
558    ///
559    /// [`CascadeContext::enter`]: crate::CascadeContext::enter
560    pub(crate) fn compute_with_ancestors_media(
561        &self,
562        node: &dyn StyledNode,
563        parent: Option<&ComputedStyle>,
564        scratch: &mut ComputeScratch,
565        ancestors: &[NodeIdentity],
566        siblings: &[NodeIdentity],
567        media: &MediaContext,
568    ) -> ComputedStyle {
569        self.compute_inner(node, parent, scratch, Some(ancestors), Some(siblings), media)
570    }
571
572    /// Cached one-shot compute: like [`compute_with_media`](Self::compute_with_media)
573    /// but consults `cache` first. The returned `u64` is the node's signature —
574    /// pass it as the next child's `parent_sig` so the ancestor chain is
575    /// transitively captured by the signature fold.
576    ///
577    /// **Combinator limitation**: this one-shot path has no ancestor context
578    /// (same as [`compute_with_media`](Self::compute_with_media)), so
579    /// combinator selectors do NOT match here. For combinator-aware caching use
580    /// [`compute_cached_ancestors`](Self::compute_cached_ancestors) (which
581    /// [`CascadeContext::enter`](crate::CascadeContext::enter) picks
582    /// automatically when the stylesheet `has_combinators()`).
583    pub fn compute_cached(
584        &self,
585        node: &dyn StyledNode,
586        parent: Option<&ComputedStyle>,
587        parent_sig: Option<u64>,
588        media: &MediaContext,
589        scratch: &mut ComputeScratch,
590        cache: &mut ComputeCache,
591    ) -> (ComputedStyle, u64) {
592        let node_id = NodeIdentity::from_node(node);
593        let sig = node_signature(&node_id, parent_sig, &[], media);
594        if let Some(hit) = cache.get(sig, self.generation()) {
595            return (hit, sig);
596        }
597        let computed = self.compute_with_media(node, parent, scratch, media);
598        cache.insert(sig, computed.clone(), self.generation());
599        (computed, sig)
600    }
601
602    /// Cached combinator-aware compute: like
603    /// [`compute_with_ancestors_media`](Self::compute_with_ancestors_media) but
604    /// consults `cache` first. Used by
605    /// [`CascadeContext::enter`](crate::CascadeContext::enter) when the
606    /// stylesheet `has_combinators()` AND a cache is attached.
607    ///
608    /// The signature captures the ancestor chain via `parent_sig` (each
609    /// ancestor's sig folds its own parent's sig), so this is correct: a hit
610    /// against a signature built from the full ancestor stack yields the same
611    /// `ComputedStyle` as a fresh compute through
612    /// [`compute_with_ancestors_media`](Self::compute_with_ancestors_media).
613    #[allow(clippy::too_many_arguments)] // threading all combinator + cache context
614    pub(crate) fn compute_cached_ancestors(
615        &self,
616        node: &dyn StyledNode,
617        parent: Option<&ComputedStyle>,
618        parent_sig: Option<u64>,
619        ancestors: &[NodeIdentity],
620        siblings: &[NodeIdentity],
621        media: &MediaContext,
622        scratch: &mut ComputeScratch,
623        cache: &mut ComputeCache,
624    ) -> (ComputedStyle, u64) {
625        let node_id = NodeIdentity::from_node(node);
626        let sig = node_signature(&node_id, parent_sig, siblings, media);
627        if let Some(hit) = cache.get(sig, self.generation()) {
628            return (hit, sig);
629        }
630        let computed =
631            self.compute_with_ancestors_media(node, parent, scratch, ancestors, siblings, media);
632        cache.insert(sig, computed.clone(), self.generation());
633        (computed, sig)
634    }
635
636    /// Shared compute body. `ancestors` selects the matching path:
637    ///
638    /// - `None` — cheap raw-args path ([`Selector::matches_values`]). No
639    ///   [`NodeIdentity`] is built; combinator selectors never match. This
640    ///   preserves the no-combinator hot path's zero-allocation property.
641    /// - `Some(stack)` — combinator-aware path: builds one `NodeIdentity` for
642    ///   the node and matches via [`Selector::matches_chain`] against `stack`
643    ///   and the `siblings` slice (empty when `siblings` is `None`, as on the
644    ///   one-shot paths). Used only when the stylesheet `has_combinators()`.
645    ///
646    /// `media` gates `@media`-tagged rules: a rule whose query does not match
647    /// `media` is skipped. The check is `Option::is_some()`-fast for
648    /// `media: None` rules (the common, no-`@media` case).
649    fn compute_inner(
650        &self,
651        node: &dyn StyledNode,
652        parent: Option<&ComputedStyle>,
653        scratch: &mut ComputeScratch,
654        ancestors: Option<&[NodeIdentity]>,
655        siblings: Option<&[NodeIdentity]>,
656        media: &MediaContext,
657    ) -> ComputedStyle {
658        let rules = self.rules();
659
660        // 1. Collect matching rule *indices* into the reused scratch buffer.
661        //    Rules are stored pre-sorted by (origin, specificity, order) — see
662        //    `Stylesheet::sort_rules` — so the indices land in ascending
663        //    priority order as a side effect of iterating a sorted slice.
664        scratch.matching.clear();
665        match ancestors {
666            None => {
667                // Cheap raw-args path: hoist node fields once, no NodeIdentity.
668                let type_name = node.type_name();
669                let id = node.id();
670                let classes: Classes<'_> = node.classes();
671                let state = node.state();
672                let position = node.position();
673                for (i, r) in rules.iter().enumerate() {
674                    if r.selector.matches_values(type_name, id, &classes, state, &position)
675                        && rule_media_matches(&r.media, media)
676                        && rule_supports_matches(&r.supports, media)
677                    {
678                        scratch.matching.push(i);
679                    }
680                }
681            }
682            Some(stack) => {
683                // Combinator-aware path: build one NodeIdentity for the node,
684                // then match every selector (combinator or not) via matches_chain.
685                let node_id = NodeIdentity::from_node(node);
686                let sibs: &[NodeIdentity] = siblings.unwrap_or(&[]);
687                for (i, r) in rules.iter().enumerate() {
688                    if r.selector.matches_chain(&node_id, stack, sibs)
689                        && rule_media_matches(&r.media, media)
690                        && rule_supports_matches(&r.supports, media)
691                    {
692                        scratch.matching.push(i);
693                    }
694                }
695            }
696        }
697
698        // 2. Fold declarations (later = higher priority). The per-`compute`
699        //    sort by (origin, specificity, order) that used to live here is
700        //    gone: rules are already sorted at mutation time, so the
701        //    iteration above visits them in priority order.
702        let mut own = CssStyle::new();
703        for &i in &scratch.matching {
704            own.overlay(&rules[i].style);
705        }
706
707        // 3. Inheritance.
708        if let Some(parent) = parent {
709            resolve_explicit_inherit(&mut own, &parent.style);
710            own.inherit_from(&parent.style);
711        }
712
713        // 4. var() resolution against the stylesheet's token table. The active
714        //    `MediaContext` is threaded in so `:root { --x }` overrides declared
715        //    inside a matching `@media` block participate.
716        resolve_vars_in_place(&mut own, self.tokens(), media);
717
718        ComputedStyle::new(own)
719    }
720}
721
722/// The media-matching predicate for a rule: `true` when the rule is untagged
723/// (`media: None`) or its query matches `ctx`. Inlined into the hot rule loop;
724/// for `None` rules this collapses to a single `is_some()` check with no query
725/// evaluation.
726#[inline]
727fn rule_media_matches(query: &Option<crate::media::MediaQuery>, ctx: &MediaContext) -> bool {
728    match query {
729        None => true,
730        Some(q) => q.matches(ctx),
731    }
732}
733
734/// The supports-matching predicate for a rule: `true` when the rule is untagged
735/// (`supports: None`) or its query matches `ctx` (capability conditions evaluate
736/// against the [`MediaContext`] flags, property conditions against the engine's
737/// known-property set). Inlined into the hot rule loop; for `None` rules this
738/// collapses to a single `is_some()` check with no query evaluation.
739#[inline]
740fn rule_supports_matches(query: &Option<crate::supports::SupportsQuery>, ctx: &MediaContext) -> bool {
741    match query {
742        None => true,
743        Some(q) => q.matches(ctx),
744    }
745}
746
747/// Replace explicit `inherit` keyword colors with the parent's value, for all
748/// three color fields (CSS `inherit` forces inheritance even for
749/// non-inheritable properties like `background`).
750fn resolve_explicit_inherit(own: &mut CssStyle, parent: &CssStyle) {
751    if matches!(own.color, Some(Color::Inherit)) {
752        own.color = parent.color.clone();
753    }
754    if matches!(own.background, Some(Color::Inherit)) {
755        own.background = parent.background.clone();
756    }
757    if matches!(own.underline_color, Some(Color::Inherit)) {
758        own.underline_color = parent.underline_color.clone();
759    }
760}
761
762/// Resolve every `var()` / leftover `inherit` in the color and length fields
763/// to a literal — including the `Color` nested inside a `border` spec, the
764/// `BoxEdgesValue` in padding/margin, and the `BorderStyleValue` in the border
765/// style. Color fields degrade to `Reset` on failure; length fields degrade to
766/// `Auto`; box-edges fields degrade to zero edges; border-style degrades to
767/// `None` — all lenient, none panic.
768///
769/// `media` gates `:root { --x }` overrides declared inside `@media` blocks: a
770/// matching query's overrides win over the default map (last-matching wins),
771/// with the default map always consulted as fallback. Pass
772/// [`MediaContext::default`] on the one-shot paths where media-gated tokens are
773/// documented not to apply.
774fn resolve_vars_in_place(style: &mut CssStyle, tokens: &ThemeTokens, media: &MediaContext) {
775    resolve_color_field(&mut style.color, tokens, media);
776    resolve_color_field(&mut style.background, tokens, media);
777    resolve_color_field(&mut style.underline_color, tokens, media);
778    // The border color / style are nested inside `Option<BorderSpec>`, so they
779    // are not covered by the top-level field passes above. Resolve them here
780    // too, or a `border: rounded var(--dim)` survives the cascade as a `Var`
781    // and `paint` drops it — the border then draws with no explicit color.
782    if let Some(border) = style.border.as_mut() {
783        resolve_color_field(&mut border.color, tokens, media);
784        resolve_border_style_field(&mut border.style, tokens, media);
785    }
786    resolve_length_field(&mut style.width, tokens, media);
787    resolve_length_field(&mut style.height, tokens, media);
788    resolve_box_edges_field(&mut style.padding, tokens, media);
789    resolve_box_edges_field(&mut style.margin, tokens, media);
790}
791
792fn resolve_color_field(field: &mut Option<Color>, tokens: &ThemeTokens, media: &MediaContext) {
793    if let Some(inner) = field {
794        match inner {
795            Color::Literal(_) | Color::Reset => {} // already concrete
796            Color::Var { .. } | Color::Inherit => {
797                *field = Some(Color::Literal(token::resolve_with_media(inner, tokens, media)));
798            }
799        }
800    }
801}
802
803/// Mirrors [`resolve_color_field`] for the length path (width/height). A
804/// `Length::Var` is resolved against the token table; anything else is left
805/// untouched. Failures (undefined name, type mismatch, cycle) degrade to
806/// [`Length::Auto`] — consistent with the lenient color path degrading to
807/// `Reset`.
808fn resolve_length_field(field: &mut Option<Length>, tokens: &ThemeTokens, media: &MediaContext) {
809    if let Some(inner) = field {
810        if let Length::Var { .. } = inner {
811            *field = Some(token::resolve_length_with_media(inner, tokens, media));
812        }
813    }
814}
815
816/// Resolve a padding/margin `BoxEdgesValue` field in place. A `Var` is
817/// resolved against the token table (following chains and the `var()` fallback
818/// recursively); on failure (undefined name, type mismatch, cycle) the fallback
819/// is tried, and if there is none it degrades to [`BoxEdges::zero`]
820/// (`BoxEdgesValue::Edges(BoxEdges::zero())`). A concrete `Edges` is left
821/// untouched.
822fn resolve_box_edges_field(
823    field: &mut Option<BoxEdgesValue>,
824    tokens: &ThemeTokens,
825    media: &MediaContext,
826) {
827    if let Some(inner) = field.take() {
828        *field = Some(resolve_box_edges_value(inner, tokens, media, 0));
829    }
830}
831
832/// Recursive resolver for [`BoxEdgesValue`]. A depth cap (32) guards against
833/// cycles; the fallback (`var(--x, expr)`) is tried when the name is undefined.
834/// On total failure this returns `Edges(BoxEdges::zero())` — lenient, no panic.
835fn resolve_box_edges_value(
836    value: BoxEdgesValue,
837    tokens: &ThemeTokens,
838    media: &MediaContext,
839    depth: u8,
840) -> BoxEdgesValue {
841    if depth > 32 {
842        return BoxEdgesValue::Edges(BoxEdges::zero());
843    }
844    match value {
845        BoxEdgesValue::Edges(_) => value,
846        BoxEdgesValue::Var { name, fallback } => {
847            match tokens.get_box_edges_with(&name, media) {
848                Some(edges) => BoxEdgesValue::Edges(edges),
849                None => match fallback {
850                    Some(fb) => resolve_box_edges_value(*fb, tokens, media, depth + 1),
851                    None => BoxEdgesValue::Edges(BoxEdges::zero()),
852                },
853            }
854        }
855    }
856}
857
858/// Resolve the border-style `BorderStyleValue` field in place. A `Var` is
859/// resolved against the token table (following chains and the `var()` fallback
860/// recursively); on failure the fallback is tried, and if there is none it
861/// degrades to [`BorderStyle::None`] (`BorderStyleValue::Fixed(None)`). A
862/// concrete `Fixed` is left untouched.
863fn resolve_border_style_field(
864    field: &mut BorderStyleValue,
865    tokens: &ThemeTokens,
866    media: &MediaContext,
867) {
868    let owned = std::mem::take(field);
869    *field = resolve_border_style_value(owned, tokens, media, 0);
870}
871
872/// Recursive resolver for [`BorderStyleValue`]. A depth cap (32) guards against
873/// cycles; the fallback is tried when the name is undefined. On total failure
874/// this returns `Fixed(BorderStyle::None)` — lenient, no panic.
875fn resolve_border_style_value(
876    value: BorderStyleValue,
877    tokens: &ThemeTokens,
878    media: &MediaContext,
879    depth: u8,
880) -> BorderStyleValue {
881    if depth > 32 {
882        return BorderStyleValue::Fixed(BorderStyle::None);
883    }
884    match value {
885        BorderStyleValue::Fixed(_) => value,
886        BorderStyleValue::Var { name, fallback } => {
887            match tokens.get_border_style_with(&name, media) {
888                Some(style) => BorderStyleValue::Fixed(style),
889                None => match fallback {
890                    Some(fb) => resolve_border_style_value(*fb, tokens, media, depth + 1),
891                    None => BorderStyleValue::Fixed(BorderStyle::None),
892                },
893            }
894        }
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use crate::node::{NodeRef, OwnedNode, State};
902    use crate::stylesheet::Origin;
903    use ratatui::style::Color as RC;
904
905    fn sheet() -> Stylesheet {
906        let mut s = Stylesheet::with_tokens(
907            crate::token::ThemeTokens::new().set("accent", Color::literal(RC::Cyan)),
908        );
909        // type rule (low specificity)
910        s.add("Button", CssStyle::new().color(RC::Gray), Origin::User)
911            .unwrap();
912        // class rule (higher specificity)
913        s.add(
914            "Button.primary",
915            CssStyle::new().background(RC::Blue),
916            Origin::User,
917        )
918        .unwrap();
919        // id rule (highest specificity)
920        s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
921            .unwrap();
922        // focus pseudo-state
923        s.add(
924            "Button:focus",
925            CssStyle::new().background(RC::Green),
926            Origin::User,
927        )
928        .unwrap();
929        // var() consumer
930        s.add(
931            ".accented",
932            CssStyle::new().color(Color::var("accent")),
933            Origin::User,
934        )
935        .unwrap();
936        // inline (origin) overrides specificity
937        s
938    }
939
940    #[test]
941    fn specificity_wins() {
942        let s = sheet();
943        let n = OwnedNode::new("Button")
944            .with_id("save")
945            .with_classes(["primary"]);
946        let c = s.compute(&n, None);
947        // #save (id) wins over Button (type) for color.
948        assert_eq!(c.style.color, Some(Color::literal(RC::Yellow)));
949        // .primary (class) wins over Button (type) for background.
950        assert_eq!(c.style.background, Some(Color::literal(RC::Blue)));
951    }
952
953    #[test]
954    fn pseudo_state_matches() {
955        let s = sheet();
956        let n = OwnedNode::new("Button").with_state(State::focus());
957        let c = s.compute(&n, None);
958        assert_eq!(c.style.background, Some(Color::literal(RC::Green)));
959    }
960
961    #[test]
962    fn nth_child_cascade_end_to_end() {
963        // :nth-child(odd) should set red only for odd positions (1-based).
964        let mut s = Stylesheet::new();
965        s.add(
966            "Item:nth-child(odd)",
967            CssStyle::new().color(RC::Red),
968            Origin::User,
969        )
970        .unwrap();
971
972        // sibling_count = 3. index 0 → 1-based 1 (odd) → red.
973        let first = OwnedNode::new("Item").with_position(crate::node::Position::new(0, 3));
974        // index 1 → 1-based 2 (even) → no rule → None.
975        let second = OwnedNode::new("Item").with_position(crate::node::Position::new(1, 3));
976        // index 2 → 1-based 3 (odd) → red.
977        let third = OwnedNode::new("Item").with_position(crate::node::Position::new(2, 3));
978
979        assert_eq!(
980            s.compute(&first, None).style.color,
981            Some(Color::literal(RC::Red))
982        );
983        assert_eq!(s.compute(&second, None).style.color, None);
984        assert_eq!(
985            s.compute(&third, None).style.color,
986            Some(Color::literal(RC::Red))
987        );
988    }
989
990    #[test]
991    fn nth_child_default_position_does_not_match() {
992        // A node with default Position (sibling_count 0) must not match
993        // :nth-child(odd) even though its index defaults to 0 (1-based 1, odd).
994        let mut s = Stylesheet::new();
995        s.add(
996            "Item:nth-child(odd)",
997            CssStyle::new().color(RC::Red),
998            Origin::User,
999        )
1000        .unwrap();
1001
1002        let n = OwnedNode::new("Item"); // default position
1003        assert_eq!(n.position().sibling_count, 0);
1004        let c = s.compute(&n, None);
1005        assert_eq!(c.style.color, None);
1006    }
1007
1008    #[test]
1009    fn var_resolves_from_tokens() {
1010        let s = sheet();
1011        let n = OwnedNode::new("Text").with_classes(["accented"]);
1012        let c = s.compute(&n, None);
1013        assert_eq!(c.style.color, Some(Color::literal(RC::Cyan)));
1014    }
1015
1016    #[test]
1017    fn border_color_var_resolves_from_tokens() {
1018        // Regression for the 0.1.1 limitation: a `var()` in the border
1019        // shorthand color must be resolved against the token table, not left as
1020        // a `Color::Var` (which `paint` would silently drop).
1021        //
1022        // This is the exact downstream DetailPanel case: `#003237` was used as a
1023        // literal in place of `var(--border-dim)` because the cascade did not
1024        // resolve border colors. With the fix, the token resolves.
1025        let sheet = Stylesheet::parse(
1026            ":root{--border-dim:#003237} .panel { border: rounded var(--border-dim); }",
1027        )
1028        .unwrap();
1029        let n = OwnedNode::new("Div").with_classes(["panel"]);
1030        let c = sheet.compute(&n, None);
1031        let border = c.style.border.expect("border present");
1032        assert_eq!(
1033            border.style,
1034            crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
1035        );
1036        assert_eq!(
1037            border.color,
1038            Some(Color::literal(RC::Rgb(0x00, 0x32, 0x37)))
1039        );
1040    }
1041
1042    #[test]
1043    fn border_color_var_via_subdeclaration_resolves() {
1044        // The `border-color: var(--x)` sub-declaration path must resolve too —
1045        // it lands in the same nested `BorderSpec.color` field.
1046        let sheet = Stylesheet::parse(
1047            ":root{--rim:#ff0000} .b { border-style: single; border-color: var(--rim); }",
1048        )
1049        .unwrap();
1050        let n = OwnedNode::new("Div").with_classes(["b"]);
1051        let c = sheet.compute(&n, None);
1052        let border = c.style.border.expect("border present");
1053        assert_eq!(
1054            border.color,
1055            Some(Color::literal(RC::Rgb(0xff, 0x00, 0x00)))
1056        );
1057    }
1058
1059    #[test]
1060    fn border_color_var_fallback_resolves() {
1061        // An undefined border-color var with a fallback degrades to the
1062        // fallback, mirroring the other color fields.
1063        let sheet = Stylesheet::parse(".b { border: rounded var(--nope, #00ff00); }").unwrap();
1064        let n = OwnedNode::new("Div").with_classes(["b"]);
1065        let c = sheet.compute(&n, None);
1066        let border = c.style.border.expect("border present");
1067        assert_eq!(
1068            border.color,
1069            Some(Color::literal(RC::Rgb(0x00, 0xff, 0x00)))
1070        );
1071    }
1072
1073    #[test]
1074    fn inheritance_from_parent() {
1075        let s = sheet();
1076        let parent_node = OwnedNode::new("Button").with_classes(["primary"]);
1077        let parent = s.compute(&parent_node, None);
1078        // Child Text has no color of its own; inherits parent's.
1079        let child = OwnedNode::new("Text");
1080        let computed = s.compute(&child, Some(&parent));
1081        assert_eq!(computed.style.color, Some(Color::literal(RC::Gray)));
1082    }
1083
1084    #[test]
1085    fn origin_overrides_specificity() {
1086        let mut s = Stylesheet::new();
1087        s.add("Button", CssStyle::new().color(RC::Red), Origin::User)
1088            .unwrap();
1089        // Inline origin wins despite identical selector.
1090        s.add("Button", CssStyle::new().color(RC::Blue), Origin::Inline)
1091            .unwrap();
1092        let n = OwnedNode::new("Button");
1093        let c = s.compute(&n, None);
1094        assert_eq!(c.style.color, Some(Color::literal(RC::Blue)));
1095    }
1096
1097    #[test]
1098    fn rules_stored_in_cascade_sorted_order() {
1099        // Insert rules in deliberately scrambled origin/specificity order and
1100        // assert the stored slice comes out sorted ascending by
1101        // (origin, specificity, order).
1102        let mut s = Stylesheet::new();
1103        // type rule, Origin::User — (User, (0,0,1), 0)
1104        s.add("Button", CssStyle::new(), Origin::User).unwrap();
1105        // id rule, Origin::User — (User, (1,0,0), 1)
1106        s.add("#save", CssStyle::new(), Origin::User).unwrap();
1107        // class rule, Origin::User — (User, (0,1,0), 2)
1108        s.add(".primary", CssStyle::new(), Origin::User).unwrap();
1109        // type rule, Origin::Inline — (Inline, (0,0,1), 3)
1110        s.add("Button", CssStyle::new(), Origin::Inline).unwrap();
1111        // class rule, Origin::UserAgent — (UA, (0,1,0), 4)
1112        s.add(".primary", CssStyle::new(), Origin::UserAgent)
1113            .unwrap();
1114
1115        let rules = s.rules();
1116        for w in rules.windows(2) {
1117            let a = &w[0];
1118            let b = &w[1];
1119            let ka = (a.origin, a.selector.specificity(), a.order);
1120            let kb = (b.origin, b.selector.specificity(), b.order);
1121            assert!(ka <= kb, "rules not sorted: {ka:?} > {kb:?}");
1122        }
1123
1124        // Spot-check the extremes: the lowest-priority rule is the UserAgent
1125        // class (origin UA) and the highest is the Inline type rule.
1126        assert_eq!(rules.first().unwrap().origin, Origin::UserAgent);
1127        assert_eq!(rules.last().unwrap().origin, Origin::Inline);
1128    }
1129
1130    #[test]
1131    fn compute_unchanged_after_sort_removal_scrambled_insertion() {
1132        // Mirror `specificity_wins` + `origin_overrides_specificity` but with
1133        // rules inserted in a deliberately hostile (high→low priority) order,
1134        // so that removing the per-`compute` sort would visibly break the
1135        // result if rules weren't pre-sorted.
1136        let mut s = Stylesheet::new();
1137        // highest specificity first, lowest last (reverse of priority).
1138        s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
1139            .unwrap();
1140        s.add(
1141            "Button.primary",
1142            CssStyle::new().background(RC::Blue),
1143            Origin::User,
1144        )
1145        .unwrap();
1146        s.add("Button", CssStyle::new().color(RC::Gray), Origin::User)
1147            .unwrap();
1148
1149        let n = OwnedNode::new("Button")
1150            .with_id("save")
1151            .with_classes(["primary"]);
1152        let c = s.compute(&n, None);
1153        // id beats class beats type: #save color wins over Button color.
1154        assert_eq!(c.style.color, Some(Color::literal(RC::Yellow)));
1155        // .primary background wins over Button (type) background (none).
1156        assert_eq!(c.style.background, Some(Color::literal(RC::Blue)));
1157    }
1158
1159    #[test]
1160    fn inline_origin_wins_in_scrambled_insertion_order() {
1161        // Inline origin beats User even when the User rule is added last and
1162        // has equal specificity — stresses the (origin, …) sort key.
1163        let mut s = Stylesheet::new();
1164        s.add("Button", CssStyle::new().color(RC::Blue), Origin::Inline)
1165            .unwrap();
1166        s.add("Button", CssStyle::new().color(RC::Red), Origin::User)
1167            .unwrap();
1168        let n = OwnedNode::new("Button");
1169        let c = s.compute(&n, None);
1170        assert_eq!(c.style.color, Some(Color::literal(RC::Blue)));
1171    }
1172
1173    #[test]
1174    fn render_computed_applies_margin_once() {
1175        // Regression: render_computed must render the block into the
1176        // margin-shrunk area and the widget into the block's inner area, with
1177        // apply_margin run exactly once. We can't easily materialize a
1178        // ratatui::Frame in a unit test, so we pin the area invariant that
1179        // render_computed now computes via a single apply_margin + the
1180        // shared `layout_with_shrunk` helper (instead of calling
1181        // apply_margin twice as it used to).
1182        let computed = ComputedStyle::new(
1183            CssStyle::new()
1184                .margin("2")
1185                .padding("1")
1186                .border("rounded #00d4ff"),
1187        );
1188        let area = Rect::new(0, 0, 44, 8);
1189
1190        // This is exactly the sequence render_computed runs internally now.
1191        let shrunk = computed.apply_margin(area);
1192        let (_block, _style, inner) = computed.layout_with_shrunk(shrunk);
1193
1194        // Block renders into shrunk (margin removed on each side).
1195        assert_eq!(shrunk, Rect::new(2, 2, 40, 4));
1196        // Widget renders into inner (margin + border + padding removed).
1197        assert_eq!(inner, Rect::new(4, 4, 36, 0));
1198    }
1199
1200    #[test]
1201    fn with_inline_overrides_specificity() {
1202        // An id selector has the highest specificity in the sheet, but an inline
1203        // declaration layered on top post-compute must still win — that is the
1204        // whole point of inline origin being applied last.
1205        let mut s = Stylesheet::new();
1206        s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
1207            .unwrap();
1208        let n = OwnedNode::new("Button").with_id("save");
1209        let c = s
1210            .compute(&n, None)
1211            .with_inline(&CssStyle::new().color("red"));
1212        // The id rule set Yellow; inline red wins.
1213        assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
1214    }
1215
1216    #[test]
1217    fn apply_inline_in_place_overrides() {
1218        // Same semantics, mutating form.
1219        let mut s = Stylesheet::new();
1220        s.add(
1221            "Button.primary",
1222            CssStyle::new().color(RC::Blue),
1223            Origin::User,
1224        )
1225        .unwrap();
1226        let n = OwnedNode::new("Button").with_classes(["primary"]);
1227        let mut c = s.compute(&n, None);
1228        c.apply_inline(&CssStyle::new().color("red"));
1229        assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
1230    }
1231
1232    #[test]
1233    fn layout_inner_matches_handwritten_sequence() {
1234        // A fully-featured style: margin (outer), rounded border, padding.
1235        let computed = ComputedStyle::new(
1236            CssStyle::new()
1237                .margin("2")
1238                .padding("1")
1239                .border("rounded #00d4ff"),
1240        );
1241        let area = Rect::new(0, 0, 44, 8);
1242
1243        let (_block, _style, inner_from_layout) = computed.layout(area);
1244
1245        // The hand-written sequence layout() must be equivalent to.
1246        let shrunk = computed.apply_margin(area);
1247        let block = computed.to_block();
1248        let inner_from_hand = block.inner(shrunk);
1249
1250        assert_eq!(inner_from_layout, inner_from_hand);
1251        // Sanity: with margin 2 (each side) + 1 border + 1 padding, the inner
1252        // width drops by 2*(2+1+1) = 8, and height by 8 too.
1253        assert_eq!(inner_from_layout, Rect::new(4, 4, 36, 0));
1254    }
1255
1256    #[test]
1257    fn layout_inner_equals_area_with_no_box_model() {
1258        let computed = ComputedStyle::new(CssStyle::new());
1259        let area = Rect::new(0, 0, 30, 10);
1260        let (_block, _style, inner) = computed.layout(area);
1261        assert_eq!(inner, area);
1262    }
1263
1264    #[test]
1265    fn layout_content_style_matches_to_style() {
1266        let computed = ComputedStyle::new(CssStyle::new().color(RC::Cyan).bold().padding("1"));
1267        let area = Rect::new(0, 0, 20, 5);
1268        let (_block, style, _inner) = computed.layout(area);
1269        assert_eq!(style, computed.to_style());
1270    }
1271
1272    // ---------------------------------------------------------------------
1273    // NodeRef / compute_with parity & reuse
1274    // ---------------------------------------------------------------------
1275
1276    fn parity_sheet() -> Stylesheet {
1277        let mut s = Stylesheet::new();
1278        s.add("Button", CssStyle::new().color(RC::Gray), Origin::User)
1279            .unwrap();
1280        s.add(
1281            "Button.primary",
1282            CssStyle::new().background(RC::Blue),
1283            Origin::User,
1284        )
1285        .unwrap();
1286        s.add("#save", CssStyle::new().color(RC::Yellow), Origin::User)
1287            .unwrap();
1288        s.add(
1289            "Button:focus",
1290            CssStyle::new().background(RC::Green),
1291            Origin::User,
1292        )
1293        .unwrap();
1294        s
1295    }
1296
1297    #[test]
1298    fn noderef_behavioral_parity() {
1299        // Same data via OwnedNode vs NodeRef → identical ComputedStyle across
1300        // all four selector dimensions (type, class, id, state).
1301        let sheet = parity_sheet();
1302
1303        let owned = OwnedNode::new("Button")
1304            .with_id("save")
1305            .with_classes(["primary"])
1306            .with_state(State::focus());
1307        let borrowed = NodeRef::new("Button")
1308            .id("save")
1309            .classes(&["primary"])
1310            .state(State::focus());
1311
1312        let c_owned = sheet.compute(&owned, None);
1313        let c_borrowed = sheet.compute(&borrowed, None);
1314        assert_eq!(c_owned, c_borrowed);
1315    }
1316
1317    #[test]
1318    fn noderef_zero_string_construction() {
1319        // Pure &'static str path — no String/Vec heap allocation is possible.
1320        let sheet = parity_sheet();
1321        let node = NodeRef::new("Button")
1322            .classes(&["primary"])
1323            .state(State::focus());
1324        let c = sheet.compute(&node, None);
1325        // type + class match for background; color from the type rule.
1326        // (Button.primary sets Blue, Button:focus sets Green; same specificity
1327        // (0,1,1) so source order wins → Green, added last.)
1328        assert_eq!(c.style.background, Some(Color::literal(RC::Green)));
1329        assert_eq!(c.style.color, Some(Color::literal(RC::Gray)));
1330    }
1331
1332    #[test]
1333    fn compute_with_matches_compute() {
1334        let sheet = parity_sheet();
1335        let mut scratch = ComputeScratch::new();
1336
1337        let cases: [(&str, OwnedNode); 5] = [
1338            ("plain", OwnedNode::new("Button")),
1339            (
1340                "primary",
1341                OwnedNode::new("Button").with_classes(["primary"]),
1342            ),
1343            ("id", OwnedNode::new("Button").with_id("save")),
1344            ("focus", OwnedNode::new("Button").with_state(State::focus())),
1345            (
1346                "combo",
1347                OwnedNode::new("Button")
1348                    .with_id("save")
1349                    .with_classes(["primary"])
1350                    .with_state(State::focus()),
1351            ),
1352        ];
1353
1354        for (name, node) in cases {
1355            let via_compute = sheet.compute(&node, None);
1356            let via_compute_with = sheet.compute_with(&node, None, &mut scratch);
1357            assert_eq!(via_compute, via_compute_with, "mismatch for case `{name}`");
1358        }
1359    }
1360
1361    #[test]
1362    fn scratch_reuse_no_panic() {
1363        // Reuse the same scratch across many computes of varying sizes — the
1364        // clear()+push() path must stay correct and never leak prior results.
1365        let sheet = parity_sheet();
1366        let mut scratch = ComputeScratch::new();
1367
1368        // A node that matches many rules, then one that matches none, then a
1369        // big one again — exercise the clear/reuse.
1370        let big = NodeRef::new("Button")
1371            .id("save")
1372            .classes(&["primary"])
1373            .state(State::focus());
1374        let none = NodeRef::new("NoSuchType");
1375
1376        let c1 = sheet.compute_with(&big, None, &mut scratch);
1377        let c_none = sheet.compute_with(&none, None, &mut scratch);
1378        let c2 = sheet.compute_with(&big, None, &mut scratch);
1379
1380        // The "none" node only matches the universal-less base → no rules.
1381        assert_eq!(c_none.style.color, None);
1382        // Re-running the big node after the empty one yields the same result.
1383        assert_eq!(c1, c2);
1384        assert_eq!(c1.style.color, Some(Color::literal(RC::Yellow)));
1385    }
1386
1387    // ---------------------------------------------------------------------
1388    // CascadeContext
1389    // ---------------------------------------------------------------------
1390
1391    fn context_sheet() -> Stylesheet {
1392        // Panel sets a color; Text sets none → Text inherits Panel's color.
1393        let mut s = Stylesheet::new();
1394        s.add("Panel", CssStyle::new().color("#cdd6f4"), Origin::User)
1395            .unwrap();
1396        s
1397    }
1398
1399    #[test]
1400    fn context_inherits_without_manual_threading() {
1401        // enter(Panel) then enter(Text) — Text should inherit Panel's color
1402        // without the test ever writing `Some(&parent)`.
1403        let sheet = context_sheet();
1404        let mut ctx = CascadeContext::new(&sheet);
1405
1406        let _panel = ctx.enter(&OwnedNode::new("Panel"));
1407        let text = ctx.enter(&OwnedNode::new("Text"));
1408
1409        assert_eq!(
1410            text.style.color,
1411            Some(Color::literal(RC::Rgb(0xcd, 0xd6, 0xf4)))
1412        );
1413    }
1414
1415    #[test]
1416    fn context_parity_with_manual_compute() {
1417        // Same small tree (Root→Panel→Text) computed two ways: CascadeContext
1418        // vs the hand-written compute(node, Some(&parent)) chain. Every node's
1419        // ComputedStyle must be identical.
1420        let mut sheet = Stylesheet::new();
1421        sheet
1422            .add("Root", CssStyle::new().color(RC::Red), Origin::User)
1423            .unwrap();
1424        sheet
1425            .add("Panel", CssStyle::new().padding("1"), Origin::User)
1426            .unwrap();
1427        // Text sets nothing → inherits everything inheritable from Panel/Root.
1428        sheet.add("Text", CssStyle::new(), Origin::User).unwrap();
1429
1430        // --- CascadeContext path ---
1431        let mut ctx = CascadeContext::new(&sheet);
1432        let ctx_root = ctx.enter(&OwnedNode::new("Root"));
1433        let ctx_panel = ctx.enter(&OwnedNode::new("Panel"));
1434        let ctx_text = ctx.enter(&OwnedNode::new("Text"));
1435
1436        // --- Manual threading path ---
1437        let man_root = sheet.compute(&OwnedNode::new("Root"), None);
1438        let man_panel = sheet.compute(&OwnedNode::new("Panel"), Some(&man_root));
1439        let man_text = sheet.compute(&OwnedNode::new("Text"), Some(&man_panel));
1440
1441        assert_eq!(ctx_root, man_root);
1442        assert_eq!(ctx_panel, man_panel);
1443        assert_eq!(ctx_text, man_text);
1444    }
1445
1446    #[test]
1447    fn context_leave_restores_parent() {
1448        // enter A (color), enter B (different color), leave, enter C (no color)
1449        // → C must inherit from A, not B.
1450        let mut sheet = Stylesheet::new();
1451        sheet
1452            .add("A", CssStyle::new().color(RC::Red), Origin::User)
1453            .unwrap();
1454        sheet
1455            .add("B", CssStyle::new().color(RC::Blue), Origin::User)
1456            .unwrap();
1457        // C has no color rule.
1458        sheet.add("C", CssStyle::new(), Origin::User).unwrap();
1459
1460        let mut ctx = CascadeContext::new(&sheet);
1461        let _a = ctx.enter(&OwnedNode::new("A"));
1462        let _b = ctx.enter(&OwnedNode::new("B"));
1463        ctx.leave(); // drop B
1464        let c = ctx.enter(&OwnedNode::new("C"));
1465
1466        // C inherits A's color (Red), not B's (Blue).
1467        assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
1468    }
1469
1470    #[test]
1471    fn context_depth() {
1472        let sheet = context_sheet();
1473        let mut ctx = CascadeContext::new(&sheet);
1474
1475        assert_eq!(ctx.depth(), 0);
1476        ctx.enter(&OwnedNode::new("Panel"));
1477        assert_eq!(ctx.depth(), 1);
1478        ctx.enter(&OwnedNode::new("Text"));
1479        assert_eq!(ctx.depth(), 2);
1480        ctx.leave();
1481        assert_eq!(ctx.depth(), 1);
1482        ctx.leave();
1483        assert_eq!(ctx.depth(), 0);
1484        assert!(ctx.leave().is_none());
1485    }
1486
1487    #[test]
1488    fn context_scratch_reused() {
1489        // Many consecutive enters of mixed nodes — the internal scratch buffer
1490        // is cleared and reused each time; correctness must not regress.
1491        let mut sheet = Stylesheet::new();
1492        sheet
1493            .add("A", CssStyle::new().color(RC::Red), Origin::User)
1494            .unwrap();
1495        sheet
1496            .add("A.child", CssStyle::new().bold(), Origin::User)
1497            .unwrap();
1498        sheet
1499            .add("NoMatch", CssStyle::new().color(RC::Green), Origin::User)
1500            .unwrap();
1501
1502        let mut ctx = CascadeContext::new(&sheet);
1503
1504        // child matches two rules (A + A.child); NoMatch matches none.
1505        let child = ctx.enter(&OwnedNode::new("A").with_classes(["child"]));
1506        assert_eq!(child.style.color, Some(Color::literal(RC::Red)));
1507
1508        let none = ctx.enter(&OwnedNode::new("TotallyUnknown"));
1509        // No matching rule, no inheritable parent value set on color here
1510        // (parent A had Red, and color is inheritable) → inherits Red.
1511        assert_eq!(none.style.color, Some(Color::literal(RC::Red)));
1512
1513        // Re-run child-like after a no-match — must not leak prior matching set.
1514        ctx.leave();
1515        let child2 = ctx.enter(&OwnedNode::new("A").with_classes(["child"]));
1516        assert_eq!(child2.style.color, Some(Color::literal(RC::Red)));
1517    }
1518
1519    // ---------------------------------------------------------------------
1520    // Length var() resolution (width/height)
1521    // ---------------------------------------------------------------------
1522
1523    #[test]
1524    fn width_var_resolves() {
1525        let sheet = Stylesheet::parse(":root{--w:50%} .col { width: var(--w);}").unwrap();
1526        let node = OwnedNode::new("Div").with_classes(["col"]);
1527        let c = sheet.compute(&node, None);
1528        assert_eq!(c.style.width, Some(crate::box_model::Length::Percent(50)));
1529    }
1530
1531    #[test]
1532    fn width_var_chain() {
1533        let sheet =
1534            Stylesheet::parse(":root{--w: var(--w2); --w2: 10;} .x { width: var(--w); }").unwrap();
1535        let node = OwnedNode::new("Div").with_classes(["x"]);
1536        let c = sheet.compute(&node, None);
1537        assert_eq!(c.style.width, Some(crate::box_model::Length::Cells(10)));
1538    }
1539
1540    #[test]
1541    fn width_var_undefined_degrades_to_auto() {
1542        // Lenient parse: an undefined var degrades to Auto, no error.
1543        let sheet = Stylesheet::parse(".x { width: var(--nope); }").unwrap();
1544        let node = OwnedNode::new("Div").with_classes(["x"]);
1545        let c = sheet.compute(&node, None);
1546        assert_eq!(c.style.width, Some(crate::box_model::Length::Auto));
1547    }
1548
1549    #[test]
1550    fn width_var_mistype_degrades_to_auto() {
1551        // A name bound to a Color is a type mismatch on the length path → Auto.
1552        let sheet = Stylesheet::parse(":root{--c:#fff} .x { width: var(--c); }").unwrap();
1553        let node = OwnedNode::new("Div").with_classes(["x"]);
1554        let c = sheet.compute(&node, None);
1555        assert_eq!(c.style.width, Some(crate::box_model::Length::Auto));
1556    }
1557
1558    #[test]
1559    fn height_var_resolves() {
1560        let sheet = Stylesheet::parse(":root{--h:max(8)} .row { height: var(--h); }").unwrap();
1561        let node = OwnedNode::new("Div").with_classes(["row"]);
1562        let c = sheet.compute(&node, None);
1563        assert_eq!(c.style.height, Some(crate::box_model::Length::Max(8)));
1564    }
1565
1566    #[test]
1567    fn width_var_undefined_uses_fallback() {
1568        // An undefined width var WITH a fallback resolves to the fallback,
1569        // mirroring the color var() path. (Lenient parse; no error.)
1570        let sheet = Stylesheet::parse(".x { width: var(--nope, 7); }").unwrap();
1571        let node = OwnedNode::new("Div").with_classes(["x"]);
1572        let c = sheet.compute(&node, None);
1573        assert_eq!(c.style.width, Some(crate::box_model::Length::Cells(7)));
1574    }
1575
1576    // ---------------------------------------------------------------------
1577    // Box-edges / border-style var() resolution (P6-4)
1578    // ---------------------------------------------------------------------
1579
1580    #[test]
1581    fn padding_var_resolves_from_token() {
1582        let sheet = Stylesheet::parse(
1583            ":root{--pad: 1 2} .x { padding: var(--pad); }",
1584        )
1585        .unwrap();
1586        let node = OwnedNode::new("Div").with_classes(["x"]);
1587        let c = sheet.compute(&node, None);
1588        // 1 2 → top=1, right=2, bottom=1, left=2 (two-value shorthand).
1589        assert_eq!(
1590            c.style.padding,
1591            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges {
1592                top: 1,
1593                right: 2,
1594                bottom: 1,
1595                left: 2,
1596            }))
1597        );
1598    }
1599
1600    #[test]
1601    fn padding_var_resolved_into_block() {
1602        // to_block() projects the resolved padding onto the Block — verify via
1603        // block.inner(area), which shrinks the area by the padding on each side.
1604        let sheet = Stylesheet::parse(
1605            ":root{--pad: 1 2} .x { padding: var(--pad); }",
1606        )
1607        .unwrap();
1608        let node = OwnedNode::new("Div").with_classes(["x"]);
1609        let c = sheet.compute(&node, None);
1610        let block = c.to_block();
1611        // Edges{top:1,right:2,bottom:1,left:2} on a 10x10 area → inner is
1612        // (x=2, y=1, width=6, height=8).
1613        let area = Rect::new(0, 0, 10, 10);
1614        let inner = block.inner(area);
1615        assert_eq!(inner, Rect::new(2, 1, 6, 8));
1616    }
1617
1618    #[test]
1619    fn margin_var_resolves_from_token() {
1620        let sheet = Stylesheet::parse(
1621            ":root{--m: 3} .x { margin: var(--m); }",
1622        )
1623        .unwrap();
1624        let node = OwnedNode::new("Div").with_classes(["x"]);
1625        let c = sheet.compute(&node, None);
1626        assert_eq!(
1627            c.style.margin,
1628            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(3)))
1629        );
1630        // apply_margin shrinks the area by the resolved edges.
1631        let area = Rect::new(0, 0, 10, 10);
1632        let shrunk = c.apply_margin(area);
1633        assert_eq!((shrunk.x, shrunk.y, shrunk.width, shrunk.height), (3, 3, 4, 4));
1634    }
1635
1636    #[test]
1637    fn border_style_var_resolves() {
1638        let sheet = Stylesheet::parse(
1639            ":root{--bs: rounded} .x { border-style: var(--bs); }",
1640        )
1641        .unwrap();
1642        let node = OwnedNode::new("Div").with_classes(["x"]);
1643        let c = sheet.compute(&node, None);
1644        let border = c.style.border.expect("border present");
1645        assert_eq!(
1646            border.style,
1647            crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
1648        );
1649    }
1650
1651    #[test]
1652    fn border_style_var_via_shorthand_resolves() {
1653        // `border: var(--bs)` — the var is the style component.
1654        let sheet = Stylesheet::parse(
1655            ":root{--bs: double} .x { border: var(--bs); }",
1656        )
1657        .unwrap();
1658        let node = OwnedNode::new("Div").with_classes(["x"]);
1659        let c = sheet.compute(&node, None);
1660        let border = c.style.border.expect("border present");
1661        assert_eq!(
1662            border.style,
1663            crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Double)
1664        );
1665    }
1666
1667    #[test]
1668    fn padding_var_undefined_degrades_to_zero() {
1669        // Lenient: an undefined padding var with no fallback → zero edges.
1670        let sheet = Stylesheet::parse(".x { padding: var(--nope); }").unwrap();
1671        let node = OwnedNode::new("Div").with_classes(["x"]);
1672        let c = sheet.compute(&node, None);
1673        assert_eq!(
1674            c.style.padding,
1675            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::zero()))
1676        );
1677        // to_block produces no-op padding (zero edges): block.inner(area) == area.
1678        let block = c.to_block();
1679        let area = Rect::new(0, 0, 10, 10);
1680        assert_eq!(block.inner(area), area);
1681    }
1682
1683    #[test]
1684    fn padding_var_undefined_uses_fallback() {
1685        // An undefined padding var WITH a fallback resolves to the fallback.
1686        let sheet = Stylesheet::parse(".x { padding: var(--nope, 3); }").unwrap();
1687        let node = OwnedNode::new("Div").with_classes(["x"]);
1688        let c = sheet.compute(&node, None);
1689        assert_eq!(
1690            c.style.padding,
1691            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(3)))
1692        );
1693    }
1694
1695    #[test]
1696    fn border_style_var_undefined_degrades_to_none() {
1697        // Lenient: an undefined border-style var → None style.
1698        let sheet = Stylesheet::parse(".x { border-style: var(--nope); }").unwrap();
1699        let node = OwnedNode::new("Div").with_classes(["x"]);
1700        let c = sheet.compute(&node, None);
1701        let border = c.style.border.expect("border present");
1702        assert_eq!(
1703            border.style,
1704            crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::None)
1705        );
1706    }
1707
1708    #[test]
1709    fn box_edges_var_mistype_degrades() {
1710        // A name bound to a Color is a type mismatch on the box-edges path →
1711        // degrades to zero edges.
1712        let sheet = Stylesheet::parse(
1713            ":root{--c:#fff} .x { padding: var(--c); }",
1714        )
1715        .unwrap();
1716        let node = OwnedNode::new("Div").with_classes(["x"]);
1717        let c = sheet.compute(&node, None);
1718        assert_eq!(
1719            c.style.padding,
1720            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::zero()))
1721        );
1722    }
1723
1724    #[test]
1725    fn box_edges_media_gated_token_resolves() {
1726        // A media-gated :root override for a box-edges token resolves under a
1727        // matching context.
1728        let sheet = Stylesheet::parse(
1729            ":root{--pad:1} @media (min-width:80){:root{--pad:2}} .x{padding:var(--pad)}",
1730        )
1731        .unwrap();
1732        let node = OwnedNode::new("Div").with_classes(["x"]);
1733        let mut scratch = ComputeScratch::new();
1734        // Matching → 2.
1735        let large = MediaContext { cols: 100, ..Default::default() };
1736        let c = sheet.compute_with_media(&node, None, &mut scratch, &large);
1737        assert_eq!(
1738            c.style.padding,
1739            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(2)))
1740        );
1741        // Non-matching → default 1.
1742        let small = MediaContext { cols: 40, ..Default::default() };
1743        let c = sheet.compute_with_media(&node, None, &mut scratch, &small);
1744        assert_eq!(
1745            c.style.padding,
1746            Some(crate::box_model::BoxEdgesValue::Edges(BoxEdges::uniform(1)))
1747        );
1748    }
1749
1750    // ---------------------------------------------------------------------
1751    // Combinators (descendant `A B` + child `A > B`) via CascadeContext
1752    // ---------------------------------------------------------------------
1753
1754    #[test]
1755    fn has_combinators_flag() {
1756        // A combinator-free sheet stays false.
1757        let mut plain = Stylesheet::new();
1758        plain.add("Button", CssStyle::new(), Origin::User).unwrap();
1759        assert!(!plain.has_combinators());
1760
1761        // Adding a combinator rule flips it true.
1762        let mut with_comb = Stylesheet::new();
1763        with_comb
1764            .add("Panel Button", CssStyle::new(), Origin::User)
1765            .unwrap();
1766        assert!(with_comb.has_combinators());
1767
1768        // A plain sheet extended with a combinator sheet inherits the flag.
1769        let mut merged = Stylesheet::new();
1770        merged.add("Text", CssStyle::new(), Origin::User).unwrap();
1771        assert!(!merged.has_combinators());
1772        merged.extend(&with_comb);
1773        assert!(merged.has_combinators());
1774    }
1775
1776    #[test]
1777    fn descendant_combinator_matches_in_context() {
1778        // `Panel Text { color: red }` — Text matches as a descendant of Panel.
1779        let mut sheet = Stylesheet::new();
1780        sheet
1781            .add("Panel Text", CssStyle::new().color(RC::Red), Origin::User)
1782            .unwrap();
1783
1784        let mut ctx = CascadeContext::new(&sheet);
1785        let _root = ctx.enter(&OwnedNode::new("Root"));
1786        let _panel = ctx.enter(&OwnedNode::new("Panel"));
1787        let text = ctx.enter(&OwnedNode::new("Text"));
1788
1789        assert_eq!(text.style.color, Some(Color::literal(RC::Red)));
1790    }
1791
1792    #[test]
1793    fn child_combinator_direct_child_matches() {
1794        // `Panel > Text { color: blue }` — Text is a direct child of Panel.
1795        let mut sheet = Stylesheet::new();
1796        sheet
1797            .add("Panel > Text", CssStyle::new().color(RC::Blue), Origin::User)
1798            .unwrap();
1799
1800        let mut ctx = CascadeContext::new(&sheet);
1801        let _root = ctx.enter(&OwnedNode::new("Root"));
1802        let _panel = ctx.enter(&OwnedNode::new("Panel"));
1803        let text = ctx.enter(&OwnedNode::new("Text"));
1804
1805        assert_eq!(text.style.color, Some(Color::literal(RC::Blue)));
1806    }
1807
1808    #[test]
1809    fn child_combinator_indirect_child_does_not_match() {
1810        // `Panel > Text` — when Text's direct parent is Other (not Panel), the
1811        // child combinator must NOT match, even though Panel is an ancestor.
1812        let mut sheet = Stylesheet::new();
1813        sheet
1814            .add("Panel > Text", CssStyle::new().color(RC::Blue), Origin::User)
1815            .unwrap();
1816
1817        let mut ctx = CascadeContext::new(&sheet);
1818        let _root = ctx.enter(&OwnedNode::new("Root"));
1819        let _panel = ctx.enter(&OwnedNode::new("Panel"));
1820        let _other = ctx.enter(&OwnedNode::new("Other"));
1821        let text = ctx.enter(&OwnedNode::new("Text"));
1822
1823        // No matching rule → color absent.
1824        assert_eq!(text.style.color, None);
1825    }
1826
1827    #[test]
1828    fn descendant_vs_child_distinction() {
1829        // A 3-deep tree Root → Panel → Text.
1830        // `Root > Text` must NOT match (Text's direct parent is Panel).
1831        // `Root Text` must match (Text is a descendant of Root).
1832        let mut child_sheet = Stylesheet::new();
1833        child_sheet
1834            .add("Root > Text", CssStyle::new().color(RC::Red), Origin::User)
1835            .unwrap();
1836        let mut desc_sheet = Stylesheet::new();
1837        desc_sheet
1838            .add("Root Text", CssStyle::new().color(RC::Green), Origin::User)
1839            .unwrap();
1840
1841        // Child combinator: does not match the 3-deep tree.
1842        let mut ctx_c = CascadeContext::new(&child_sheet);
1843        let _r = ctx_c.enter(&OwnedNode::new("Root"));
1844        let _p = ctx_c.enter(&OwnedNode::new("Panel"));
1845        let t_c = ctx_c.enter(&OwnedNode::new("Text"));
1846        assert_eq!(t_c.style.color, None, "Root > Text must not match a grandchild");
1847
1848        // Descendant combinator: does match.
1849        let mut ctx_d = CascadeContext::new(&desc_sheet);
1850        let _r = ctx_d.enter(&OwnedNode::new("Root"));
1851        let _p = ctx_d.enter(&OwnedNode::new("Panel"));
1852        let t_d = ctx_d.enter(&OwnedNode::new("Text"));
1853        assert_eq!(t_d.style.color, Some(Color::literal(RC::Green)));
1854    }
1855
1856    #[test]
1857    fn non_combinator_rules_match_in_context() {
1858        // Regression: a plain compound rule still matches through CascadeContext
1859        // when the sheet happens to also have combinators (exercising the
1860        // compute_with_ancestors path for ancestor-less selectors).
1861        let mut sheet = Stylesheet::new();
1862        sheet
1863            .add("Button", CssStyle::new().color(RC::Yellow), Origin::User)
1864            .unwrap();
1865        sheet
1866            .add("Panel Button", CssStyle::new().bold(), Origin::User)
1867            .unwrap();
1868        assert!(sheet.has_combinators());
1869
1870        let mut ctx = CascadeContext::new(&sheet);
1871        let _panel = ctx.enter(&OwnedNode::new("Panel"));
1872        let btn = ctx.enter(&OwnedNode::new("Button"));
1873
1874        // Both rules apply: color from the plain rule, weight bold from the
1875        // combinator rule.
1876        assert_eq!(btn.style.color, Some(Color::literal(RC::Yellow)));
1877        assert!(btn.style.weight.is_some());
1878    }
1879
1880    #[test]
1881    fn combinator_rule_does_not_match_one_shot() {
1882        // Documented limitation: the one-shot compute() path has no ancestor
1883        // context, so a combinator selector does NOT apply there.
1884        let mut sheet = Stylesheet::new();
1885        sheet
1886            .add("Panel Text", CssStyle::new().color(RC::Red), Origin::User)
1887            .unwrap();
1888
1889        let node = OwnedNode::new("Text");
1890        let c = sheet.compute(&node, None);
1891        // No match — color absent.
1892        assert_eq!(c.style.color, None);
1893    }
1894
1895    #[test]
1896    fn context_leave_keeps_stacks_in_sync() {
1897        // After leaving a subtree, a re-entered sibling must match against the
1898        // correct (popped) ancestor chain. This exercises that leave() pops
1899        // the identity stack alongside the style stack.
1900        let mut sheet = Stylesheet::new();
1901        // `Panel > Text` colors red only when Text's direct parent is Panel.
1902        sheet
1903            .add("Panel > Text", CssStyle::new().color(RC::Red), Origin::User)
1904            .unwrap();
1905
1906        let mut ctx = CascadeContext::new(&sheet);
1907        let _root = ctx.enter(&OwnedNode::new("Root"));
1908        let _panel = ctx.enter(&OwnedNode::new("Panel"));
1909        let text1 = ctx.enter(&OwnedNode::new("Text"));
1910        assert_eq!(text1.style.color, Some(Color::literal(RC::Red)));
1911        ctx.leave(); // pop Text
1912
1913        // Re-enter Text as a child of Panel again — must still match.
1914        let text2 = ctx.enter(&OwnedNode::new("Text"));
1915        assert_eq!(text2.style.color, Some(Color::literal(RC::Red)));
1916        ctx.leave(); // pop Text
1917        ctx.leave(); // pop Panel
1918
1919        // Now enter Text as a child of Root — must NOT match (Panel is gone).
1920        let text3 = ctx.enter(&OwnedNode::new("Text"));
1921        assert_eq!(text3.style.color, None);
1922    }
1923
1924    // ---------------------------------------------------------------------
1925    // Sibling combinators (`A + B`, `A ~ B`) via CascadeContext
1926    // ---------------------------------------------------------------------
1927
1928    #[test]
1929    fn adjacent_combinator_matches_preceding_sibling() {
1930        // `Item + Item { color: red }` — three sibling Items under one parent.
1931        // The 2nd and 3rd each have a preceding Item sibling; the 1st does not.
1932        let mut sheet = Stylesheet::new();
1933        sheet
1934            .add("Item + Item", CssStyle::new().color(RC::Red), Origin::User)
1935            .unwrap();
1936        assert!(sheet.has_combinators());
1937
1938        let mut ctx = CascadeContext::new(&sheet);
1939        let _root = ctx.enter(&OwnedNode::new("Root"));
1940
1941        let first = ctx.enter(&OwnedNode::new("Item"));
1942        assert_eq!(first.style.color, None, "first Item has no preceding sibling");
1943        ctx.leave();
1944
1945        let second = ctx.enter(&OwnedNode::new("Item"));
1946        assert_eq!(
1947            second.style.color,
1948            Some(Color::literal(RC::Red)),
1949            "second Item follows a sibling Item"
1950        );
1951        ctx.leave();
1952
1953        let third = ctx.enter(&OwnedNode::new("Item"));
1954        assert_eq!(
1955            third.style.color,
1956            Some(Color::literal(RC::Red)),
1957            "third Item follows a sibling Item"
1958        );
1959    }
1960
1961    #[test]
1962    fn general_sibling_combinator_matches_any_preceding() {
1963        // `Item ~ Item { color: blue }` — same three-item layout: 2nd and 3rd
1964        // have at least one prior Item sibling.
1965        let mut sheet = Stylesheet::new();
1966        sheet
1967            .add("Item ~ Item", CssStyle::new().color(RC::Blue), Origin::User)
1968            .unwrap();
1969
1970        let mut ctx = CascadeContext::new(&sheet);
1971        let _root = ctx.enter(&OwnedNode::new("Root"));
1972
1973        let first = ctx.enter(&OwnedNode::new("Item"));
1974        assert_eq!(first.style.color, None);
1975        ctx.leave();
1976
1977        let second = ctx.enter(&OwnedNode::new("Item"));
1978        assert_eq!(second.style.color, Some(Color::literal(RC::Blue)));
1979        ctx.leave();
1980
1981        let third = ctx.enter(&OwnedNode::new("Item"));
1982        assert_eq!(third.style.color, Some(Color::literal(RC::Blue)));
1983    }
1984
1985    #[test]
1986    fn adjacent_combinator_requires_immediate_predecessor_type() {
1987        // `Header + Content` — Content matches only when its immediately
1988        // preceding sibling is Header. A Sidebar predecessor must NOT trigger.
1989        let mut sheet = Stylesheet::new();
1990        sheet
1991            .add("Header + Content", CssStyle::new().color(RC::Green), Origin::User)
1992            .unwrap();
1993
1994        let mut ctx = CascadeContext::new(&sheet);
1995        let _root = ctx.enter(&OwnedNode::new("Root"));
1996
1997        // Sidebar then Content — immediate predecessor is Sidebar, not Header.
1998        let _sidebar = ctx.enter(&OwnedNode::new("Sidebar"));
1999        ctx.leave();
2000        let content = ctx.enter(&OwnedNode::new("Content"));
2001        assert_eq!(content.style.color, None);
2002
2003        ctx.leave();
2004        // Now Header then Content — match.
2005        let _header = ctx.enter(&OwnedNode::new("Header"));
2006        ctx.leave();
2007        let content2 = ctx.enter(&OwnedNode::new("Content"));
2008        assert_eq!(content2.style.color, Some(Color::literal(RC::Green)));
2009    }
2010
2011    #[test]
2012    fn sibling_plus_descendant_combinator() {
2013        // `Panel Item + Item` — an Item that follows an Item sibling, both
2014        // inside Panel (Panel as ancestor). Exercises the sibling + descendant
2015        // combination through the full CascadeContext path.
2016        let mut sheet = Stylesheet::new();
2017        sheet
2018            .add("Panel Item + Item", CssStyle::new().color(RC::Red), Origin::User)
2019            .unwrap();
2020
2021        let mut ctx = CascadeContext::new(&sheet);
2022        let _root = ctx.enter(&OwnedNode::new("Root"));
2023        let _panel = ctx.enter(&OwnedNode::new("Panel"));
2024
2025        let first = ctx.enter(&OwnedNode::new("Item"));
2026        assert_eq!(first.style.color, None);
2027        ctx.leave();
2028
2029        let second = ctx.enter(&OwnedNode::new("Item"));
2030        assert_eq!(second.style.color, Some(Color::literal(RC::Red)));
2031    }
2032
2033    #[test]
2034    fn sibling_lists_reset_on_new_parent() {
2035        // Items under ParentA, then items under ParentB: a ParentB item must
2036        // NOT see ParentA's items as siblings. Verifies the `siblings[D+1]`
2037        // clear on enter resets the children context.
2038        let mut sheet = Stylesheet::new();
2039        sheet
2040            .add("Item + Item", CssStyle::new().color(RC::Red), Origin::User)
2041            .unwrap();
2042
2043        let mut ctx = CascadeContext::new(&sheet);
2044        let _root = ctx.enter(&OwnedNode::new("Root"));
2045
2046        // ParentA: Item, Item (the second matches `Item + Item`).
2047        let _pa = ctx.enter(&OwnedNode::new("ParentA"));
2048        let _pa_first = ctx.enter(&OwnedNode::new("Item"));
2049        ctx.leave();
2050        let _pa_second = ctx.enter(&OwnedNode::new("Item"));
2051        assert_eq!(_pa_second.style.color, Some(Color::literal(RC::Red)));
2052        ctx.leave();
2053        ctx.leave(); // leave ParentA
2054
2055        // ParentB: first Item must NOT see ParentA's second Item as a sibling.
2056        let _pb = ctx.enter(&OwnedNode::new("ParentB"));
2057        let pb_first = ctx.enter(&OwnedNode::new("Item"));
2058        assert_eq!(pb_first.style.color, None, "ParentB's first item has no prior sibling");
2059    }
2060
2061    #[test]
2062    fn sibling_combinator_does_not_match_one_shot() {
2063        // Documented limitation: the one-shot compute() path has no sibling
2064        // context, so a `+`/`~` selector does NOT apply there.
2065        let mut sheet = Stylesheet::new();
2066        sheet
2067            .add("Item + Item", CssStyle::new().color(RC::Red), Origin::User)
2068            .unwrap();
2069
2070        let node = OwnedNode::new("Item");
2071        let c = sheet.compute(&node, None);
2072        assert_eq!(c.style.color, None);
2073    }
2074
2075    #[test]
2076    fn descendant_combinator_still_matches_via_context() {
2077        // Regression: existing descendant/child combinators keep working after
2078        // the sibling-tracking plumbing lands.
2079        let mut sheet = Stylesheet::new();
2080        sheet
2081            .add("Panel Button", CssStyle::new().color(RC::Yellow), Origin::User)
2082            .unwrap();
2083        sheet
2084            .add("Panel > Button", CssStyle::new().bold(), Origin::User)
2085            .unwrap();
2086
2087        let mut ctx = CascadeContext::new(&sheet);
2088        let _panel = ctx.enter(&OwnedNode::new("Panel"));
2089        let btn = ctx.enter(&OwnedNode::new("Button"));
2090        assert_eq!(btn.style.color, Some(Color::literal(RC::Yellow)));
2091        assert!(btn.style.weight.is_some());
2092    }
2093
2094    // ---------------------------------------------------------------------
2095    // @media queries
2096    // ---------------------------------------------------------------------
2097
2098    fn media_sheet() -> Stylesheet {
2099        // A media-gated Button rule: only applies when cols >= 80.
2100        Stylesheet::parse("@media (min-width: 80) { Button { color: red; } }").unwrap()
2101    }
2102
2103    #[test]
2104    fn media_rule_applies_when_context_matches() {
2105        let sheet = media_sheet();
2106        let mut scratch = ComputeScratch::new();
2107        let media = MediaContext { cols: 100, rows: 24, ..Default::default() };
2108        let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
2109        assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
2110    }
2111
2112    #[test]
2113    fn media_rule_skipped_when_context_does_not_match() {
2114        let sheet = media_sheet();
2115        let mut scratch = ComputeScratch::new();
2116        let media = MediaContext { cols: 60, rows: 24, ..Default::default() };
2117        let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
2118        assert_eq!(c.style.color, None, "media-gated rule must not apply when cols < 80");
2119    }
2120
2121    #[test]
2122    fn media_rule_skipped_by_default_context() {
2123        // The default context (cols=0) must NOT satisfy min-width: 80.
2124        let sheet = media_sheet();
2125        let c = sheet.compute(&OwnedNode::new("Button"), None);
2126        assert_eq!(c.style.color, None, "default-context compute does not apply media-gated rules");
2127    }
2128
2129    #[test]
2130    fn plain_and_media_rules_coexist() {
2131        // A sheet with BOTH a plain (always-applies) rule and a media-gated rule.
2132        let sheet = Stylesheet::parse(
2133            "Button { color: blue; } @media (min-width: 80) { Button { color: red; } }",
2134        )
2135        .unwrap();
2136        let mut scratch = ComputeScratch::new();
2137
2138        // Small terminal: only the plain rule applies → blue.
2139        let small = MediaContext { cols: 40, ..Default::default() };
2140        let c_small = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &small);
2141        assert_eq!(c_small.style.color, Some(Color::literal(RC::Blue)));
2142
2143        // Large terminal: media rule (later source order, same specificity) wins → red.
2144        let large = MediaContext { cols: 120, ..Default::default() };
2145        let c_large = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &large);
2146        assert_eq!(c_large.style.color, Some(Color::literal(RC::Red)));
2147    }
2148
2149    #[test]
2150    fn cascade_context_with_media_applies_gated_rule() {
2151        let sheet = media_sheet();
2152        let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
2153            cols: 100,
2154            rows: 24,
2155            ..Default::default()
2156        });
2157        let btn = ctx.enter(&OwnedNode::new("Button"));
2158        assert_eq!(btn.style.color, Some(Color::literal(RC::Red)));
2159
2160        // Switch to a non-matching context and re-enter — rule no longer applies.
2161        ctx.set_media(MediaContext { cols: 40, ..Default::default() });
2162        ctx.leave();
2163        let btn2 = ctx.enter(&OwnedNode::new("Button"));
2164        assert_eq!(btn2.style.color, None);
2165    }
2166
2167    #[test]
2168    fn cascade_context_media_combinator_path() {
2169        // Stress the compute_with_ancestors_media path: a sheet that has BOTH a
2170        // combinator rule and a media-gated combinator rule.
2171        let sheet = Stylesheet::parse(
2172            "@media (min-width: 80) { Panel Button { color: green; } }",
2173        )
2174        .unwrap();
2175        assert!(sheet.has_combinators());
2176
2177        let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
2178            cols: 100,
2179            rows: 24,
2180            ..Default::default()
2181        });
2182        let _panel = ctx.enter(&OwnedNode::new("Panel"));
2183        let btn = ctx.enter(&OwnedNode::new("Button"));
2184        assert_eq!(btn.style.color, Some(Color::literal(RC::Green)));
2185
2186        // Small context: the combinator media rule must NOT apply.
2187        ctx.set_media(MediaContext { cols: 40, ..Default::default() });
2188        ctx.leave();
2189        ctx.leave();
2190        let _panel2 = ctx.enter(&OwnedNode::new("Panel"));
2191        let btn2 = ctx.enter(&OwnedNode::new("Button"));
2192        assert_eq!(btn2.style.color, None);
2193    }
2194
2195    // ---------------------------------------------------------------------
2196    // Media-gated :root token resolution end-to-end (P4-3)
2197    // ---------------------------------------------------------------------
2198
2199    fn media_token_sheet() -> Stylesheet {
2200        // :root default = red; @media (min-width: 80) override = blue.
2201        Stylesheet::parse(
2202            ":root { --accent: red } @media (min-width: 80) { :root { --accent: blue } } .a { color: var(--accent); }",
2203        )
2204        .unwrap()
2205    }
2206
2207    #[test]
2208    fn media_gated_token_resolves_blue_under_matching_context() {
2209        let sheet = media_token_sheet();
2210        let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
2211            cols: 100,
2212            ..Default::default()
2213        });
2214        let a = ctx.enter(&OwnedNode::new("Div").with_classes(["a"]));
2215        assert_eq!(a.style.color, Some(Color::literal(RC::Blue)));
2216    }
2217
2218    #[test]
2219    fn media_gated_token_resolves_red_under_non_matching_context() {
2220        let sheet = media_token_sheet();
2221        let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
2222            cols: 60,
2223            ..Default::default()
2224        });
2225        let a = ctx.enter(&OwnedNode::new("Div").with_classes(["a"]));
2226        assert_eq!(a.style.color, Some(Color::literal(RC::Red)));
2227    }
2228
2229    #[test]
2230    fn media_gated_token_resolves_default_via_one_shot_compute() {
2231        // The one-shot compute path uses a default MediaContext, so the
2232        // media-gated override does NOT apply — only the default (red) does.
2233        let sheet = media_token_sheet();
2234        let a = sheet.compute(&OwnedNode::new("Div").with_classes(["a"]), None);
2235        assert_eq!(a.style.color, Some(Color::literal(RC::Red)));
2236    }
2237
2238    #[test]
2239    fn media_gated_token_via_compute_with_media() {
2240        let sheet = media_token_sheet();
2241        let mut scratch = ComputeScratch::new();
2242        let node = OwnedNode::new("Div").with_classes(["a"]);
2243        // Matching → blue.
2244        let large = MediaContext { cols: 100, ..Default::default() };
2245        let c_large = sheet.compute_with_media(&node, None, &mut scratch, &large);
2246        assert_eq!(c_large.style.color, Some(Color::literal(RC::Blue)));
2247        // Non-matching → red.
2248        let small = MediaContext { cols: 60, ..Default::default() };
2249        let c_small = sheet.compute_with_media(&node, None, &mut scratch, &small);
2250        assert_eq!(c_small.style.color, Some(Color::literal(RC::Red)));
2251    }
2252
2253    #[test]
2254    fn non_media_tokens_still_resolve_as_before() {
2255        // Regression: a plain :root token (no @media) resolves exactly as before
2256        // under both the one-shot and context paths.
2257        let sheet = Stylesheet::parse(
2258            ":root { --c: #abcdef } .x { color: var(--c); }",
2259        )
2260        .unwrap();
2261        let node = OwnedNode::new("Div").with_classes(["x"]);
2262        let one_shot = sheet.compute(&node, None);
2263        assert_eq!(
2264            one_shot.style.color,
2265            Some(Color::literal(RC::Rgb(0xab, 0xcd, 0xef)))
2266        );
2267        let mut ctx = CascadeContext::new(&sheet);
2268        let via_ctx = ctx.enter(&node);
2269        assert_eq!(via_ctx.style.color, one_shot.style.color);
2270    }
2271
2272    // ---------------------------------------------------------------------
2273    // ComputeCache via CascadeContext (P4-4)
2274    // ---------------------------------------------------------------------
2275
2276    /// Walk a 3-level tree (Root → Panel → Text) once through `ctx`, capturing
2277    /// each node's `ComputedStyle` into `out` in enter order.
2278    fn walk_tree_cached(ctx: &mut CascadeContext<'_>, out: &mut Vec<ComputedStyle>) {
2279        out.push(ctx.enter(&OwnedNode::new("Root")));
2280        out.push(ctx.enter(&OwnedNode::new("Panel")));
2281        out.push(ctx.enter(&OwnedNode::new("Text")));
2282        ctx.leave();
2283        ctx.leave();
2284        ctx.leave();
2285    }
2286
2287    #[test]
2288    fn cache_warm_walk_produces_identical_styles() {
2289        // First (cold) walk misses; second (warm) walk hits on every node.
2290        // Correctness invariant: the warm walk's results are byte-identical to
2291        // the cold walk's.
2292        let mut sheet = Stylesheet::new();
2293        sheet.add("Root", CssStyle::new().color(RC::Red), Origin::User).unwrap();
2294        sheet.add("Panel", CssStyle::new().padding("1"), Origin::User).unwrap();
2295        sheet.add("Text", CssStyle::new().bold(), Origin::User).unwrap();
2296
2297        let mut ctx = CascadeContext::new(&sheet).with_cache(16);
2298        let mut cold = Vec::new();
2299        walk_tree_cached(&mut ctx, &mut cold);
2300        // After the cold walk the cache holds 3 distinct signatures.
2301        assert_eq!(ctx.cache().unwrap().len(), 3);
2302
2303        // Second walk — should be served entirely from the cache.
2304        let mut warm = Vec::new();
2305        walk_tree_cached(&mut ctx, &mut warm);
2306
2307        // Correctness: warm == cold.
2308        assert_eq!(warm.len(), cold.len());
2309        for (i, (w, c)) in warm.iter().zip(cold.iter()).enumerate() {
2310            assert_eq!(w, c, "warm walk node {i} differs from cold walk");
2311        }
2312        // The cache size is unchanged (no new inserts on a warm walk).
2313        assert_eq!(ctx.cache().unwrap().len(), 3);
2314    }
2315
2316    #[test]
2317    fn cache_invalidated_by_stylesheet_mutation() {
2318        // After sheet.add(...), the generation bumps and the next compute must
2319        // recompute (cache cleared). The new rule's effect must show up.
2320        //
2321        // We use the one-shot compute_cached API directly because CascadeContext
2322        // borrows the sheet immutably for its whole lifetime — a real host
2323        // drops the context, mutates the sheet, then rebuilds it. The cache
2324        // here stands in for a cache the host carries across frames.
2325        let mut sheet = Stylesheet::new();
2326        sheet.add("Text", CssStyle::new().color(RC::Red), Origin::User).unwrap();
2327
2328        let mut scratch = ComputeScratch::new();
2329        let mut cache = ComputeCache::new(8);
2330        let media = MediaContext::default();
2331        let node = OwnedNode::new("Text");
2332
2333        let (text1, sig1) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
2334        assert_eq!(text1.style.color, Some(Color::literal(RC::Red)));
2335        assert_eq!(cache.len(), 1);
2336
2337        // Mutate the sheet — generation bumps, cache auto-invalidates on next access.
2338        sheet.add("Text", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
2339
2340        // Re-compute: the cache detects the gen mismatch and clears; the new
2341        // (later, same-specificity) rule wins → Blue.
2342        let (text2, sig2) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
2343        assert_eq!(
2344            text2.style.color,
2345            Some(Color::literal(RC::Blue)),
2346            "mutation must invalidate the cache"
2347        );
2348        // The signature is the same (same node, same media, same parent), but
2349        // the cache was cleared by the gen mismatch and repopulated.
2350        assert_eq!(sig1, sig2);
2351        assert_eq!(cache.len(), 1);
2352    }
2353
2354    #[test]
2355    fn cache_invalidated_by_tokens_mut() {
2356        // tokens_mut bumps gen, so a downstream var() resolution changes.
2357        let mut sheet = Stylesheet::with_tokens(
2358            crate::token::ThemeTokens::new().set("accent", Color::literal(RC::Red)),
2359        );
2360        sheet.add(".a", CssStyle::new().color(Color::var("accent")), Origin::User).unwrap();
2361
2362        let mut scratch = ComputeScratch::new();
2363        let mut cache = ComputeCache::new(8);
2364        let media = MediaContext::default();
2365        let node = OwnedNode::new("Div").with_classes(["a"]);
2366
2367        let (a1, _) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
2368        assert_eq!(a1.style.color, Some(Color::literal(RC::Red)));
2369
2370        // Mutate the token via tokens_mut — gen bumps.
2371        sheet.tokens_mut().insert("accent", Color::literal(RC::Blue));
2372
2373        let (a2, _) = sheet.compute_cached(&node, None, None, &media, &mut scratch, &mut cache);
2374        assert_eq!(
2375            a2.style.color,
2376            Some(Color::literal(RC::Blue)),
2377            "tokens_mut must invalidate the cache so the var re-resolves"
2378        );
2379    }
2380
2381    #[test]
2382    fn cache_invalidated_by_media_change() {
2383        // Media is part of the signature, so changing the active media context
2384        // produces different signatures and naturally recomputes.
2385        let sheet = Stylesheet::parse(
2386            "@media (min-width: 80) { Button { color: red; } }",
2387        )
2388        .unwrap();
2389
2390        let mut ctx = CascadeContext::new(&sheet).with_cache(8).with_media(MediaContext {
2391            cols: 100,
2392            ..Default::default()
2393        });
2394        let big = ctx.enter(&OwnedNode::new("Button"));
2395        assert_eq!(big.style.color, Some(Color::literal(RC::Red)));
2396        ctx.leave();
2397
2398        // Switch to a non-matching context: the signature differs (media is
2399        // folded in), so the cache misses and the rule no longer applies.
2400        ctx.set_media(MediaContext { cols: 40, ..Default::default() });
2401        let small = ctx.enter(&OwnedNode::new("Button"));
2402        assert_eq!(small.style.color, None);
2403    }
2404
2405    #[test]
2406    fn cache_parent_dependency_different_parents() {
2407        // Two nodes with identical identity but different parents inherit
2408        // differently → different signatures → different results.
2409        let mut sheet = Stylesheet::new();
2410        sheet.add("Red", CssStyle::new().color(RC::Red), Origin::User).unwrap();
2411        sheet.add("Blue", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
2412        // Child has no color of its own — inherits.
2413        sheet.add("Child", CssStyle::new(), Origin::User).unwrap();
2414
2415        let mut ctx = CascadeContext::new(&sheet).with_cache(8);
2416
2417        // Branch A: Red → Child
2418        let _red = ctx.enter(&OwnedNode::new("Red"));
2419        let child_a = ctx.enter(&OwnedNode::new("Child"));
2420        assert_eq!(child_a.style.color, Some(Color::literal(RC::Red)));
2421        ctx.leave();
2422        ctx.leave();
2423
2424        // Branch B: Blue → Child
2425        let _blue = ctx.enter(&OwnedNode::new("Blue"));
2426        let child_b = ctx.enter(&OwnedNode::new("Child"));
2427        assert_eq!(
2428            child_b.style.color,
2429            Some(Color::literal(RC::Blue)),
2430            "identical Child node with a different parent must produce a different result"
2431        );
2432    }
2433
2434    #[test]
2435    fn cache_works_with_combinator_sheet_descendant() {
2436        // A sheet with a descendant combinator (`Panel Text`) walked via
2437        // with_cache must still match — the cached-ancestors path is used.
2438        let mut sheet = Stylesheet::new();
2439        sheet.add("Panel Text", CssStyle::new().color(RC::Green), Origin::User).unwrap();
2440        assert!(sheet.has_combinators());
2441
2442        let mut ctx = CascadeContext::new(&sheet).with_cache(8);
2443        let _root = ctx.enter(&OwnedNode::new("Root"));
2444        let _panel = ctx.enter(&OwnedNode::new("Panel"));
2445        let text = ctx.enter(&OwnedNode::new("Text"));
2446        assert_eq!(text.style.color, Some(Color::literal(RC::Green)));
2447
2448        // Second walk — the cached result is served and still matches.
2449        ctx.leave();
2450        ctx.leave();
2451        ctx.leave();
2452        let _root = ctx.enter(&OwnedNode::new("Root"));
2453        let _panel = ctx.enter(&OwnedNode::new("Panel"));
2454        let text2 = ctx.enter(&OwnedNode::new("Text"));
2455        assert_eq!(text2.style.color, Some(Color::literal(RC::Green)));
2456        assert_eq!(text2, text, "warm cached walk == cold walk for combinators");
2457    }
2458
2459    #[test]
2460    fn cache_works_with_combinator_sheet_child() {
2461        // Child combinator + cache.
2462        let mut sheet = Stylesheet::new();
2463        sheet.add("Panel > Text", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
2464        assert!(sheet.has_combinators());
2465
2466        let mut ctx = CascadeContext::new(&sheet).with_cache(8);
2467        let _root = ctx.enter(&OwnedNode::new("Root"));
2468        let _panel = ctx.enter(&OwnedNode::new("Panel"));
2469        let text = ctx.enter(&OwnedNode::new("Text"));
2470        assert_eq!(text.style.color, Some(Color::literal(RC::Blue)));
2471    }
2472
2473    #[test]
2474    fn cache_works_with_sibling_combinator() {
2475        // `Item + Item` + cache: the sibling identities are folded into the
2476        // parent signature transitively... actually they are NOT directly in
2477        // the signature, so we assert this carefully: the cached-ancestors
2478        // path is used, and the rule applies on the cold walk. On a warm walk
2479        // with the SAME sibling structure, the result is stable.
2480        let mut sheet = Stylesheet::new();
2481        sheet.add("Item + Item", CssStyle::new().color(RC::Red), Origin::User).unwrap();
2482        assert!(sheet.has_combinators());
2483
2484        let mut ctx = CascadeContext::new(&sheet).with_cache(16);
2485        let _root = ctx.enter(&OwnedNode::new("Root"));
2486
2487        let first = ctx.enter(&OwnedNode::new("Item"));
2488        assert_eq!(first.style.color, None);
2489        ctx.leave();
2490        let second = ctx.enter(&OwnedNode::new("Item"));
2491        assert_eq!(second.style.color, Some(Color::literal(RC::Red)));
2492        ctx.leave();
2493        ctx.leave();
2494
2495        // Warm walk with the same structure.
2496        let _root = ctx.enter(&OwnedNode::new("Root"));
2497        let first2 = ctx.enter(&OwnedNode::new("Item"));
2498        assert_eq!(first2.style.color, None);
2499        ctx.leave();
2500        let second2 = ctx.enter(&OwnedNode::new("Item"));
2501        assert_eq!(second2.style.color, Some(Color::literal(RC::Red)));
2502    }
2503
2504    #[test]
2505    fn cache_off_context_behaves_identically() {
2506        // Regression: a CascadeContext WITHOUT with_cache is byte-for-byte
2507        // identical to the uncached baseline. We assert by comparing against
2508        // the manual compute() chain for a 3-level tree.
2509        let mut sheet = Stylesheet::new();
2510        sheet.add("Root", CssStyle::new().color(RC::Red), Origin::User).unwrap();
2511        sheet.add("Panel", CssStyle::new().padding("1"), Origin::User).unwrap();
2512        sheet.add("Text", CssStyle::new().bold(), Origin::User).unwrap();
2513
2514        // Context path (no cache).
2515        let mut ctx = CascadeContext::new(&sheet);
2516        let ctx_root = ctx.enter(&OwnedNode::new("Root"));
2517        let ctx_panel = ctx.enter(&OwnedNode::new("Panel"));
2518        let ctx_text = ctx.enter(&OwnedNode::new("Text"));
2519
2520        // Manual threading.
2521        let man_root = sheet.compute(&OwnedNode::new("Root"), None);
2522        let man_panel = sheet.compute(&OwnedNode::new("Panel"), Some(&man_root));
2523        let man_text = sheet.compute(&OwnedNode::new("Text"), Some(&man_panel));
2524
2525        assert_eq!(ctx_root, man_root);
2526        assert_eq!(ctx_panel, man_panel);
2527        assert_eq!(ctx_text, man_text);
2528
2529        // No cache attached.
2530        assert!(ctx.cache().is_none());
2531    }
2532
2533    #[test]
2534    fn cache_recomputes_correctly_after_mixed_tree_walks() {
2535        // Stress: walk a tree with siblings, leave, walk a different shape,
2536        // then re-walk the first. The cache must stay correct — every signature
2537        // is built fresh from the current ancestor chain, so identical subtrees
2538        // share entries.
2539        let mut sheet = Stylesheet::new();
2540        sheet.add("A", CssStyle::new().color(RC::Red), Origin::User).unwrap();
2541        sheet.add("B", CssStyle::new().color(RC::Blue), Origin::User).unwrap();
2542
2543        let mut ctx = CascadeContext::new(&sheet).with_cache(32);
2544
2545        // Walk A → B.
2546        let _ = ctx.enter(&OwnedNode::new("A"));
2547        let b1 = ctx.enter(&OwnedNode::new("B"));
2548        assert_eq!(b1.style.color, Some(Color::literal(RC::Blue)));
2549        ctx.leave();
2550        ctx.leave();
2551
2552        // Walk B → A (different structure).
2553        let _ = ctx.enter(&OwnedNode::new("B"));
2554        let a1 = ctx.enter(&OwnedNode::new("A"));
2555        // A has its own color (Red), does not inherit Blue from B (color is
2556        // inheritable but A's rule sets Red explicitly).
2557        assert_eq!(a1.style.color, Some(Color::literal(RC::Red)));
2558        ctx.leave();
2559        ctx.leave();
2560
2561        // Re-walk A → B — identical to the first, should be served from cache.
2562        let _ = ctx.enter(&OwnedNode::new("A"));
2563        let b2 = ctx.enter(&OwnedNode::new("B"));
2564        assert_eq!(b2.style.color, Some(Color::literal(RC::Blue)));
2565        assert_eq!(b2, b1, "re-walked subtree is identical to the first");
2566    }
2567
2568    // ---------------------------------------------------------------------
2569    // @supports queries
2570    // ---------------------------------------------------------------------
2571
2572    fn supports_sheet() -> Stylesheet {
2573        Stylesheet::parse("@supports (truecolor) { Button { color: red; } }").unwrap()
2574    }
2575
2576    #[test]
2577    fn supports_rule_applies_when_capability_matches() {
2578        let sheet = supports_sheet();
2579        let mut scratch = ComputeScratch::new();
2580        let media = MediaContext { truecolor: true, ..Default::default() };
2581        let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
2582        assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
2583    }
2584
2585    #[test]
2586    fn supports_rule_skipped_when_capability_does_not_match() {
2587        let sheet = supports_sheet();
2588        let mut scratch = ComputeScratch::new();
2589        let media = MediaContext { truecolor: false, ..Default::default() };
2590        let c = sheet.compute_with_media(&OwnedNode::new("Button"), None, &mut scratch, &media);
2591        assert_eq!(c.style.color, None, "supports-gated rule must not apply when truecolor is off");
2592    }
2593
2594    #[test]
2595    fn supports_rule_skipped_by_default_context() {
2596        // The default context (truecolor=false) must NOT satisfy (truecolor).
2597        let sheet = supports_sheet();
2598        let c = sheet.compute(&OwnedNode::new("Button"), None);
2599        assert_eq!(c.style.color, None, "default-context compute does not apply supports-gated rules");
2600    }
2601
2602    #[test]
2603    fn supports_property_known_applies() {
2604        // (border-style) is a known engine property → applies regardless of ctx.
2605        let sheet = Stylesheet::parse("@supports (border-style) { .x { border-style: rounded; } }").unwrap();
2606        let mut scratch = ComputeScratch::new();
2607        let media = MediaContext::default();
2608        let c = sheet.compute_with_media(&OwnedNode::new("Div").with_classes(["x"]), None, &mut scratch, &media);
2609        let border = c.style.border.expect("border present");
2610        assert_eq!(
2611            border.style,
2612            crate::box_model::BorderStyleValue::Fixed(crate::box_model::BorderStyle::Rounded)
2613        );
2614    }
2615
2616    #[test]
2617    fn supports_property_unknown_does_not_apply() {
2618        // (future-thing) is NOT a known engine property → rule skipped.
2619        let sheet = Stylesheet::parse("@supports (future-thing) { .x { color: red; } }").unwrap();
2620        let mut scratch = ComputeScratch::new();
2621        let media = MediaContext::default();
2622        let c = sheet.compute_with_media(&OwnedNode::new("Div").with_classes(["x"]), None, &mut scratch, &media);
2623        assert_eq!(c.style.color, None, "supports rule with unknown property must not apply");
2624    }
2625
2626    #[test]
2627    fn supports_combined_with_media_requires_both() {
2628        // A rule gated by BOTH @media (min-width:80) AND @supports (truecolor)
2629        // applies only when BOTH match.
2630        let sheet = Stylesheet::parse(
2631            "@media (min-width: 80) { @supports (truecolor) { Button { color: green; } } }",
2632        )
2633        .unwrap();
2634
2635        let mut scratch = ComputeScratch::new();
2636        let node = OwnedNode::new("Button");
2637
2638        // Both match → applies.
2639        let both = MediaContext { cols: 100, truecolor: true, ..Default::default() };
2640        let c = sheet.compute_with_media(&node, None, &mut scratch, &both);
2641        assert_eq!(c.style.color, Some(Color::literal(RC::Green)), "both match → applies");
2642
2643        // Media matches, supports does not → skip.
2644        let media_only = MediaContext { cols: 100, truecolor: false, ..Default::default() };
2645        let c = sheet.compute_with_media(&node, None, &mut scratch, &media_only);
2646        assert_eq!(c.style.color, None, "supports fails → no apply");
2647
2648        // Supports matches, media does not → skip.
2649        let supports_only = MediaContext { cols: 40, truecolor: true, ..Default::default() };
2650        let c = sheet.compute_with_media(&node, None, &mut scratch, &supports_only);
2651        assert_eq!(c.style.color, None, "media fails → no apply");
2652    }
2653
2654    #[test]
2655    fn supports_inside_media_applies_when_both_match_via_context() {
2656        // Same nesting through CascadeContext (the combinator-aware path).
2657        let sheet = Stylesheet::parse(
2658            "@media (min-width: 80) { @supports (truecolor) { Button { color: green; } } }",
2659        )
2660        .unwrap();
2661        let mut ctx = CascadeContext::new(&sheet).with_media(MediaContext {
2662            cols: 100,
2663            truecolor: true,
2664            ..Default::default()
2665        });
2666        let btn = ctx.enter(&OwnedNode::new("Button"));
2667        assert_eq!(btn.style.color, Some(Color::literal(RC::Green)));
2668
2669        // Drop supports → no longer applies.
2670        ctx.set_media(MediaContext { cols: 100, truecolor: false, ..Default::default() });
2671        ctx.leave();
2672        let btn2 = ctx.enter(&OwnedNode::new("Button"));
2673        assert_eq!(btn2.style.color, None);
2674    }
2675
2676    #[test]
2677    fn plain_and_supports_rules_coexist() {
2678        // A sheet with BOTH a plain (always-applies) rule and a supports-gated
2679        // rule. The supports-gated rule has later source order, so it wins when
2680        // it applies.
2681        let sheet = Stylesheet::parse(
2682            "Button { color: blue; } @supports (truecolor) { Button { color: red; } }",
2683        )
2684        .unwrap();
2685        let mut scratch = ComputeScratch::new();
2686        let node = OwnedNode::new("Button");
2687
2688        // truecolor off: only the plain rule applies → blue.
2689        let tc_off = MediaContext { truecolor: false, ..Default::default() };
2690        let c = sheet.compute_with_media(&node, None, &mut scratch, &tc_off);
2691        assert_eq!(c.style.color, Some(Color::literal(RC::Blue)));
2692
2693        // truecolor on: supports rule wins (later source order) → red.
2694        let tc_on = MediaContext { truecolor: true, ..Default::default() };
2695        let c = sheet.compute_with_media(&node, None, &mut scratch, &tc_on);
2696        assert_eq!(c.style.color, Some(Color::literal(RC::Red)));
2697    }
2698}