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