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