Skip to main content

bevy_react/
layer.rs

1//! Auto-promotion of UI subtrees to composited **layers**.
2//!
3//! A node whose style makes it a *layer root* (`opacity` present on a node
4//! with children unless `groupAlpha: false`, a non-empty `filter` chain, or
5//! `cache: "always"`) has its
6//! whole subtree captured into an offscreen atlas by a custom render pass and
7//! drawn back as one quad — so `opacity` fades the subtree as a group (web
8//! semantics) instead of folding into each node's own colors (which shows
9//! overlapping children through each other).
10//!
11//! Captures are **cached**: a clean layer (no content dirt this frame — see
12//! [`LayerContentDirt`], [`resolve_layer_repaints`]) skips its capture pass
13//! entirely and composites last frame's texture. The layer root's own
14//! translation and group alpha are *composite-time* parameters, so
15//! translate/opacity animation of a promoted subtree costs no re-capture —
16//! promotion is the `will-change` pattern, an optimization rather than a tax.
17//!
18//! Captures are also **clip-independent**: ancestor clipping (a scroll
19//! container or the viewport) never reaches the captured pixels — members
20//! are captured under *interior* clips only (the cascade restarted at the
21//! layer root, [`clip`]) and the ancestor clip clamps the **composite quad**
22//! at draw time instead (web semantics: `overflow` clips the filtered
23//! *result*). This is what makes the translation-invariance above actually
24//! hold under scroll: without it, a capture taken while clipped would be
25//! served stale after scrolling into view. An offscreen layer still captures
26//! when dirty (its quad just draws nothing) — the accepted cost is invisible
27//! re-captures for continuously-animated offscreen subtrees.
28//!
29//! Promotion is a *render-side* concern: the subtree stays in the main UI tree
30//! (layout, picking, refs, animations untouched); promoting inserts the
31//! [`PromotedLayer`] marker on the existing entity and demoting removes it.
32//! The render half lives in [`render`] and works entirely through public
33//! `bevy_ui_render` seams — stock extraction/queue/prepare run untouched; a
34//! post-queue system moves the subtree's already-queued phase items into a
35//! per-layer synthetic view rendered to the atlas. See `render` for details.
36//!
37//! Extensibility contract: each future promotion rule (transform3d,
38//! backdrop) is one evaluator producing one [`PromotionReasons`] flag plus
39//! composite parameters the render pass forwards without interpreting.
40//! Promotion is `!reasons.is_empty()`; demotion is the flags emptying.
41
42use bevy::platform::collections::{HashMap, HashSet};
43use bevy::prelude::*;
44use bevy::ui::{ComputedNode, UiGlobalTransform};
45
46use crate::protocol::{NodeId, animatable::AnimatableField, props::Props};
47
48pub mod clip;
49pub mod pick3d;
50pub mod render;
51pub mod transform3d;
52
53/// Why a node is promoted — one bit per rule, OR'd together. A node is
54/// promoted iff any bit is set; it demotes when the set empties.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct PromotionReasons(pub u32);
57
58impl PromotionReasons {
59    /// `opacity` present on a node with children (group alpha).
60    pub const OPACITY: u32 = 1 << 0;
61    /// Non-empty `filter` chain in the base style or any hover/press/focus
62    /// variant (presence union, like [`Self::OPACITY`] — interaction never
63    /// flips promotion). Like [`Self::FORCED`] it skips the child-count and
64    /// `groupAlpha` gates: the effect is subtree-wide by definition, and a
65    /// filtered leaf (e.g. a grayscale `<image>`) is valid.
66    pub const FILTER: u32 = 1 << 2;
67    /// `cache: "always"` — user-forced promotion for capture caching. Unlike
68    /// [`Self::OPACITY`] it has no visual effect of its own (no opacity → the
69    /// group alpha stays `1.0`), so it skips the child-count and `groupAlpha`
70    /// gates: a leaf with an expensive paint is a valid cache unit.
71    pub const FORCED: u32 = 1 << 4;
72    /// `transform3d` present in the base style or any hover/press/focus
73    /// variant (presence union, value-blind — an identity `{}` promotes, so
74    /// the layer exists before the matrix animates and interaction never
75    /// flips promotion). Like [`Self::FILTER`]/[`Self::FORCED`] it skips the
76    /// child-count and `groupAlpha` gates: the transform applies to the
77    /// captured result, so a leaf is a valid layer.
78    pub const TRANSFORM3D: u32 = 1 << 1;
79    /// Non-empty `backdropFilter` chain in the base style or any variant —
80    /// same presence-union, value-blind rules as [`Self::FILTER`], same
81    /// skipped gates (a leaf frosted region is valid). The chain filters the
82    /// frame *behind* the node; the layer exists so the composite pass has a
83    /// stacking position to draw the filtered backdrop quad at.
84    pub const BACKDROP: u32 = 1 << 3;
85    /// `morphFilter` present in the base style or any variant — same
86    /// presence-union, value-blind rules as [`Self::FILTER`], same skipped
87    /// gates. The layer must exist (and its capture must be cached) *before*
88    /// the first key change, so the old appearance is there to freeze — the
89    /// same eager-promotion reasoning as a hover-only filter.
90    pub const MORPH: u32 = 1 << 5;
91
92    pub fn is_empty(self) -> bool {
93        self.0 == 0
94    }
95}
96
97/// Marker on a promoted layer root. Insert = promote, remove = demote —
98/// nothing else about the entity changes.
99#[derive(Component, Debug, Clone, Copy)]
100pub struct PromotedLayer {
101    pub reasons: PromotionReasons,
102}
103
104/// The composite-time alpha of a promoted subtree (applied once to the whole
105/// captured group). Separate from [`PromotedLayer`] so per-frame writes from
106/// the animation/transition paths don't look like promotion-state changes.
107#[derive(Component, Debug, Clone, Copy, PartialEq)]
108pub struct LayerGroupAlpha(pub f32);
109
110/// The layer's capture rectangle in *physical* pixels, in the same
111/// screen space as `UiGlobalTransform` — the node's border box, inflated on
112/// every side by the node's **quantized filter outset** (blur reads/writes
113/// beyond the border box; see [`crate::filters::quantize_outset`]). Recomputed
114/// every frame after layout by [`sync_layer_geometry`]; consumed by
115/// extraction. v1 clips the capture to this box (web opacity does not clip —
116/// known divergence, diag-warned).
117///
118/// The anchor (`min`) is **fractional** and follows the node exactly, so a
119/// translation — even subpixel — shifts the capture window and the composite
120/// quad by the same amount and never changes the captured pixels (the layer
121/// cache holds; the quad is sampled bilinearly at its fractional position).
122/// Ancestor clipping cannot break this invariance: captures are
123/// clip-independent (see [`clip`]) and the ancestor clip clamps the quad at
124/// composite time instead. Only `size` (whole texels, ceil of the border box
125/// plus outset) keys texture allocation.
126#[derive(Component, Debug, Clone, Copy, PartialEq)]
127pub struct LayerCaptureRect {
128    /// Top-left of the capture window (border box minus the outset margin),
129    /// fractional physical px (may be negative for a partially-offscreen
130    /// layer — capture is layer-local).
131    pub min: Vec2,
132    /// Capture texture size in whole texels.
133    pub size: UVec2,
134    /// The applied quantized outset margin (physical px, per side) — the
135    /// max of the content and backdrop chains' outsets. Carried so the
136    /// render side can recover the un-inflated border box
137    /// (`min + outset .. min + size − outset`): the backdrop composite quad
138    /// must cover only the border box (frost never paints in the outset
139    /// ring), while its UVs map into the inflated snapshot.
140    pub outset: u32,
141}
142
143/// Which layer root each node under a promoted subtree belongs to
144/// (ancestor-or-self, nearest wins — so nested layers map their interior,
145/// including the inner root's own paint, to the *inner* root). Rebuilt every
146/// frame after layout; extracted to the render world to route stolen phase
147/// items. A layer root's *composite quad* is the one thing that routes by
148/// [`Self::enclosing`] instead (it draws inside the parent layer's capture,
149/// or the screen when there is none).
150#[derive(Resource, Debug, Default)]
151pub struct LayerMembership {
152    /// node entity → its nearest layer-root ancestor-or-self.
153    pub node_to_layer: HashMap<Entity, Entity>,
154    /// layer root → the nearest *strictly enclosing* layer root (`None` for
155    /// top-level layers, whose quads composite straight into the screen
156    /// phase). Also doubles as the per-layer nesting-depth source.
157    pub enclosing: HashMap<Entity, Option<Entity>>,
158}
159
160/// Per-layer observability row in [`LayersRegistry`]. Identity fields are
161/// written by [`evaluate_layer_promotions`]; geometry fields
162/// (`capture_rect`/`depth`) and the live `group_alpha` are refreshed by
163/// [`sync_layer_geometry`]; cache stats by [`resolve_layer_repaints`].
164#[derive(Debug, Clone, Copy)]
165pub struct LayerMeta {
166    pub node: NodeId,
167    pub entity: Entity,
168    pub reasons: PromotionReasons,
169    /// Live composite alpha (animations/transitions included).
170    pub group_alpha: f32,
171    /// Physical-pixel capture rect; `None` while the layer is inactive
172    /// (zero-sized, hidden, or not laid out yet). Includes the quantized
173    /// filter-outset margin — the *actual* capture (bleed included), not just
174    /// the border box. **Signed** on purpose: outset inflation routinely
175    /// pushes the min past the viewport origin (a filtered layer near the
176    /// window edge), and the observability rect must report that truthfully
177    /// instead of clamping to 0 and overstating the far edge.
178    pub capture_rect: Option<IRect>,
179    /// Nesting depth: 1 = top-level layer, 2 = layer inside a layer, …
180    pub depth: u32,
181    /// How many frames re-captured this layer since promotion (cache misses).
182    pub repaints: u64,
183    /// Whether the last resolved frame served the cached capture (no repaint).
184    pub cached: bool,
185    /// The node's `cache` style keyword. `Never` makes
186    /// [`resolve_layer_repaints`] dirty this layer every frame (its pixels
187    /// are written outside the dirt tracking's sight — live portals,
188    /// app-owned render targets).
189    pub cache_policy: crate::protocol::style::LayerCache,
190}
191
192/// Public registry of currently promoted layers — the observability surface
193/// auto-promotion comes with (promotion cost is invisible in JSX; this is
194/// where to see what promoted and why). Tests assert on it; a future devtools
195/// "layers" tab is a pure consumer.
196#[derive(Resource, Debug, Default)]
197pub struct LayersRegistry {
198    pub layers: HashMap<NodeId, LayerMeta>,
199}
200
201/// Frame-scoped content-dirt inbox for the layer capture cache. Every site
202/// that mutates a node's *rendered appearance* pushes the entity here (see
203/// [`mark_content_dirty`]); [`resolve_layer_repaints`] drains it in
204/// `PostUpdate`, once [`LayerMembership`] is this frame's — the taps
205/// themselves can't resolve node → layer, membership isn't valid yet.
206#[derive(Resource, Debug, Default)]
207pub struct LayerContentDirt {
208    /// Entities whose painted content changed → their owning layer (and every
209    /// enclosing layer) must re-capture.
210    pub nodes: Vec<Entity>,
211    /// Promoted roots whose *composite-only* params changed — their own
212    /// translate or group alpha. Those are applied at composite time (the
213    /// quad moves / the alpha multiplies), so they dirty only the **enclosing**
214    /// layer chain (the quad is content of the outer capture), never the
215    /// root's own layer.
216    pub composite_only: Vec<Entity>,
217}
218
219/// Per-layer repaint decisions, rebuilt from scratch every frame by
220/// [`resolve_layer_repaints`] — nothing persists to be cleared, so render
221/// extraction (which runs at the sync point after the whole main frame)
222/// always reads the same frame's state.
223#[derive(Resource, Debug, Default)]
224pub struct LayerRepaintState {
225    /// Layer roots whose capture must re-render this frame.
226    pub dirty: HashSet<Entity>,
227    /// This frame's per-root subtree geometry hash, staged by
228    /// [`sync_layer_geometry`] and compared/swapped by the resolver.
229    pub geo_hashes: HashMap<Entity, u64>,
230    prev_hashes: HashMap<Entity, u64>,
231}
232
233/// Tap helper for `EntityCommands` call sites (style apply, op arms): queue a
234/// push of this entity into [`LayerContentDirt`]. Queued (not immediate)
235/// because the op-apply sites only hold `EntityCommands`; the push lands at
236/// the next command flush, well before the `PostUpdate` resolver. A missing
237/// resource (external app without the plugin) degrades to a no-op.
238pub fn mark_content_dirty(ec: &mut EntityCommands) {
239    ec.queue(|mut e: bevy::ecs::world::EntityWorldMut| {
240        let id = e.id();
241        e.world_scope(|w| {
242            if let Some(mut dirt) = w.get_resource_mut::<LayerContentDirt>() {
243                dirt.nodes.push(id);
244            }
245        });
246    });
247}
248
249/// The promotion rule set — pure, one flag per rule (see the module doc's
250/// extensibility contract). [`PromotionReasons::FORCED`] is `cache: "always"`
251/// in the base style, gated only on element eligibility (no visual semantics
252/// of its own — no child or `groupAlpha` gate). [`PromotionReasons::FILTER`]
253/// is a non-empty `filter` chain in the base style OR any hover/press/focus
254/// variant, gated the same way (the effect is subtree-wide by definition, and
255/// a filtered leaf is valid). Like opacity the union is presence-based:
256/// interaction must never flip promotion, so a hover-only filter promotes
257/// eagerly — the layer (and its capture) exists before the first hover.
258/// [`PromotionReasons::OPACITY`]:
259///
260/// - `opacity` **present** — value-blind (an explicit `opacity: 1` stays
261///   promoted, so fades crossing 1.0 never thrash), unioned across the base
262///   style, hover/press/focus variants (interaction must never flip
263///   promotion), and animated bindings;
264/// - at least one child (a leaf's group alpha is visually identical to the
265///   per-node fold — promoting it would be pure cost);
266/// - `groupAlpha != false` (the opt-out, read from the base style only —
267///   the field is `no_overlay`);
268/// - the element kind is eligible (`ineligible_element = false`). v1
269///   ineligible: `<text>` (its fold already cascades to spans via
270///   resolved-style inheritance — group semantics without a layer) and
271///   detached roots (`<surface>`/`<root>` — separate render paths).
272pub fn promotion_reasons(
273    props: &Props,
274    child_count: usize,
275    ineligible_element: bool,
276) -> PromotionReasons {
277    // Presence-based, value-blind — and an `{ animated }` opacity is presence
278    // too (the field is `Some(Animatable::Animated)`), so an animated-only
279    // opacity promotes exactly like a static one.
280    let opacity_present = props.all_styles().any(|s| s.opacity.is_some());
281    let group_gate = props.style.as_ref().and_then(|s| s.group_alpha) != Some(false);
282
283    let mut reasons = 0;
284    if opacity_present && group_gate && child_count >= 1 && !ineligible_element {
285        reasons |= PromotionReasons::OPACITY;
286    }
287    // `cache: "always"`/`"never"` — forced promotion (for capture caching /
288    // for an always-recaptured live layer). Base style only (`no_overlay`),
289    // and deliberately NOT gated on children or `groupAlpha`: it has no
290    // visual semantics of its own, so the only gates are the element-kind
291    // ones.
292    let forced = matches!(
293        props.style.as_ref().and_then(|s| s.cache),
294        Some(
295            crate::protocol::style::LayerCache::Always | crate::protocol::style::LayerCache::Never
296        )
297    );
298    if forced && !ineligible_element {
299        reasons |= PromotionReasons::FORCED;
300    }
301    // A non-empty `filter` chain (a `Some` empty chain is a no-op), unioned
302    // across the base style and the hover/press/focus variants like OPACITY
303    // (interaction must never flip promotion — a hover filter promotes
304    // eagerly, so the capture exists before the first hover). Animated
305    // bindings deliberately do NOT join the union: a `FilterParam` binding
306    // without a `filter` style has no chain to drive (the binding's bind-time
307    // validation warns `filterBinding` and stays inert instead).
308    // No child or `groupAlpha` gate: the effect is subtree-wide by
309    // definition, so even a leaf is a valid layer (same reasoning as FORCED).
310    let filtered = props
311        .all_styles()
312        .any(|s| s.filter.as_ref().is_some_and(|chain| !chain.0.is_empty()));
313    if filtered && !ineligible_element {
314        reasons |= PromotionReasons::FILTER;
315    }
316    // `transform3d` presence — value-blind like FILTER (identity promotes, so
317    // animating from identity never flips promotion), no child/`groupAlpha`
318    // gate (the transform reshapes the captured result; a leaf is valid).
319    let transformed3d = props.all_styles().any(|s| s.transform3d.is_some());
320    if transformed3d && !ineligible_element {
321        reasons |= PromotionReasons::TRANSFORM3D;
322    }
323    // A non-empty `backdropFilter` chain — same rules as FILTER (presence
324    // union across variants, value-blind, no child/`groupAlpha` gate: a leaf
325    // "frosted glass" region is valid; animated `backdropFilter[i].param`
326    // bindings do not join the union).
327    let backdrop = props
328        .all_styles()
329        .any(|s| s.backdrop_filter.as_ref().is_some_and(|c| !c.0.is_empty()));
330    if backdrop && !ineligible_element {
331        reasons |= PromotionReasons::BACKDROP;
332    }
333    // `morphFilter` presence — same presence-union, value-blind rules as
334    // FILTER (the decode already degraded malformed values to `None`). The
335    // layer promotes eagerly so a cached capture exists to freeze on the
336    // first key change.
337    let morph = props.all_styles().any(|s| s.morph_filter.is_some());
338    if morph && !ineligible_element {
339        reasons |= PromotionReasons::MORPH;
340    }
341    PromotionReasons(reasons)
342}
343
344/// The single writer of promotion state. Drains the bridge's dirty set (fed
345/// by the op-apply hooks), re-evaluates [`promotion_reasons`] per node, and
346/// flips the [`PromotedLayer`]/[`LayerGroupAlpha`] markers + the registry +
347/// `bridge.promoted_layers`. Ordered after `apply_js_ops` and before the
348/// interaction/transition/animation appliers so every later alpha writer this
349/// frame sees the final promotion state.
350pub fn evaluate_layer_promotions(
351    mut commands: Commands,
352    mut bridge: ResMut<crate::bridge::JsBridge>,
353    mut registry: ResMut<LayersRegistry>,
354    assets: Res<AssetServer>,
355    mut ui_assets: crate::reconcile::UiAssets,
356    mut style_variants: Query<&mut crate::bridge::StyleVariants>,
357) {
358    // Sweep rows whose node vanished (removal forgets the node's bridge data
359    // — including `promoted_layers` — but can't reach this resource).
360    registry
361        .layers
362        .retain(|id, meta| bridge.nodes.get(id) == Some(&meta.entity));
363
364    if bridge.layer_dirty.is_empty() {
365        return;
366    }
367    let dirty: Vec<NodeId> = bridge.layer_dirty.drain().collect();
368    for id in dirty {
369        let Some(&entity) = bridge.nodes.get(&id) else {
370            continue; // Removed in the same batch; sweep handled the row.
371        };
372        let reasons = match bridge.props_cache.get(&id) {
373            Some(props) => promotion_reasons(
374                props,
375                bridge.children_of(id).count(),
376                // Text elements (fold cascades to spans already) and detached
377                // roots (`<surface>`/`<root>` — own render paths) are
378                // ineligible in v1.
379                bridge.text_styles.contains_key(&id) || bridge.is_detached_root(id),
380            ),
381            None => PromotionReasons::default(),
382        };
383        let was_promoted = bridge.promoted_layers.contains(&id);
384        if !reasons.is_empty() {
385            // The static group alpha; the style/animation/transition appliers
386            // own per-frame updates from here on.
387            let alpha = bridge
388                .props_cache
389                .get(&id)
390                .and_then(|p| p.style.as_ref())
391                .and_then(|s| s.opacity.static_val())
392                .unwrap_or(1.0);
393            let cache_policy = bridge
394                .props_cache
395                .get(&id)
396                .and_then(|p| p.style.as_ref())
397                .and_then(|s| s.cache)
398                .unwrap_or_default();
399            commands
400                .entity(entity)
401                .insert((PromotedLayer { reasons }, LayerGroupAlpha(alpha)));
402            bridge.promoted_layers.insert(id);
403            // Upsert: keep geometry fields across re-evaluations of an
404            // already-promoted node.
405            let row = registry.layers.entry(id).or_insert(LayerMeta {
406                node: id,
407                entity,
408                reasons,
409                group_alpha: alpha,
410                capture_rect: None,
411                depth: 1,
412                repaints: 0,
413                cached: false,
414                cache_policy,
415            });
416            row.entity = entity;
417            row.reasons = reasons;
418            row.group_alpha = alpha;
419            row.cache_policy = cache_policy;
420            if !was_promoted && let Some(props) = bridge.props_cache.get(&id) {
421                // Promote flip: colors were folded while unpromoted — bake
422                // the unfolded values + group alpha now, in one shot.
423                crate::reconcile::reapply_opacity_outputs(
424                    &mut commands,
425                    entity,
426                    props,
427                    true,
428                    bridge.foreign_images.contains(&id),
429                    &assets,
430                    &mut ui_assets,
431                    &mut style_variants,
432                );
433            }
434        } else if was_promoted {
435            // The resolved chains are promotion-scoped state; `FilterInput`/
436            // `BackdropInput` are NOT removed here (they mirror the style,
437            // not the promotion).
438            commands.entity(entity).remove::<(
439                PromotedLayer,
440                LayerGroupAlpha,
441                LayerCaptureRect,
442                crate::filters::ResolvedFilterChain,
443                crate::filters::ResolvedBackdropChain,
444                crate::filters::ResolvedMorphChain,
445                crate::filters::MorphState,
446                transform3d::LayerTransform3d,
447                transform3d::LayerTransform3dMatrix,
448            )>();
449            bridge.promoted_layers.remove(&id);
450            registry.layers.remove(&id);
451            if let Some(props) = bridge.props_cache.get(&id) {
452                // Demote flip: resume the per-node fold with baked values.
453                crate::reconcile::reapply_opacity_outputs(
454                    &mut commands,
455                    entity,
456                    props,
457                    false,
458                    bridge.foreign_images.contains(&id),
459                    &assets,
460                    &mut ui_assets,
461                    &mut style_variants,
462                );
463            }
464        }
465    }
466}
467
468/// Recomputes each promoted layer's capture rect (inflated by the node's
469/// quantized filter outset — blur reads/writes beyond the border box, and the
470/// composite quad, texture allocation, and synthetic-view ortho all derive
471/// from this rect), the subtree membership map, and each layer's
472/// content-geometry hash (see [`fold_member_geometry`]). Also warns
473/// (`filterBleed`) when a nested filtered layer's inflated rect escapes its
474/// enclosing layer's capture — v1 clips there, losing part of the bleed.
475/// Runs in `PostUpdate` after `bevy_ui` layout so `ComputedNode` /
476/// `UiGlobalTransform` are this frame's values.
477#[allow(clippy::too_many_arguments, clippy::type_complexity)]
478pub fn sync_layer_geometry(
479    mut commands: Commands,
480    roots: Query<
481        (
482            Entity,
483            &ComputedNode,
484            &UiGlobalTransform,
485            &crate::bridge::RNode,
486            &LayerGroupAlpha,
487            Option<&crate::filters::ResolvedFilterChain>,
488            Option<&crate::filters::FilterInput>,
489            Option<&crate::filters::ResolvedBackdropChain>,
490        ),
491        With<PromotedLayer>,
492    >,
493    root_markers: Query<(), With<PromotedLayer>>,
494    children: Query<&Children>,
495    parents: Query<&ChildOf>,
496    existing_rects: Query<&LayerCaptureRect>,
497    geometry: Query<(&ComputedNode, &UiGlobalTransform)>,
498    mut membership: ResMut<LayerMembership>,
499    mut registry: ResMut<LayersRegistry>,
500    mut repaints: ResMut<LayerRepaintState>,
501    // Bleed-warn dedup: inner root → the (inner, outer) rect pair last warned
502    // about. `diag::report` mirrors every call into the console ring, so a
503    // per-frame re-report would spam it (the devtools `warning` event path
504    // dedups on its own; the console does not).
505    mut warned_bleeds: Local<HashMap<Entity, (LayerCaptureRect, LayerCaptureRect)>>,
506) {
507    membership.node_to_layer.clear();
508    membership.enclosing.clear();
509    // Post-swap leftovers from last frame's resolver; this frame's hashes are
510    // staged fresh below.
511    repaints.geo_hashes.clear();
512    // This frame's rects by root, for the bleed pass below (`enclosing` roots
513    // may be visited in any order, so containment is checked in a second pass
514    // once every rect is known).
515    let mut frame_rects: HashMap<Entity, LayerCaptureRect> = HashMap::default();
516    // Filtered roots with a non-zero outset: (root, node id, quantized outset,
517    // first wire filter name — the warning `value` the devtools inspector
518    // matches against the retained `filter` style row).
519    let mut bleed_candidates: Vec<(Entity, NodeId, u32, String)> = Vec::new();
520    for (root, computed, transform, rnode, alpha, chain, filter_input, backdrop_chain) in &roots {
521        let row = registry.layers.get_mut(&rnode.0);
522        if let Some(row) = &row {
523            debug_assert_eq!(row.entity, root);
524        }
525        let size = computed.size();
526        if size.x <= 0.5 || size.y <= 0.5 {
527            // Zero-sized / not laid out yet: inactive this frame. The gate
528            // reads the CONTENT size (pre-inflation) on purpose — a filter
529            // outset alone must not activate an empty node.
530            if let Some(row) = row {
531                row.capture_rect = None;
532            }
533            continue;
534        }
535        // Fractional anchor + whole-texel size (see `LayerCaptureRect`): the
536        // anchor tracks the node exactly so translation never re-captures;
537        // only a size change reallocs.
538        let min = transform.translation - size * 0.5;
539        let mut rect = LayerCaptureRect {
540            min,
541            size: UVec2::new(size.x.ceil() as u32, size.y.ceil() as u32),
542            outset: 0,
543        };
544        if rect.size.x == 0 || rect.size.y == 0 {
545            if let Some(row) = row {
546                row.capture_rect = None;
547            }
548            continue;
549        }
550        // A filter chain reads/writes beyond the border box (blur bleed):
551        // grow the capture window by the chain's outset on every side.
552        // Quantized to 16px steps because an animated blur radius changes
553        // `outset_px` every frame — coarse steps keep the capture size, and
554        // with it the geometry hash (size is folded below) and the texture
555        // allocation, stable within a step; crossing a step re-captures
556        // automatically. The backdrop chain contributes too (its blur needs
557        // source pixels beyond the border box in the snapshot): one shared
558        // window, inflated by the max of both chains' outsets.
559        let content_outset = chain.map_or(0, |c| crate::filters::quantize_outset(c.outset_px));
560        let backdrop_outset =
561            backdrop_chain.map_or(0, |c| crate::filters::quantize_outset(c.0.outset_px));
562        let outset = content_outset.max(backdrop_outset);
563        if outset > 0 {
564            rect.min -= Vec2::splat(outset as f32);
565            rect.size += UVec2::splat(2 * outset);
566            rect.outset = outset;
567        }
568        // Bleed candidacy stays keyed to the CONTENT chain only: a backdrop
569        // never bleeds into an enclosing capture (its snapshot is sampled
570        // from the frame, edge-clamped; the quad is clamped to the border
571        // box), so a backdrop-only outset must not warn.
572        if content_outset > 0 {
573            let value = filter_input
574                .and_then(|i| i.0.0.first())
575                .map_or_else(|| "filter".to_owned(), |u| u.name.clone());
576            bleed_candidates.push((root, rnode.0, content_outset, value));
577        }
578        frame_rects.insert(root, rect);
579        if existing_rects.get(root) != Ok(&rect) {
580            commands.entity(root).insert(rect);
581        }
582        // Mirror live geometry + alpha into the observability registry (the
583        // registry keeps integer px for display — round the anchor; the rect
584        // includes the filter-outset margin, i.e. the real capture, and the
585        // min is signed so a partially-offscreen or outset-inflated layer
586        // displays truthfully). Depth (1 = top-level) is refreshed below once
587        // `enclosing` is known.
588        if let Some(row) = row {
589            let display_min = IVec2::new(rect.min.x.round() as i32, rect.min.y.round() as i32);
590            row.capture_rect = Some(IRect::from_corners(
591                display_min,
592                display_min + rect.size.as_ivec2(),
593            ));
594            row.group_alpha = alpha.0;
595        }
596        // Everything under `root` (itself included) belongs to its nearest
597        // enclosing-or-self layer. Starting each DFS at a root and letting
598        // inner roots re-claim their own subtree makes "nearest wins" hold
599        // regardless of iteration order (an outer root's DFS re-visits inner
600        // subtrees with the inner root as the current layer). The same walk
601        // folds this layer's content-geometry hash: every directly-owned
602        // member (and each *directly nested* layer root, whose composite quad
603        // is this layer's content) contributes its root-relative geometry.
604        let mut hash = GEO_HASH_SEED;
605        // The capture size is content too: a resize re-captures even when the
606        // interior is otherwise static (texture realloc + web width/height
607        // semantics).
608        fold_geo_i32(&mut hash, rect.size.x as i32);
609        fold_geo_i32(&mut hash, rect.size.y as i32);
610        mark_subtree(
611            root,
612            root,
613            root,
614            transform.translation,
615            &children,
616            &root_markers,
617            &geometry,
618            &mut hash,
619            &mut membership.node_to_layer,
620        );
621        repaints.geo_hashes.insert(root, hash);
622        // The quad target: nearest strictly-enclosing promoted ancestor. Depth
623        // = number of promoted ancestors + 1.
624        let mut enclosing = None;
625        let mut depth = 1u32;
626        let mut cursor = root;
627        while let Ok(parent) = parents.get(cursor) {
628            cursor = parent.parent();
629            if root_markers.contains(cursor) {
630                if enclosing.is_none() {
631                    enclosing = Some(cursor);
632                }
633                depth += 1;
634            }
635        }
636        membership.enclosing.insert(root, enclosing);
637        if let Some(row) = registry.layers.get_mut(&rnode.0) {
638            row.depth = depth;
639        }
640    }
641    // Bleed check: v1 composites a nested layer's quad inside the ENCLOSING
642    // layer's capture, which clips at its rect — a filtered layer whose
643    // inflated rect escapes it loses part of its bleed (web filters don't
644    // clip; known divergence, hence the warn). Top-level layers composite to
645    // the screen and are exempt. (Distinct from ancestor *overflow* clipping,
646    // which is composite-time — see [`clip`] — and clips bleed correctly:
647    // this is the capture TEXTURE's own bounds, the ortho window.) Deduped per root on the (inner, outer) rect
648    // pair so a steady bleed reports once and any geometry change re-reports.
649    // Bound the dedup map to this frame's candidates — not all active roots:
650    // a root that stays promoted (e.g. via opacity) while its filter is
651    // unset must drop its entry, or re-adding the identical filter with
652    // unchanged geometry would be wrongly suppressed.
653    let candidate_roots: HashSet<Entity> = bleed_candidates.iter().map(|(r, ..)| *r).collect();
654    warned_bleeds.retain(|e, _| candidate_roots.contains(e));
655    for (root, node, outset, value) in bleed_candidates {
656        let outer = match membership.enclosing.get(&root) {
657            Some(&Some(outer)) => outer,
658            _ => {
659                warned_bleeds.remove(&root);
660                continue;
661            }
662        };
663        let (Some(&inner_rect), Some(&outer_rect)) =
664            (frame_rects.get(&root), frame_rects.get(&outer))
665        else {
666            warned_bleeds.remove(&root);
667            continue;
668        };
669        let inner_max = inner_rect.min + inner_rect.size.as_vec2();
670        let outer_max = outer_rect.min + outer_rect.size.as_vec2();
671        let mut sides: Vec<&str> = Vec::new();
672        if inner_rect.min.x < outer_rect.min.x {
673            sides.push("left");
674        }
675        if inner_rect.min.y < outer_rect.min.y {
676            sides.push("top");
677        }
678        if inner_max.x > outer_max.x {
679            sides.push("right");
680        }
681        if inner_max.y > outer_max.y {
682            sides.push("bottom");
683        }
684        if sides.is_empty() {
685            warned_bleeds.remove(&root);
686            continue;
687        }
688        let pair = (inner_rect, outer_rect);
689        if warned_bleeds.get(&root) == Some(&pair) {
690            continue;
691        }
692        warned_bleeds.insert(root, pair);
693        let _scope = crate::diag::node_scope(node);
694        crate::diag::report(
695            "filterBleed",
696            &value,
697            &format!(
698                "filter outset ({outset}px) bleeds past the enclosing promoted layer's capture on the {} side and is clipped there — leave ≥{outset}px between this node and that ancestor's edge, or avoid nesting it under a promoted layer",
699                sides.join("/")
700            ),
701        );
702    }
703}
704
705#[allow(clippy::too_many_arguments)]
706fn mark_subtree(
707    node: Entity,
708    layer: Entity,
709    dfs_root: Entity,
710    root_translation: Vec2,
711    children: &Query<&Children>,
712    roots: &Query<(), With<PromotedLayer>>,
713    geometry: &Query<(&ComputedNode, &UiGlobalTransform)>,
714    hash: &mut u64,
715    map: &mut HashMap<Entity, Entity>,
716) {
717    // Geometry-hash contribution: a node is `dfs_root`'s *content* while the
718    // incoming claim context is still `dfs_root` itself — that covers its
719    // directly-owned members plus each directly nested layer root (whose
720    // composite quad draws inside this capture; the nested root's *interior*
721    // belongs to the inner hash, and inner dirt propagates outward anyway).
722    if layer == dfs_root
723        && let Ok((computed, transform)) = geometry.get(node)
724    {
725        fold_member_geometry(hash, root_translation, transform, computed);
726    }
727    // An inner promoted root claims itself and its subtree: its own paint
728    // fades with the *inner* group. (Its composite quad routes by
729    // `LayerMembership::enclosing`, not this map.)
730    let layer = if roots.contains(node) { node } else { layer };
731    map.insert(node, layer);
732    if let Ok(kids) = children.get(node) {
733        for &kid in kids {
734            mark_subtree(
735                kid,
736                layer,
737                dfs_root,
738                root_translation,
739                children,
740                roots,
741                geometry,
742                hash,
743                map,
744            );
745        }
746    }
747}
748
749/// FNV-1a offset basis — the seed of every per-layer geometry hash.
750const GEO_HASH_SEED: u64 = 0xcbf29ce484222325;
751
752fn fold_geo_i32(hash: &mut u64, v: i32) {
753    for b in v.to_le_bytes() {
754        *hash = (*hash ^ b as u64).wrapping_mul(0x100000001b3);
755    }
756}
757
758/// Fold one member's **root-relative** geometry into a layer's content hash:
759/// translation relative to the layer root (so moving the whole layer cancels
760/// exactly and never re-captures), the affine's linear part (member scale /
761/// rotation), and the laid-out size. Quantized (1/64 px positions, 1/1024
762/// matrix entries) so float ulp noise — `(a+d)-(b+d)` isn't bit-exact — can't
763/// flap the hash. Visit order is encoded implicitly by the fold sequence, so
764/// reorders change the hash too.
765pub fn fold_member_geometry(
766    hash: &mut u64,
767    root_translation: Vec2,
768    transform: &UiGlobalTransform,
769    computed: &ComputedNode,
770) {
771    let rel = transform.translation - root_translation;
772    fold_geo_i32(hash, (rel.x * 64.0).round() as i32);
773    fold_geo_i32(hash, (rel.y * 64.0).round() as i32);
774    let m = transform.matrix2;
775    fold_geo_i32(hash, (m.x_axis.x * 1024.0).round() as i32);
776    fold_geo_i32(hash, (m.x_axis.y * 1024.0).round() as i32);
777    fold_geo_i32(hash, (m.y_axis.x * 1024.0).round() as i32);
778    fold_geo_i32(hash, (m.y_axis.y * 1024.0).round() as i32);
779    let size = computed.size();
780    fold_geo_i32(hash, (size.x * 64.0).round() as i32);
781    fold_geo_i32(hash, (size.y * 64.0).round() as i32);
782}
783
784/// `<image>` textures arrive asynchronously — no op, no bevy-react write site
785/// — so watch the asset events and dirty the owning layer of any node using a
786/// touched image. (One frame late for loads, which are async anyway; canvas
787/// uploads are also `Modified` here, double-covering their direct tap.)
788pub fn watch_layer_image_assets(
789    mut events: MessageReader<AssetEvent<Image>>,
790    images: Query<(Entity, &bevy::ui::widget::ImageNode)>,
791    registry: Res<LayersRegistry>,
792    mut dirt: ResMut<LayerContentDirt>,
793) {
794    if registry.layers.is_empty() {
795        events.clear();
796        return;
797    }
798    let mut touched: Vec<AssetId<Image>> = Vec::new();
799    for event in events.read() {
800        match event {
801            AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } => {
802                touched.push(*id);
803            }
804            _ => {}
805        }
806    }
807    if touched.is_empty() {
808        return;
809    }
810    for (entity, image) in &images {
811        if touched.contains(&image.image.id()) {
812            dirt.nodes.push(entity);
813        }
814    }
815}
816
817/// Turn this frame's dirt into per-layer repaint decisions. Runs in
818/// `PostUpdate` after [`sync_layer_geometry`] (membership + geometry hashes
819/// are this frame's) and after `bevy_ui`'s text systems (`Changed<TextLayoutInfo>`
820/// must see this frame's reshapes). Render extraction reads the result at the
821/// sync point; the state is rebuilt from scratch next frame, so nothing needs
822/// clearing across frames.
823pub fn resolve_layer_repaints(
824    mut dirt: ResMut<LayerContentDirt>,
825    mut state: ResMut<LayerRepaintState>,
826    membership: Res<LayerMembership>,
827    mut registry: ResMut<LayersRegistry>,
828    bridge: Option<Res<crate::bridge::JsBridge>>,
829    reshaped: Query<Entity, Changed<bevy::text::TextLayoutInfo>>,
830    focus: Query<&crate::bridge::FocusState>,
831) {
832    let state = &mut *state;
833    state.dirty.clear();
834
835    // 1. Content dirt → owning layer.
836    for e in dirt.nodes.drain(..) {
837        if let Some(&layer) = membership.node_to_layer.get(&e) {
838            state.dirty.insert(layer);
839        }
840    }
841    // 2. Composite-only dirt (a promoted root's own translate / group alpha)
842    //    → the ENCLOSING layer only: the root's quad is content of the outer
843    //    capture, while its own capture is unaffected.
844    for e in dirt.composite_only.drain(..) {
845        let layer = membership.node_to_layer.get(&e).copied().unwrap_or(e);
846        if let Some(&Some(outer)) = membership.enclosing.get(&layer) {
847            state.dirty.insert(outer);
848        }
849    }
850    // 3. Text reshape (font load, re-wrap, edits): Bevy's own text systems
851    //    write `TextLayoutInfo` — there is no bevy-react write site to tap.
852    for e in &reshaped {
853        if let Some(&layer) = membership.node_to_layer.get(&e) {
854            state.dirty.insert(layer);
855        }
856    }
857    // 4. A focused editable inside a layer repaints every frame: the caret
858    //    blink is rendered by Bevy's text systems with no signal we can see.
859    if let Some(bridge) = bridge {
860        for id in &bridge.editable_inputs {
861            if let Some(&e) = bridge.nodes.get(id)
862                && focus.get(e).is_ok_and(|f| f.0)
863                && let Some(&layer) = membership.node_to_layer.get(&e)
864            {
865                state.dirty.insert(layer);
866            }
867        }
868    }
869    // 5. Geometry: any change in a layer's root-relative content geometry —
870    //    including the first frame after promotion (no previous hash).
871    for (&root, hash) in &state.geo_hashes {
872        if state.prev_hashes.get(&root) != Some(hash) {
873            state.dirty.insert(root);
874        }
875    }
876    std::mem::swap(&mut state.prev_hashes, &mut state.geo_hashes);
877    // 6. `cache: "never"` — the layer's pixels are written outside the dirt
878    //    tracking's sight (live portal targets, app-owned textures), so it
879    //    re-captures unconditionally. Seeded before the outward propagation:
880    //    live pixels defeat ancestor caching (same rationale as backdrop).
881    for meta in registry.layers.values() {
882        if meta.cache_policy == crate::protocol::style::LayerCache::Never {
883            state.dirty.insert(meta.entity);
884        }
885    }
886    // 7. Propagate outward: a dirty inner layer's composite quad re-draws
887    //    inside its enclosing captures, so every outer layer re-captures too.
888    let seeds: Vec<Entity> = state.dirty.iter().copied().collect();
889    for mut layer in seeds {
890        while let Some(&Some(outer)) = membership.enclosing.get(&layer) {
891            if !state.dirty.insert(outer) {
892                break; // already dirty ⇒ its own chain is already walked
893            }
894            layer = outer;
895        }
896    }
897    // 8. Observability: per-layer cache stats for devtools.
898    for meta in registry.layers.values_mut() {
899        let dirty = state.dirty.contains(&meta.entity);
900        meta.cached = !dirty;
901        if dirty {
902            meta.repaints += 1;
903        }
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::bridge::JsBridge;
911    use crate::protocol::{NodeId, op::Op, outbound::Outbound, props::Props};
912    use bevy::ui::BackgroundColor;
913
914    fn props(json: serde_json::Value) -> Props {
915        serde_json::from_value(json).expect("valid props")
916    }
917
918    /// [`promotion_reasons`] truth table: presence-based (value-blind) opacity
919    /// union across base/variants/animated bindings, gated by children,
920    /// `groupAlpha`, and element eligibility.
921    #[test]
922    fn promotion_reasons_matrix() {
923        let promoted = |p: &Props, kids: usize, ineligible: bool| {
924            !promotion_reasons(p, kids, ineligible).is_empty()
925        };
926        let base = props(serde_json::json!({ "style": { "opacity": 0.5 } }));
927        assert!(promoted(&base, 1, false));
928        // Value-blind: an explicit `opacity: 1` still promotes (no thrash
929        // when a fade settles at 1.0).
930        let one = props(serde_json::json!({ "style": { "opacity": 1.0 } }));
931        assert!(promoted(&one, 1, false));
932        // No children → leaf fold is visually identical, never promote.
933        assert!(!promoted(&base, 0, false));
934        // No opacity anywhere → no reason.
935        let plain = props(serde_json::json!({ "style": { "width": 10 } }));
936        assert!(!promoted(&plain, 3, false));
937        // The opt-out gate.
938        let opted_out =
939            props(serde_json::json!({ "style": { "opacity": 0.5, "groupAlpha": false } }));
940        assert!(!promoted(&opted_out, 1, false));
941        // Variant-carried opacity counts (hover must not flip promotion).
942        let hover_only = props(serde_json::json!({
943            "style": { "width": 10 },
944            "hoverStyle": { "opacity": 0.8 },
945        }));
946        assert!(promoted(&hover_only, 1, false));
947        // An `{ animated }` opacity is field presence like a static one.
948        let animated = props(serde_json::json!({
949            "style": { "opacity": { "animated": { "id": 1 } } },
950        }));
951        assert!(promoted(&animated, 1, false));
952        // Ineligible element kinds (text / detached roots) never promote.
953        assert!(!promoted(&base, 1, true));
954
955        // `cache: "always"` forces promotion — no opacity, no children, and
956        // even `groupAlpha: false` (which only gates the OPACITY rule) needed.
957        let forced = props(serde_json::json!({ "style": { "cache": "always" } }));
958        assert_eq!(
959            promotion_reasons(&forced, 0, false).0,
960            PromotionReasons::FORCED
961        );
962        let forced_opted_out = props(serde_json::json!({
963            "style": { "cache": "always", "opacity": 0.5, "groupAlpha": false }
964        }));
965        assert_eq!(
966            promotion_reasons(&forced_opted_out, 1, false).0,
967            PromotionReasons::FORCED
968        );
969        // Forced + opacity on an eligible node sets both bits.
970        let both = props(serde_json::json!({
971            "style": { "cache": "always", "opacity": 0.5 }
972        }));
973        assert_eq!(
974            promotion_reasons(&both, 1, false).0,
975            PromotionReasons::FORCED | PromotionReasons::OPACITY
976        );
977        // `cache: "auto"` is the default: no forced bit.
978        let auto = props(serde_json::json!({ "style": { "cache": "auto" } }));
979        assert!(!promoted(&auto, 1, false));
980        // Element eligibility still applies to forced promotion.
981        assert!(!promoted(&forced, 1, true));
982
983        // A non-empty `filter` chain promotes — even a leaf (the effect is
984        // subtree-wide by definition; no child or `groupAlpha` gate, same
985        // reasoning as FORCED).
986        let filtered = props(serde_json::json!({ "style": { "filter": { "name": "blur" } } }));
987        assert_eq!(
988            promotion_reasons(&filtered, 0, false).0,
989            PromotionReasons::FILTER
990        );
991        // A present-but-empty chain (`filter: []`) is a no-op — no promotion.
992        let empty_chain = props(serde_json::json!({ "style": { "filter": [] } }));
993        assert!(!promoted(&empty_chain, 1, false));
994        // Filter + opacity on an eligible node sets both bits.
995        let filter_and_opacity = props(serde_json::json!({
996            "style": { "filter": { "name": "blur" }, "opacity": 0.5 }
997        }));
998        assert_eq!(
999            promotion_reasons(&filter_and_opacity, 1, false).0,
1000            PromotionReasons::FILTER | PromotionReasons::OPACITY
1001        );
1002        // Element eligibility still applies to filter promotion.
1003        assert!(!promoted(&filtered, 0, true));
1004        // Variant-carried filters count — presence union, like opacity
1005        // (interaction must never flip promotion): a hover/press/focus-only
1006        // filter promotes EAGERLY, even while not hovered, so the capture
1007        // exists before the first hover.
1008        for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1009            let variant_filter = props(serde_json::json!({
1010                "style": { "width": 10 },
1011                (variant): { "filter": { "name": "blur" } },
1012            }));
1013            assert_eq!(
1014                promotion_reasons(&variant_filter, 0, false).0,
1015                PromotionReasons::FILTER,
1016                "{variant}-only filter promotes eagerly"
1017            );
1018        }
1019        // A variant carrying only an EMPTY chain is still a no-op.
1020        let empty_variant = props(serde_json::json!({
1021            "style": { "width": 10 },
1022            "hoverStyle": { "filter": [] },
1023        }));
1024        assert!(!promoted(&empty_variant, 1, false));
1025
1026        // `transform3d` presence promotes — value-blind (an identity `{}`
1027        // still promotes, so animating from identity never flips promotion),
1028        // even on a leaf (no child/`groupAlpha` gate).
1029        let transformed = props(serde_json::json!({
1030            "style": { "transform3d": { "rotateY": 45 } }
1031        }));
1032        assert_eq!(
1033            promotion_reasons(&transformed, 0, false).0,
1034            PromotionReasons::TRANSFORM3D
1035        );
1036        let identity_3d = props(serde_json::json!({ "style": { "transform3d": {} } }));
1037        assert_eq!(
1038            promotion_reasons(&identity_3d, 0, false).0,
1039            PromotionReasons::TRANSFORM3D
1040        );
1041        // `groupAlpha: false` does NOT gate it (that knob is opacity-only).
1042        let opted_out = props(serde_json::json!({
1043            "style": { "transform3d": {}, "groupAlpha": false }
1044        }));
1045        assert!(promoted(&opted_out, 0, false));
1046        // Variant-only presence promotes eagerly (interaction never flips
1047        // promotion), and element eligibility still applies.
1048        for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1049            let variant_3d = props(serde_json::json!({
1050                "style": { "width": 10 },
1051                (variant): { "transform3d": { "rotateX": 10 } },
1052            }));
1053            assert_eq!(
1054                promotion_reasons(&variant_3d, 0, false).0,
1055                PromotionReasons::TRANSFORM3D,
1056                "{variant}-only transform3d promotes eagerly"
1057            );
1058        }
1059        assert!(!promoted(&transformed, 0, true));
1060
1061        // A non-empty `backdropFilter` chain promotes — same rules as FILTER:
1062        // even a leaf, value-blind, empty chain is a no-op, variant presence
1063        // unions, and element eligibility still applies.
1064        let backdrop =
1065            props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } }));
1066        assert_eq!(
1067            promotion_reasons(&backdrop, 0, false).0,
1068            PromotionReasons::BACKDROP
1069        );
1070        let empty_backdrop = props(serde_json::json!({ "style": { "backdropFilter": [] } }));
1071        assert!(!promoted(&empty_backdrop, 1, false));
1072        // Backdrop + content filter are independent bits.
1073        let both_chains = props(serde_json::json!({
1074            "style": { "backdropFilter": { "name": "blur" }, "filter": { "name": "sepia" } }
1075        }));
1076        assert_eq!(
1077            promotion_reasons(&both_chains, 0, false).0,
1078            PromotionReasons::BACKDROP | PromotionReasons::FILTER
1079        );
1080        for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1081            let variant_backdrop = props(serde_json::json!({
1082                "style": { "width": 10 },
1083                (variant): { "backdropFilter": { "name": "blur" } },
1084            }));
1085            assert_eq!(
1086                promotion_reasons(&variant_backdrop, 0, false).0,
1087                PromotionReasons::BACKDROP,
1088                "{variant}-only backdropFilter promotes eagerly"
1089            );
1090        }
1091        assert!(!promoted(&backdrop, 0, true));
1092
1093        // `morphFilter` presence promotes — same rules as FILTER: even a
1094        // leaf, value-blind, variant presence unions, and element
1095        // eligibility still applies. (A malformed value already degraded to
1096        // `None` at decode, so presence == a well-formed morph.)
1097        let morph = props(serde_json::json!({
1098            "style": { "morphFilter": { "key": "a", "name": "crossfade" } }
1099        }));
1100        assert_eq!(
1101            promotion_reasons(&morph, 0, false).0,
1102            PromotionReasons::MORPH
1103        );
1104        // Morph + content filter are independent bits.
1105        let morph_and_filter = props(serde_json::json!({
1106            "style": {
1107                "morphFilter": { "key": "a", "name": "crossfade" },
1108                "filter": { "name": "sepia" }
1109            }
1110        }));
1111        assert_eq!(
1112            promotion_reasons(&morph_and_filter, 0, false).0,
1113            PromotionReasons::MORPH | PromotionReasons::FILTER
1114        );
1115        for variant in ["hoverStyle", "pressStyle", "focusStyle"] {
1116            let variant_morph = props(serde_json::json!({
1117                "style": { "width": 10 },
1118                (variant): { "morphFilter": { "key": "a", "name": "crossfade" } },
1119            }));
1120            assert_eq!(
1121                promotion_reasons(&variant_morph, 0, false).0,
1122                PromotionReasons::MORPH,
1123                "{variant}-only morphFilter promotes eagerly"
1124            );
1125        }
1126        assert!(!promoted(&morph, 0, true));
1127
1128        // Absent → no bit; unset (style without the field) demotes.
1129        let plain = props(serde_json::json!({ "style": { "width": 10 } }));
1130        assert!(!promoted(&plain, 1, false));
1131    }
1132
1133    /// Spin up the op-apply + evaluator pipeline headless (mirrors
1134    /// `reconcile::tests::op_app`).
1135    fn layer_app() -> (bevy::app::App, crossbeam_channel::Sender<Vec<Op>>) {
1136        use bevy::app::App;
1137        let mut app = App::new();
1138        app.add_plugins((MinimalPlugins, AssetPlugin::default()));
1139        app.init_asset::<Image>();
1140        app.init_asset::<bevy::image::TextureAtlasLayout>();
1141        app.init_resource::<crate::plugin::Fonts>();
1142        app.init_resource::<crate::reconcile::OpApplyStats>();
1143        app.init_resource::<crate::ui_map::AtlasLayoutCache>();
1144        app.init_resource::<LayersRegistry>();
1145        app.init_resource::<LayerMembership>();
1146
1147        let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
1148        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
1149        std::mem::forget(out_rx);
1150        let root = app.world_mut().spawn_empty().id();
1151        app.insert_resource(JsBridge::new(ops_rx, out_tx, root));
1152        app.add_systems(
1153            Update,
1154            (
1155                crate::reconcile::apply_js_ops,
1156                evaluate_layer_promotions.after(crate::reconcile::apply_js_ops),
1157            ),
1158        );
1159        (app, ops_tx)
1160    }
1161
1162    fn create(id: NodeId, json: serde_json::Value) -> Op {
1163        Op::Create {
1164            id,
1165            kind: "node".into(),
1166            props: Box::new(props(json)),
1167            text: None,
1168        }
1169    }
1170
1171    fn update(id: NodeId, json: serde_json::Value, style_unset: &[&str]) -> Op {
1172        Op::Update {
1173            id,
1174            props: Box::new(props(json)),
1175            unset: vec![],
1176            style_unset: style_unset.iter().map(|s| s.to_string()).collect(),
1177        }
1178    }
1179
1180    fn entity_of(app: &bevy::app::App, id: NodeId) -> Entity {
1181        *app.world().resource::<JsBridge>().nodes.get(&id).unwrap()
1182    }
1183
1184    /// The full lifecycle: promote on opacity+child, fold suppressed while
1185    /// promoted (bg keeps its own alpha; `LayerGroupAlpha` carries the
1186    /// value), demote on `groupAlpha: false` re-bakes the fold, and losing
1187    /// the last child demotes too.
1188    #[test]
1189    fn promotion_lifecycle_and_fold_handoff() {
1190        let (mut app, ops_tx) = layer_app();
1191        ops_tx
1192            .send(vec![
1193                create(
1194                    1,
1195                    serde_json::json!({
1196                        "style": { "opacity": 0.5, "backgroundColor": "#ff0000" }
1197                    }),
1198                ),
1199                create(2, serde_json::json!({})),
1200                Op::Append {
1201                    parent: 1,
1202                    child: 2,
1203                },
1204            ])
1205            .unwrap();
1206        app.update();
1207
1208        let e = entity_of(&app, 1);
1209        assert!(app.world().get::<PromotedLayer>(e).is_some(), "promoted");
1210        assert_eq!(
1211            app.world().get::<LayerGroupAlpha>(e),
1212            Some(&LayerGroupAlpha(0.5))
1213        );
1214        let registry = app.world().resource::<LayersRegistry>();
1215        assert_eq!(registry.layers.len(), 1);
1216        assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::OPACITY);
1217        // Fold suppressed: the background keeps its full alpha (the flip
1218        // re-apply un-baked the create-time fold).
1219        let bg = app.world().get::<BackgroundColor>(e).unwrap();
1220        assert_eq!(bg.0.alpha(), 1.0, "promoted bg keeps its own alpha");
1221
1222        // Opt out via groupAlpha: demote + the fold is re-baked.
1223        ops_tx
1224            .send(vec![update(
1225                1,
1226                serde_json::json!({ "style": { "groupAlpha": false } }),
1227                &[],
1228            )])
1229            .unwrap();
1230        app.update();
1231        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1232        assert!(app.world().get::<LayerGroupAlpha>(e).is_none());
1233        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1234        let bg = app.world().get::<BackgroundColor>(e).unwrap();
1235        assert_eq!(bg.0.alpha(), 0.5, "demoted bg re-bakes the fold");
1236
1237        // Back on, then losing the last child demotes.
1238        ops_tx
1239            .send(vec![update(1, serde_json::json!({}), &["groupAlpha"])])
1240            .unwrap();
1241        app.update();
1242        let e1 = entity_of(&app, 1);
1243        assert!(app.world().get::<PromotedLayer>(e1).is_some());
1244        ops_tx
1245            .send(vec![Op::Remove {
1246                parent: 1,
1247                child: 2,
1248            }])
1249            .unwrap();
1250        app.update();
1251        assert!(
1252            app.world().get::<PromotedLayer>(e1).is_none(),
1253            "no children → demoted"
1254        );
1255        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1256    }
1257
1258    /// `cache: "always"` promotes a childless node with the FORCED reason and
1259    /// a neutral group alpha; unsetting it demotes.
1260    #[test]
1261    fn forced_cache_lifecycle() {
1262        let (mut app, ops_tx) = layer_app();
1263        ops_tx
1264            .send(vec![create(
1265                1,
1266                serde_json::json!({ "style": { "cache": "always" } }),
1267            )])
1268            .unwrap();
1269        app.update();
1270        let e = entity_of(&app, 1);
1271        let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1272        assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1273        // No opacity → the composite alpha is neutral.
1274        assert_eq!(
1275            app.world().get::<LayerGroupAlpha>(e),
1276            Some(&LayerGroupAlpha(1.0))
1277        );
1278
1279        ops_tx
1280            .send(vec![update(1, serde_json::json!({}), &["cache"])])
1281            .unwrap();
1282        app.update();
1283        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1284        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1285    }
1286
1287    /// `cache: "never"` force-promotes like `"always"` (same FORCED reason)
1288    /// but records the `Never` policy on the registry row; flipping between
1289    /// the two updates the policy without a demote/promote cycle, and
1290    /// unsetting demotes.
1291    #[test]
1292    fn never_cache_lifecycle_and_policy() {
1293        use crate::protocol::style::LayerCache;
1294        let (mut app, ops_tx) = layer_app();
1295        ops_tx
1296            .send(vec![create(
1297                1,
1298                serde_json::json!({ "style": { "cache": "never" } }),
1299            )])
1300            .unwrap();
1301        app.update();
1302        let e = entity_of(&app, 1);
1303        let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1304        assert_eq!(promoted.reasons.0, PromotionReasons::FORCED);
1305        let row = app.world().resource::<LayersRegistry>().layers[&1];
1306        assert_eq!(row.cache_policy, LayerCache::Never);
1307
1308        // Flip to "always": still promoted (same entity), policy updates.
1309        ops_tx
1310            .send(vec![update(
1311                1,
1312                serde_json::json!({ "style": { "cache": "always" } }),
1313                &[],
1314            )])
1315            .unwrap();
1316        app.update();
1317        assert!(
1318            app.world().get::<PromotedLayer>(e).is_some(),
1319            "stays promoted"
1320        );
1321        let row = app.world().resource::<LayersRegistry>().layers[&1];
1322        assert_eq!(row.cache_policy, LayerCache::Always);
1323
1324        ops_tx
1325            .send(vec![update(1, serde_json::json!({}), &["cache"])])
1326            .unwrap();
1327        app.update();
1328        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1329        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1330    }
1331
1332    /// A `filter` chain promotes a childless node with the FILTER reason and
1333    /// a registry row; unsetting the filter demotes.
1334    #[test]
1335    fn filter_promotion_lifecycle() {
1336        let (mut app, ops_tx) = layer_app();
1337        ops_tx
1338            .send(vec![create(
1339                1,
1340                serde_json::json!({ "style": { "filter": { "name": "grayscale" } } }),
1341            )])
1342            .unwrap();
1343        app.update();
1344        let e = entity_of(&app, 1);
1345        let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1346        assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1347        // No opacity → the composite alpha is neutral.
1348        assert_eq!(
1349            app.world().get::<LayerGroupAlpha>(e),
1350            Some(&LayerGroupAlpha(1.0))
1351        );
1352        let registry = app.world().resource::<LayersRegistry>();
1353        assert_eq!(registry.layers.len(), 1);
1354        assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::FILTER);
1355
1356        ops_tx
1357            .send(vec![update(1, serde_json::json!({}), &["filter"])])
1358            .unwrap();
1359        app.update();
1360        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1361        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1362    }
1363
1364    /// The BACKDROP lifecycle mirrors FILTER: a `backdropFilter` create
1365    /// promotes with the BACKDROP reason (neutral group alpha, registry
1366    /// entry), and `styleUnset` demotes and empties the registry.
1367    #[test]
1368    fn backdrop_promotion_lifecycle() {
1369        let (mut app, ops_tx) = layer_app();
1370        ops_tx
1371            .send(vec![create(
1372                1,
1373                serde_json::json!({ "style": { "backdropFilter": { "name": "grayscale" } } }),
1374            )])
1375            .unwrap();
1376        app.update();
1377        let e = entity_of(&app, 1);
1378        let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1379        assert_eq!(promoted.reasons.0, PromotionReasons::BACKDROP);
1380        assert_eq!(
1381            app.world().get::<LayerGroupAlpha>(e),
1382            Some(&LayerGroupAlpha(1.0))
1383        );
1384        let registry = app.world().resource::<LayersRegistry>();
1385        assert_eq!(registry.layers.len(), 1);
1386        assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::BACKDROP);
1387
1388        ops_tx
1389            .send(vec![update(1, serde_json::json!({}), &["backdropFilter"])])
1390            .unwrap();
1391        app.update();
1392        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1393        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1394    }
1395
1396    /// The MORPH lifecycle mirrors FILTER: a `morphFilter` create promotes a
1397    /// childless node with the MORPH reason (neutral group alpha, registry
1398    /// entry) — eagerly, so a cached capture exists to freeze on the first
1399    /// key change — and `styleUnset` demotes and empties the registry.
1400    #[test]
1401    fn morph_promotion_lifecycle() {
1402        let (mut app, ops_tx) = layer_app();
1403        ops_tx
1404            .send(vec![create(
1405                1,
1406                serde_json::json!({ "style": {
1407                    "morphFilter": { "key": "a", "name": "crossfade" }
1408                } }),
1409            )])
1410            .unwrap();
1411        app.update();
1412        let e = entity_of(&app, 1);
1413        let promoted = app.world().get::<PromotedLayer>(e).expect("promoted");
1414        assert_eq!(promoted.reasons.0, PromotionReasons::MORPH);
1415        assert_eq!(
1416            app.world().get::<LayerGroupAlpha>(e),
1417            Some(&LayerGroupAlpha(1.0))
1418        );
1419        let registry = app.world().resource::<LayersRegistry>();
1420        assert_eq!(registry.layers.len(), 1);
1421        assert_eq!(registry.layers[&1].reasons.0, PromotionReasons::MORPH);
1422        // A key-only change keeps the node promoted (presence is unchanged).
1423        ops_tx
1424            .send(vec![update(
1425                1,
1426                serde_json::json!({ "style": {
1427                    "morphFilter": { "key": "b", "name": "crossfade" }
1428                } }),
1429                &[],
1430            )])
1431            .unwrap();
1432        app.update();
1433        assert!(app.world().get::<PromotedLayer>(e).is_some());
1434
1435        ops_tx
1436            .send(vec![update(1, serde_json::json!({}), &["morphFilter"])])
1437            .unwrap();
1438        app.update();
1439        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1440        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1441    }
1442
1443    /// A `hoverStyle`-only filter promotes EAGERLY — from creation, while the
1444    /// node is not (and never has been) hovered — exactly like variant-carried
1445    /// opacity: the union is presence-based, so interaction never flips
1446    /// promotion and the capture exists before the first hover (no
1447    /// first-hover hitch). Unsetting the variant demotes.
1448    #[test]
1449    fn hover_filter_promotes_eagerly_from_creation() {
1450        let (mut app, ops_tx) = layer_app();
1451        ops_tx
1452            .send(vec![create(
1453                1,
1454                serde_json::json!({
1455                    "style": { "width": 10 },
1456                    "hoverStyle": { "filter": { "name": "blur", "params": { "radius": 8 } } },
1457                }),
1458            )])
1459            .unwrap();
1460        app.update();
1461        let e = entity_of(&app, 1);
1462        let promoted = app
1463            .world()
1464            .get::<PromotedLayer>(e)
1465            .expect("promoted before any hover");
1466        assert_eq!(promoted.reasons.0, PromotionReasons::FILTER);
1467        assert_eq!(
1468            app.world().resource::<LayersRegistry>().layers[&1]
1469                .reasons
1470                .0,
1471            PromotionReasons::FILTER
1472        );
1473
1474        // Dropping the hover variant (React removed `hoverStyle`) demotes.
1475        ops_tx
1476            .send(vec![Op::Update {
1477                id: 1,
1478                props: Box::new(props(serde_json::json!({}))),
1479                unset: vec!["hoverStyle".into()],
1480                style_unset: vec![],
1481            }])
1482            .unwrap();
1483        app.update();
1484        assert!(app.world().get::<PromotedLayer>(e).is_none(), "demoted");
1485        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
1486    }
1487
1488    /// [`resolve_layer_repaints`] unit-tested over a hand-built membership:
1489    /// content dirt resolves to the owning layer, composite-only dirt to the
1490    /// enclosing layer only, geometry-hash changes (and first frames) dirty,
1491    /// and dirt propagates out through nested layers.
1492    #[test]
1493    fn repaint_resolution_and_propagation() {
1494        let mut world = World::new();
1495        world.init_resource::<LayerContentDirt>();
1496        world.init_resource::<LayerRepaintState>();
1497        world.init_resource::<LayersRegistry>();
1498
1499        let outer = world.spawn_empty().id();
1500        let inner = world.spawn_empty().id();
1501        let member = world.spawn_empty().id(); // owned by `inner`
1502        let outer_member = world.spawn_empty().id(); // owned by `outer`
1503        let mut membership = LayerMembership::default();
1504        membership.node_to_layer.insert(outer, outer);
1505        membership.node_to_layer.insert(inner, inner);
1506        membership.node_to_layer.insert(member, inner);
1507        membership.node_to_layer.insert(outer_member, outer);
1508        membership.enclosing.insert(outer, None);
1509        membership.enclosing.insert(inner, Some(outer));
1510        world.insert_resource(membership);
1511
1512        let mut schedule = Schedule::default();
1513        schedule.add_systems(resolve_layer_repaints);
1514        let mut run = |world: &mut World| {
1515            schedule.run(world);
1516            world.resource::<LayerRepaintState>().dirty.clone()
1517        };
1518
1519        // Seed both layers' hashes (first sight = dirty).
1520        {
1521            let mut state = world.resource_mut::<LayerRepaintState>();
1522            state.geo_hashes.insert(outer, 1);
1523            state.geo_hashes.insert(inner, 2);
1524        }
1525        let dirty = run(&mut world);
1526        assert!(
1527            dirty.contains(&outer) && dirty.contains(&inner),
1528            "{dirty:?}"
1529        );
1530
1531        // Steady state: same hashes, no dirt → clean.
1532        {
1533            let mut state = world.resource_mut::<LayerRepaintState>();
1534            state.geo_hashes.insert(outer, 1);
1535            state.geo_hashes.insert(inner, 2);
1536        }
1537        assert!(run(&mut world).is_empty());
1538
1539        // Content dirt on an inner member → inner dirty AND propagates to outer.
1540        {
1541            let mut state = world.resource_mut::<LayerRepaintState>();
1542            state.geo_hashes.insert(outer, 1);
1543            state.geo_hashes.insert(inner, 2);
1544            world.resource_mut::<LayerContentDirt>().nodes.push(member);
1545        }
1546        let dirty = run(&mut world);
1547        assert!(dirty.contains(&inner) && dirty.contains(&outer));
1548
1549        // Composite-only dirt on the inner ROOT → outer only (its own capture
1550        // is untouched; its quad is the outer's content).
1551        {
1552            let mut state = world.resource_mut::<LayerRepaintState>();
1553            state.geo_hashes.insert(outer, 1);
1554            state.geo_hashes.insert(inner, 2);
1555            world
1556                .resource_mut::<LayerContentDirt>()
1557                .composite_only
1558                .push(inner);
1559        }
1560        let dirty = run(&mut world);
1561        assert!(
1562            dirty.contains(&outer) && !dirty.contains(&inner),
1563            "{dirty:?}"
1564        );
1565
1566        // Composite-only dirt on a TOP-LEVEL root → nothing to re-capture.
1567        {
1568            let mut state = world.resource_mut::<LayerRepaintState>();
1569            state.geo_hashes.insert(outer, 1);
1570            state.geo_hashes.insert(inner, 2);
1571            world
1572                .resource_mut::<LayerContentDirt>()
1573                .composite_only
1574                .push(outer);
1575        }
1576        assert!(run(&mut world).is_empty());
1577
1578        // A geometry-hash change dirties that layer (and propagates outward).
1579        {
1580            let mut state = world.resource_mut::<LayerRepaintState>();
1581            state.geo_hashes.insert(outer, 1);
1582            state.geo_hashes.insert(inner, 3);
1583        }
1584        let dirty = run(&mut world);
1585        assert!(dirty.contains(&inner) && dirty.contains(&outer));
1586
1587        // Content dirt on an outer-owned member → outer only.
1588        {
1589            let mut state = world.resource_mut::<LayerRepaintState>();
1590            state.geo_hashes.insert(outer, 1);
1591            state.geo_hashes.insert(inner, 3);
1592            world
1593                .resource_mut::<LayerContentDirt>()
1594                .nodes
1595                .push(outer_member);
1596        }
1597        let dirty = run(&mut world);
1598        assert!(dirty.contains(&outer) && !dirty.contains(&inner));
1599    }
1600
1601    /// A `cache: "never"` layer is dirty every frame with no other dirt —
1602    /// and, being ordinary dirt, propagates out through its enclosing layers
1603    /// (live pixels defeat ancestor caching). Unrelated layers stay cached.
1604    #[test]
1605    fn never_policy_repaints_every_frame() {
1606        use crate::protocol::style::LayerCache;
1607        let mut world = World::new();
1608        world.init_resource::<LayerContentDirt>();
1609        world.init_resource::<LayerRepaintState>();
1610        world.init_resource::<LayersRegistry>();
1611
1612        let outer = world.spawn_empty().id();
1613        let inner = world.spawn_empty().id(); // cache: "never", nested in outer
1614        let other = world.spawn_empty().id(); // unrelated top-level layer
1615        let mut membership = LayerMembership::default();
1616        membership.node_to_layer.insert(outer, outer);
1617        membership.node_to_layer.insert(inner, inner);
1618        membership.node_to_layer.insert(other, other);
1619        membership.enclosing.insert(outer, None);
1620        membership.enclosing.insert(inner, Some(outer));
1621        membership.enclosing.insert(other, None);
1622        world.insert_resource(membership);
1623
1624        let meta = |node: NodeId, entity: Entity, policy: LayerCache| LayerMeta {
1625            node,
1626            entity,
1627            reasons: PromotionReasons(PromotionReasons::FORCED),
1628            group_alpha: 1.0,
1629            capture_rect: None,
1630            depth: 1,
1631            repaints: 0,
1632            cached: false,
1633            cache_policy: policy,
1634        };
1635        {
1636            let mut registry = world.resource_mut::<LayersRegistry>();
1637            registry.layers.insert(1, meta(1, outer, LayerCache::Auto));
1638            registry.layers.insert(2, meta(2, inner, LayerCache::Never));
1639            registry
1640                .layers
1641                .insert(3, meta(3, other, LayerCache::Always));
1642        }
1643
1644        let mut schedule = Schedule::default();
1645        schedule.add_systems(resolve_layer_repaints);
1646        for frame in 0..2 {
1647            schedule.run(&mut world);
1648            let state = world.resource::<LayerRepaintState>();
1649            assert!(
1650                state.dirty.contains(&inner) && state.dirty.contains(&outer),
1651                "frame {frame}: {:?}",
1652                state.dirty
1653            );
1654            assert!(!state.dirty.contains(&other), "frame {frame}");
1655        }
1656        let registry = world.resource::<LayersRegistry>();
1657        assert_eq!(registry.layers[&2].repaints, 2);
1658        assert!(!registry.layers[&2].cached);
1659        assert!(registry.layers[&3].cached);
1660    }
1661
1662    /// The geometry fold cancels a uniform translation of root + members
1663    /// (moving a whole layer never re-captures) but reacts to relative moves,
1664    /// resizes, and member scale/rotation.
1665    #[test]
1666    fn geometry_fold_translation_invariance() {
1667        use bevy::math::Affine2;
1668
1669        let node = |size: Vec2, pos: Vec2| {
1670            let computed = ComputedNode {
1671                size,
1672                ..Default::default()
1673            };
1674            (
1675                computed,
1676                UiGlobalTransform::from(Affine2::from_translation(pos)),
1677            )
1678        };
1679        let fold = |members: &[(ComputedNode, UiGlobalTransform)], root: Vec2| {
1680            let mut hash = GEO_HASH_SEED;
1681            for (computed, transform) in members {
1682                fold_member_geometry(&mut hash, root, transform, computed);
1683            }
1684            hash
1685        };
1686
1687        let members = [
1688            node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1689            node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1690        ];
1691        let base = fold(&members, Vec2::new(10.0, 20.0));
1692
1693        // Shift everything (root + members) by the same delta — even a
1694        // fractional one — and the hash is unchanged.
1695        let delta = Vec2::new(123.4, -56.78);
1696        let shifted = [
1697            node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0) + delta),
1698            node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0) + delta),
1699        ];
1700        assert_eq!(base, fold(&shifted, Vec2::new(10.0, 20.0) + delta));
1701
1702        // One member moves relative to the root → different hash.
1703        let moved = [
1704            node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1705            node(Vec2::new(30.0, 30.0), Vec2::new(41.0, 25.0)),
1706        ];
1707        assert_ne!(base, fold(&moved, Vec2::new(10.0, 20.0)));
1708
1709        // A member resizes → different hash.
1710        let resized = [
1711            node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1712            node(Vec2::new(31.0, 30.0), Vec2::new(40.0, 25.0)),
1713        ];
1714        assert_ne!(base, fold(&resized, Vec2::new(10.0, 20.0)));
1715
1716        // A member's scale (affine linear part) changes → different hash.
1717        let mut scaled = [
1718            node(Vec2::new(100.0, 50.0), Vec2::new(10.0, 20.0)),
1719            node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0)),
1720        ];
1721        scaled[1].1 = UiGlobalTransform::from(Affine2::from_scale_angle_translation(
1722            Vec2::splat(1.5),
1723            0.0,
1724            Vec2::new(40.0, 25.0),
1725        ));
1726        assert_ne!(base, fold(&scaled, Vec2::new(10.0, 20.0)));
1727
1728        // Sub-quantum float noise (≪ 1/64 px) does NOT change the hash.
1729        let noisy = [
1730            node(Vec2::new(100.0, 50.0), Vec2::new(10.0 + 1e-4, 20.0)),
1731            node(Vec2::new(30.0, 30.0), Vec2::new(40.0, 25.0 - 1e-4)),
1732        ];
1733        assert_eq!(base, fold(&noisy, Vec2::new(10.0, 20.0)));
1734    }
1735
1736    /// World + schedule harness for [`sync_layer_geometry`] +
1737    /// [`resolve_layer_repaints`]: promoted roots are hand-spawned with
1738    /// laid-out geometry (`ComputedNode` / `UiGlobalTransform`), and filter
1739    /// state is inserted directly ([`crate::filters::ResolvedFilterChain`]) —
1740    /// the resolver system does not run here.
1741    fn geometry_world() -> (World, Schedule) {
1742        let mut world = World::new();
1743        world.init_resource::<LayerMembership>();
1744        world.init_resource::<LayersRegistry>();
1745        world.init_resource::<LayerRepaintState>();
1746        world.init_resource::<LayerContentDirt>();
1747        let mut schedule = Schedule::default();
1748        schedule.add_systems((sync_layer_geometry, resolve_layer_repaints).chain());
1749        (world, schedule)
1750    }
1751
1752    /// `center` is the node's `UiGlobalTransform` translation (its center, as
1753    /// in the real UI transform), so the un-inflated rect min is
1754    /// `center - size/2`.
1755    fn spawn_layer_root(world: &mut World, id: NodeId, size: Vec2, center: Vec2) -> Entity {
1756        world
1757            .spawn((
1758                ComputedNode {
1759                    size,
1760                    ..Default::default()
1761                },
1762                UiGlobalTransform::from(bevy::math::Affine2::from_translation(center)),
1763                crate::bridge::RNode(id),
1764                LayerGroupAlpha(1.0),
1765                PromotedLayer {
1766                    reasons: PromotionReasons(PromotionReasons::FILTER),
1767                },
1768            ))
1769            .id()
1770    }
1771
1772    fn filter_outset(world: &mut World, e: Entity, outset_px: u32) {
1773        world
1774            .entity_mut(e)
1775            .insert(crate::filters::ResolvedFilterChain {
1776                outset_px,
1777                ..Default::default()
1778            });
1779    }
1780
1781    fn backdrop_outset(world: &mut World, e: Entity, outset_px: u32) {
1782        world
1783            .entity_mut(e)
1784            .insert(crate::filters::ResolvedBackdropChain(
1785                crate::filters::ResolvedFilterChain {
1786                    outset_px,
1787                    ..Default::default()
1788                },
1789            ));
1790    }
1791
1792    /// A filtered root's capture rect grows by the QUANTIZED outset on every
1793    /// side: min shifts by `-q`, size by `+2q` per axis (blur reads/writes
1794    /// beyond the border box, so capture and composite quad must both cover
1795    /// the bleed).
1796    #[test]
1797    fn outset_inflates_rect_by_quantized_margin() {
1798        let (mut world, mut schedule) = geometry_world();
1799        let size = Vec2::new(100.0, 60.0);
1800        let center = Vec2::new(50.0, 30.0);
1801        let plain = spawn_layer_root(&mut world, 1, size, center);
1802        let blurred = spawn_layer_root(&mut world, 2, size, center);
1803        filter_outset(&mut world, blurred, 12); // blur radius 4 → 3×4 = 12
1804        let big = spawn_layer_root(&mut world, 3, size, center);
1805        filter_outset(&mut world, big, 60); // blur radius 20 → 60
1806        schedule.run(&mut world);
1807
1808        let base = *world.get::<LayerCaptureRect>(plain).expect("baseline rect");
1809        assert_eq!(base.min, Vec2::ZERO);
1810        assert_eq!(base.size, UVec2::new(100, 60));
1811        // quantize_outset(12) = 16.
1812        let rect = *world.get::<LayerCaptureRect>(blurred).expect("rect");
1813        assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1814        assert_eq!(rect.size, base.size + UVec2::splat(32));
1815        // quantize_outset(60) = 64.
1816        let rect = *world.get::<LayerCaptureRect>(big).expect("rect");
1817        assert_eq!(rect.min, base.min - Vec2::splat(64.0));
1818        assert_eq!(rect.size, base.size + UVec2::splat(128));
1819    }
1820
1821    /// The backdrop chain's outset inflates the shared capture window too,
1822    /// and when both chains carry one, the MAX wins (one window serves both);
1823    /// the applied margin is recorded on `rect.outset` so the render side can
1824    /// recover the un-inflated border box for the backdrop quad.
1825    #[test]
1826    fn backdrop_outset_inflates_rect_and_maxes_with_content() {
1827        let (mut world, mut schedule) = geometry_world();
1828        let size = Vec2::new(100.0, 60.0);
1829        let center = Vec2::new(50.0, 30.0);
1830        let plain = spawn_layer_root(&mut world, 1, size, center);
1831        let frosted = spawn_layer_root(&mut world, 2, size, center);
1832        backdrop_outset(&mut world, frosted, 12); // quantize → 16
1833        let both = spawn_layer_root(&mut world, 3, size, center);
1834        filter_outset(&mut world, both, 4); // quantize → 16
1835        backdrop_outset(&mut world, both, 40); // quantize → 48, wins
1836        schedule.run(&mut world);
1837
1838        let base = *world.get::<LayerCaptureRect>(plain).expect("baseline");
1839        assert_eq!(base.outset, 0);
1840        let rect = *world.get::<LayerCaptureRect>(frosted).expect("rect");
1841        assert_eq!(rect.min, base.min - Vec2::splat(16.0));
1842        assert_eq!(rect.size, base.size + UVec2::splat(32));
1843        assert_eq!(rect.outset, 16);
1844        let rect = *world.get::<LayerCaptureRect>(both).expect("rect");
1845        assert_eq!(rect.min, base.min - Vec2::splat(48.0));
1846        assert_eq!(rect.size, base.size + UVec2::splat(96));
1847        assert_eq!(rect.outset, 48);
1848    }
1849
1850    /// An ACTIVE morph never alters the capture rect: the frozen snapshot is
1851    /// layout-anchored (stretched onto the current rect at blend time), so
1852    /// the rect is the node's own box mid-flight, and a pure translation
1853    /// (scroll) mid-morph stays a capture-cache hit.
1854    #[test]
1855    fn active_morph_keeps_layout_rect() {
1856        let (mut world, mut schedule) = geometry_world();
1857        // Layout: 80×40 with min (20, 10) → center (60, 30).
1858        let root = spawn_layer_root(&mut world, 1, Vec2::new(80.0, 40.0), Vec2::new(60.0, 30.0));
1859        world.entity_mut(root).insert(crate::filters::MorphState {
1860            active: true,
1861            progress: 0.3,
1862            freeze_seq: 1,
1863        });
1864        schedule.run(&mut world);
1865        let rect = *world.get::<LayerCaptureRect>(root).expect("rect");
1866        assert_eq!(rect.min, Vec2::new(20.0, 10.0), "rect is the node's box");
1867        assert_eq!(rect.size, UVec2::new(80, 40));
1868
1869        // Steady state mid-morph: no re-capture.
1870        schedule.run(&mut world);
1871        assert!(
1872            !world.resource::<LayerRepaintState>().dirty.contains(&root),
1873            "an active morph must not re-capture every frame"
1874        );
1875
1876        // A translation mid-morph (scroll) keeps the size and the
1877        // root-relative content geometry — still a cache hit.
1878        world.entity_mut(root).insert(UiGlobalTransform::from(
1879            bevy::math::Affine2::from_translation(Vec2::new(60.0, 130.0)),
1880        ));
1881        schedule.run(&mut world);
1882        let rect = *world.get::<LayerCaptureRect>(root).expect("rect");
1883        assert_eq!(rect.min, Vec2::new(20.0, 110.0), "rect follows layout");
1884        assert!(
1885            !world.resource::<LayerRepaintState>().dirty.contains(&root),
1886            "a scrolled mid-morph layer must stay a capture-cache hit"
1887        );
1888    }
1889
1890    /// An outset change WITHIN one 16px quantize step keeps the rect — and
1891    /// therefore the geometry hash and the capture cache — untouched (the
1892    /// point of quantizing: an animated radius must not realloc every frame).
1893    #[test]
1894    fn outset_within_quantize_step_holds_rect_and_cache() {
1895        let (mut world, mut schedule) = geometry_world();
1896        let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1897        filter_outset(&mut world, e, 12);
1898        schedule.run(&mut world);
1899        assert!(
1900            world.resource::<LayerRepaintState>().dirty.contains(&e),
1901            "first frame repaints"
1902        );
1903        let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1904
1905        filter_outset(&mut world, e, 14); // same step: quantize(14) == quantize(12) == 16
1906        schedule.run(&mut world);
1907        assert_eq!(*world.get::<LayerCaptureRect>(e).expect("rect"), before);
1908        assert!(
1909            world.resource::<LayerRepaintState>().dirty.is_empty(),
1910            "no repaint within a quantize step"
1911        );
1912    }
1913
1914    /// Crossing a quantize step changes the rect size, which is folded into
1915    /// the geometry hash — so the layer re-captures automatically at the new
1916    /// size.
1917    #[test]
1918    fn outset_crossing_quantize_step_recaptures() {
1919        let (mut world, mut schedule) = geometry_world();
1920        let e = spawn_layer_root(&mut world, 1, Vec2::new(100.0, 60.0), Vec2::new(50.0, 30.0));
1921        filter_outset(&mut world, e, 14); // quantize → 16
1922        schedule.run(&mut world);
1923        schedule.run(&mut world); // settle: steady state is clean
1924        assert!(world.resource::<LayerRepaintState>().dirty.is_empty());
1925        let before = *world.get::<LayerCaptureRect>(e).expect("rect");
1926
1927        filter_outset(&mut world, e, 18); // quantize → 32: next step
1928        schedule.run(&mut world);
1929        let after = *world.get::<LayerCaptureRect>(e).expect("rect");
1930        assert_eq!(after.min, before.min - Vec2::splat(16.0));
1931        assert_eq!(after.size, before.size + UVec2::splat(32));
1932        assert!(
1933            world.resource::<LayerRepaintState>().dirty.contains(&e),
1934            "step crossing re-captures"
1935        );
1936    }
1937
1938    /// The inactive gate reads the CONTENT size: a zero-sized filtered node
1939    /// must not become an active layer just because its outset is non-zero.
1940    #[test]
1941    fn zero_content_size_stays_inactive_despite_outset() {
1942        let (mut world, mut schedule) = geometry_world();
1943        let e = spawn_layer_root(&mut world, 1, Vec2::ZERO, Vec2::ZERO);
1944        filter_outset(&mut world, e, 60);
1945        schedule.run(&mut world);
1946        assert!(
1947            world.get::<LayerCaptureRect>(e).is_none(),
1948            "zero content size stays inactive"
1949        );
1950    }
1951
1952    /// A nested filtered layer whose INFLATED rect escapes the enclosing
1953    /// layer's rect warns (`filterBleed`, attributed to the node, naming the
1954    /// clipped sides); a fully-contained bleed does not, and a steady bleed
1955    /// re-reports only when the rect pair changes.
1956    #[cfg(all(feature = "devtools", debug_assertions))]
1957    #[test]
1958    fn nested_filter_bleed_warns_when_clipped() {
1959        let _lock = crate::diag::test_lock();
1960        crate::diag::arm_runtime();
1961        let _ = crate::diag::take_runtime_warnings();
1962
1963        let (mut world, mut schedule) = geometry_world();
1964        // Outer layer: 200×200 at (0,0)-(200,200).
1965        let outer = spawn_layer_root(&mut world, 1, Vec2::splat(200.0), Vec2::splat(100.0));
1966        // Inner filtered layer: 100×100 centered → un-inflated (50,50)-(150,150).
1967        let inner = spawn_layer_root(&mut world, 2, Vec2::splat(100.0), Vec2::splat(100.0));
1968        world.entity_mut(inner).insert((
1969            ChildOf(outer),
1970            crate::filters::FilterInput(crate::filters::FilterChain(vec![
1971                crate::filters::FilterUse {
1972                    name: "blur".into(),
1973                    params: Default::default(),
1974                },
1975            ])),
1976        ));
1977        // Contained: quantize(12)=16 → (34,34)-(166,166) fits inside.
1978        filter_outset(&mut world, inner, 12);
1979        schedule.run(&mut world);
1980        let bleeds = |warns: Vec<crate::diag::RuntimeWarning>| -> Vec<_> {
1981            warns
1982                .into_iter()
1983                .filter(|w| w.kind == "filterBleed")
1984                .collect()
1985        };
1986        assert!(
1987            bleeds(crate::diag::take_runtime_warnings()).is_empty(),
1988            "contained bleed does not warn"
1989        );
1990
1991        // Escaped: quantize(60)=64 → (-14,-14)-(214,214), clipped on all sides.
1992        filter_outset(&mut world, inner, 60);
1993        schedule.run(&mut world);
1994        let warns = bleeds(crate::diag::take_runtime_warnings());
1995        assert_eq!(warns.len(), 1, "{warns:?}");
1996        assert_eq!(warns[0].node, Some(2));
1997        assert_eq!(warns[0].value, "blur");
1998        for side in ["left", "top", "right", "bottom"] {
1999            assert!(warns[0].message.contains(side), "{}", warns[0].message);
2000        }
2001
2002        // Steady state (same rect pair): no re-report.
2003        schedule.run(&mut world);
2004        assert!(
2005            bleeds(crate::diag::take_runtime_warnings()).is_empty(),
2006            "unchanged bleed is not re-reported"
2007        );
2008    }
2009
2010    /// Removing a promoted node prunes its registry row (despawn cleans the
2011    /// markers; the sweep cleans the resource).
2012    #[test]
2013    fn removal_prunes_registry() {
2014        let (mut app, ops_tx) = layer_app();
2015        ops_tx
2016            .send(vec![
2017                create(1, serde_json::json!({})),
2018                create(2, serde_json::json!({ "style": { "opacity": 0.3 } })),
2019                create(3, serde_json::json!({})),
2020                Op::Append {
2021                    parent: 1,
2022                    child: 2,
2023                },
2024                Op::Append {
2025                    parent: 2,
2026                    child: 3,
2027                },
2028            ])
2029            .unwrap();
2030        app.update();
2031        assert_eq!(app.world().resource::<LayersRegistry>().layers.len(), 1);
2032
2033        ops_tx
2034            .send(vec![Op::Remove {
2035                parent: 1,
2036                child: 2,
2037            }])
2038            .unwrap();
2039        app.update();
2040        assert!(app.world().resource::<LayersRegistry>().layers.is_empty());
2041        assert!(
2042            app.world()
2043                .resource::<JsBridge>()
2044                .promoted_layers
2045                .is_empty()
2046        );
2047    }
2048}