Skip to main content

bevy_react/
protocol.rs

1//! The wire protocol shared between the JS reconciler and the Bevy side.
2//!
3//! Everything here derives `serde` so deno_core's `serde_v8` can convert
4//! directly between the plain JS objects the reconciler builds and these Rust
5//! types — no JSON strings on the hot path. Ops only ever flow JS -> Rust, so
6//! they need `Deserialize` only; `UiEvent` flows Rust -> JS and is `Serialize`.
7//!
8//! Wire strings are decoded **once, here at the serde boundary** — never
9//! re-parsed on apply. The unit-bearing types (`Length`/`Angle`/`Time`/
10//! `FontSize`) parse into their own wire types, and the enum-like style fields
11//! (`display`/`align*`/`flex*`/grid tracks/…) decode directly into the
12//! `bevy_ui`/`bevy_text` values they drive, via field-level `deserialize_with`
13//! (which sidesteps the orphan rule), so applying a style in [`crate::ui_map`]
14//! is a plain field copy. A malformed string must **not** fail the whole batch
15//! (one typo would abort the entire commit and trigger a reload), so every
16//! deserializer falls back to the bevy default and emits a
17//! `tracing::warn!` naming the bad value (`tracing` reaches the same log sink
18//! `bevy_log` drains). In dev builds with devtools those fallbacks are also
19//! collected as structured `crate::diag` entries (`decode_warn` +
20//! [`OpBatch`]'s per-op attribution) so the inspector can flag the offending
21//! style/prop rows.
22
23use std::fmt;
24
25use bevy::text::{FontWeight, Justify, LineBreak};
26use bevy::ui::{
27    AlignContent, AlignItems, AlignSelf, BoxSizing, Display, FlexDirection, FlexWrap, FocusPolicy,
28    GridAutoFlow, GridPlacement, GridTrack, JustifyContent, JustifyItems, JustifySelf,
29    OverflowAxis, PositionType, RepeatedGridTrack,
30};
31use serde::de::{self, Deserializer, MapAccess, Visitor};
32use serde::{Deserialize, Serialize};
33
34/// Stable identity for a node, assigned by the JS reconciler. `0` is reserved
35/// for the root container (the Bevy UI root entity).
36pub type NodeId = u32;
37
38pub const ROOT_ID: NodeId = 0;
39
40/// A single mutation produced by the React reconciler during a commit. The
41/// reconciler batches a `Vec<Op>` per commit and flushes it across the boundary
42/// in one call.
43#[derive(Debug, Clone, Deserialize)]
44#[serde(tag = "op", rename_all = "camelCase")]
45pub enum Op {
46    /// Tear down the entire current tree. Emitted first by every fresh runtime
47    /// so a hot reload clears the previous UI before the new render is applied.
48    Reset,
49    /// Spawn a host element (`node`, `button`, or `image`).
50    Create {
51        id: NodeId,
52        kind: String,
53        #[serde(default)]
54        props: Props,
55        /// Inline text content for a single-string `<text>`/`<textSpan>` (the
56        /// `shouldSetTextContent` fast path — no separate child text entity).
57        #[serde(default)]
58        text: Option<String>,
59    },
60    /// Spawn a standalone text node (a bare string outside any `<text>`).
61    CreateText { id: NodeId, text: String },
62    /// Spawn a text run inside a `<text>` element (a Bevy `TextSpan`). Its style
63    /// is inherited from the enclosing `<text>` at append time.
64    CreateTextSpan { id: NodeId, text: String },
65    /// Make `child` the last child of `parent` (`parent == ROOT_ID` is the root).
66    Append { parent: NodeId, child: NodeId },
67    /// Insert `child` before `before` under `parent`.
68    Insert {
69        parent: NodeId,
70        child: NodeId,
71        before: NodeId,
72    },
73    /// Detach and despawn `child` (and its descendants).
74    Remove { parent: NodeId, child: NodeId },
75    /// Apply a prop **delta** to an existing element, against its last applied
76    /// props (retained per node in `JsBridge::props_cache`).
77    ///
78    /// A field present in `props` is set; a wire name listed in `unset` is
79    /// reset to its default (for booleans: set `false`); a field in neither is
80    /// left unchanged. `props.style` is itself a field-level delta: its `Some`
81    /// fields overwrite the corresponding fields of the last applied style,
82    /// and style wire names listed in `style_unset` are cleared (`style_unset`
83    /// applies even when `props.style` is absent). The variant styles
84    /// (`hoverStyle`/`pressStyle`/`focusStyle`) and other object-valued props
85    /// are atomic: present replaces the whole value, `unset` clears it.
86    ///
87    /// The event-like props (`value`, `selectionStart`/`selectionEnd`,
88    /// `scrollTop`/`scrollLeft`, `draw`) keep their "present = act now" meaning
89    /// and are never part of the retained state (see [`Props::merge_delta`]).
90    Update {
91        id: NodeId,
92        #[serde(default)]
93        props: Props,
94        /// Top-level prop wire names (camelCase) reset to their defaults.
95        #[serde(default)]
96        unset: Vec<String>,
97        /// Style field wire names (camelCase) cleared from the merged style.
98        /// (The enum's `rename_all` covers variant names, not their fields, so
99        /// the wire name is spelled out.)
100        #[serde(default, rename = "styleUnset")]
101        style_unset: Vec<String>,
102    },
103    /// Replace the string of a text node.
104    UpdateText { id: NodeId, text: String },
105    /// Append draw commands to a `canvas` element's retained surface — the
106    /// imperative `getContext()` handle's microtask flush, or the JS
107    /// runtime's clear+replay of a declarative painter after a resize. Paint
108    /// accumulates on the retained pixels; a leading [`DrawCmd::Clear`] makes
109    /// the batch a replace. Bypasses the props cache entirely (nothing is
110    /// retained protocol-side). A missing or non-canvas node is skipped
111    /// silently, like every other op.
112    Draw { id: NodeId, cmds: Vec<DrawCmd> },
113}
114
115/// Emit a decode-fallback warning: the log line every malformed wire value
116/// already produced, plus (in dev builds with devtools) a structured
117/// [`crate::diag`] entry so the inspector can flag the offending row. `kind`
118/// names the value's domain (`"length"`, `"rect"`, a keyword field's kind, …);
119/// `value` is the raw offending wire string.
120pub(crate) fn decode_warn(kind: &'static str, value: &str, message: &str) {
121    tracing::warn!(target: "bevy_react", "{message}");
122    crate::diag::decode_report(kind, value, message);
123}
124
125/// A `Vec<Op>` whose `Deserialize` brackets each element's decode with the
126/// [`crate::diag`] decode sink's watermarks, stamping every warning a field
127/// deserializer pushed with the op's target node id — the id is structurally
128/// out of scope down in the field visitors, but trivially known per op here.
129/// The wire format is exactly a plain op array; in release builds the
130/// bracketing calls are inline no-ops and this decodes like a bare `Vec<Op>`.
131pub struct OpBatch(pub Vec<Op>);
132
133/// The node an op targets, for decode-warning attribution. Tree ops carry no
134/// decodable values, so they have no meaningful target.
135fn op_target_id(op: &Op) -> Option<NodeId> {
136    match op {
137        Op::Create { id, .. }
138        | Op::CreateText { id, .. }
139        | Op::CreateTextSpan { id, .. }
140        | Op::Update { id, .. }
141        | Op::UpdateText { id, .. }
142        | Op::Draw { id, .. } => Some(*id),
143        Op::Reset | Op::Append { .. } | Op::Insert { .. } | Op::Remove { .. } => None,
144    }
145}
146
147impl<'de> Deserialize<'de> for OpBatch {
148    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
149        struct BatchVisitor;
150        impl<'de> Visitor<'de> for BatchVisitor {
151            type Value = Vec<Op>;
152            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
153                f.write_str("an array of reconciler ops")
154            }
155            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Op>, A::Error> {
156                // Clearing at batch start (not on drain) bounds the sink even
157                // when nothing ever drains it, and drops entries from a batch
158                // whose decode threw mid-way (Bevy never saw those ops).
159                crate::diag::decode_batch_start();
160                let mut ops = Vec::with_capacity(seq.size_hint().unwrap_or(0));
161                loop {
162                    let mark = crate::diag::decode_watermark();
163                    let Some(op) = seq.next_element::<Op>()? else {
164                        break;
165                    };
166                    crate::diag::decode_attribute_since(mark, op_target_id(&op));
167                    ops.push(op);
168                }
169                Ok(ops)
170            }
171        }
172        d.deserialize_seq(BatchVisitor).map(OpBatch)
173    }
174}
175
176/// Props for a host element. Event handlers never cross the boundary — the
177/// reconciler replaces them with booleans (e.g. `onClick: true`) and keeps the
178/// actual function in a JS-side map. Visual styling lives entirely in [`Style`];
179/// the fields here are content/attribute level.
180#[derive(Debug, Clone, Default, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct Props {
183    /// CSS-like layout + visual style, mapped onto `bevy_ui` components.
184    #[serde(default)]
185    pub style: Option<Style>,
186    /// Style overlaid on `style` while the element is hovered. Decoded exactly
187    /// like `style`; applied on the Bevy side from the node's `Interaction`.
188    #[serde(default)]
189    pub hover_style: Option<Style>,
190    /// Style overlaid on `style` (and `hover_style`) while the element is pressed.
191    #[serde(default)]
192    pub press_style: Option<Style>,
193    /// Style overlaid on `style` while the element is focused (currently
194    /// `editableText`). Applied on the Bevy side from the node's focus state, so
195    /// focus styling needs no React round-trip.
196    #[serde(default)]
197    pub focus_style: Option<Style>,
198    /// Whether this element has an `onClick` handler registered in JS.
199    #[serde(default)]
200    pub on_click: bool,
201    /// Whether this element has an `onPointerDown` handler registered in JS.
202    #[serde(default)]
203    pub on_pointer_down: bool,
204    /// Whether this element has an `onPointerMove` handler registered in JS.
205    /// Fires each frame while the pointer is held down (a drag).
206    #[serde(default)]
207    pub on_pointer_move: bool,
208    /// Whether this element has an `onPointerUp` handler registered in JS.
209    #[serde(default)]
210    pub on_pointer_up: bool,
211    /// Whether this element has an `onPointerEnter` handler registered in JS.
212    /// Fires once when the pointer enters the element (hover begins).
213    #[serde(default)]
214    pub on_pointer_enter: bool,
215    /// Whether this element has an `onPointerLeave` handler registered in JS.
216    /// Fires once when the pointer leaves the element (hover ends).
217    #[serde(default)]
218    pub on_pointer_leave: bool,
219
220    // --- controlled scroll (any node with `overflow: scroll`) ---
221    /// Controlled vertical scroll offset (logical px) → `ScrollPosition.y`. On
222    /// update it's pushed into the node only when it diverges from the live offset
223    /// (so a re-render echoing the user's own wheel scroll is a no-op — see
224    /// [`crate::reconcile`]). Each axis is independent; absent leaves it alone.
225    #[serde(default)]
226    pub scroll_top: Option<f32>,
227    /// Controlled horizontal scroll offset (logical px) → `ScrollPosition.x`.
228    #[serde(default)]
229    pub scroll_left: Option<f32>,
230    /// Logical pixels scrolled per mouse-wheel "line" for this container, overriding
231    /// the default. Maps to [`crate::bridge::ScrollStep`]; only scales `Line`-unit
232    /// wheels (trackpad `Pixel` deltas are used raw).
233    #[serde(default)]
234    pub scroll_step: Option<f32>,
235    /// Whether this element has an `onScroll` handler registered in JS. Present →
236    /// the reconciler stamps a [`crate::bridge::ScrollListener`] so the read-back
237    /// system reports offset changes (kept cheap by scoping its `Changed` query to
238    /// that marker, since `ScrollPosition` is a required component of every `Node`).
239    #[serde(default)]
240    pub on_scroll: bool,
241    /// Whether this element has an `onWheel` handler registered in JS. Present →
242    /// the reconciler stamps a [`crate::bridge::WheelListener`] so
243    /// [`crate::scroll::collect_wheel_events`] reports raw wheel deltas over the
244    /// node (any node, unlike `onScroll`, which needs `overflow: scroll`).
245    #[serde(default)]
246    pub on_wheel: bool,
247
248    /// World-anchor binding for an `<anchor>` element: the Bevy entity to follow and
249    /// an optional offset. Present → the reconciler stamps a [`crate::anchor::Anchored`]
250    /// so the per-frame positioning system tracks it. Pure-serde, Bevy-free.
251    #[serde(default)]
252    pub anchor: Option<crate::anchor::Anchor>,
253
254    // --- `image` element attributes ---
255    /// Asset path for an `image`, resolved by Bevy's `AssetServer` (relative to
256    /// the app's `assets/` folder). Absent → a solid-color image (see `tint`).
257    #[serde(default)]
258    pub src: Option<String>,
259    /// Tint multiplied with the image (hex); also the fill of a `src`-less image.
260    #[serde(default)]
261    pub tint: Option<String>,
262    /// Flip the image along its x-axis.
263    #[serde(default)]
264    pub flip_x: bool,
265    /// Flip the image along its y-axis.
266    #[serde(default)]
267    pub flip_y: bool,
268    /// How the image fits its box: the keyword `"auto"`/`"stretch"`, or a
269    /// `type`-tagged object for 9-slice (`"sliced"`) / `"tiled"` scaling.
270    #[serde(default)]
271    pub image_mode: Option<ImageMode>,
272    /// Source sub-rect of the texture to display, in source-texture pixels.
273    /// Maps to `ImageNode.rect`. With `atlas`, it offsets from the atlas cell's
274    /// top-left corner.
275    #[serde(default)]
276    pub source_rect: Option<SourceRect>,
277    /// Treat `src` as a uniform sprite-sheet grid and select one cell. Maps to
278    /// `ImageNode.texture_atlas` (builds/caches a `TextureAtlasLayout`).
279    #[serde(default)]
280    pub atlas: Option<AtlasSpec>,
281    /// Which box of the node the image fills: `"content"` | `"padding"`
282    /// (default) | `"border"`. Maps to `ImageNode.visual_box`.
283    #[serde(default)]
284    pub visual_box: Option<String>,
285
286    // --- `canvas` element attributes ---
287    /// The declarative display list for a `canvas` element: an ordered batch of
288    /// vector draw commands (the recorded form of an HTML-canvas-like
289    /// `ctx.moveTo/lineTo/…` session). Present → the retained surface is
290    /// **cleared and the list replayed** (raster state reset first).
291    /// `Some(vec![])` clears the canvas; absent leaves the retained pixels.
292    /// Imperative (accumulating) drawing rides [`Op::Draw`] instead.
293    #[serde(default)]
294    pub draw: Option<Vec<DrawCmd>>,
295    /// Whether this element has an `onResize` handler registered in JS. Cached
296    /// only so the delta stays truthful — `"resize"` events are **not** gated
297    /// on it (the JS runtime consumes them unconditionally, to replay a
298    /// declarative painter and keep the canvas handle's size fresh).
299    #[serde(default)]
300    pub on_resize: bool,
301
302    // --- `portal` element attribute ---
303    /// The render-target name a `portal` element displays. The reconciler stamps
304    /// a `crate::portal::RPortal` carrying it; the binding system points the
305    /// node's `ImageNode` at the texture the app registered under this name (or a
306    /// transparent placeholder until it appears). Pure-serde, Bevy-free.
307    #[serde(default)]
308    pub target: Option<String>,
309
310    // --- `editableText` element attributes ---
311    /// The controlled text value of an `editableText`. Seeds the field on create;
312    /// on update it's pushed into the widget only when it diverges from the live
313    /// buffer (so normal typing is never clobbered — see [`crate::reconcile`]).
314    #[serde(default)]
315    pub value: Option<String>,
316    /// Maximum number of characters an `editableText` accepts.
317    #[serde(default)]
318    pub max_length: Option<usize>,
319    /// Whether an `editableText` accepts newlines (multi-line input).
320    #[serde(default)]
321    pub multiline: bool,
322    /// Whether this element has an `onChange` handler registered in JS.
323    #[serde(default)]
324    pub on_change: bool,
325    /// Focus an `editableText` when it mounts (inserts `AutoFocus`).
326    #[serde(default)]
327    pub autofocus: bool,
328    /// Controlled selection anchor, a UTF-8 **byte** offset into the value.
329    /// When `selection_start`/`selection_end` diverge from the live selection
330    /// they're pushed into the widget (see [`crate::reconcile`]).
331    #[serde(default)]
332    pub selection_start: Option<usize>,
333    /// Controlled selection focus, a UTF-8 **byte** offset into the value.
334    #[serde(default)]
335    pub selection_end: Option<usize>,
336    /// Accessible name announced to assistive tech (sets the a11y node's label).
337    #[serde(default)]
338    pub aria_label: Option<String>,
339    /// Whether this element has an `onSelect` handler registered in JS.
340    #[serde(default)]
341    pub on_select: bool,
342    /// Whether this element has an `onFocus` handler registered in JS.
343    #[serde(default)]
344    pub on_focus: bool,
345    /// Whether this element has an `onBlur` handler registered in JS.
346    #[serde(default)]
347    pub on_blur: bool,
348}
349
350/// The `canvas` display-list command type. It lives in the [`crate::canvas`]
351/// module (which owns the host element and its rasterizer), and is re-exported here
352/// so it stays reachable as `protocol::DrawCmd` and so [`Props::draw`] can name it.
353pub use crate::canvas::DrawCmd;
354
355/// The [`Style::cache`] keyword: `"auto"` (default) leaves promotion to the
356/// other rules; `"always"` force-promotes the subtree to a cached composited
357/// layer; `"never"` force-promotes it too but re-captures it **every frame** —
358/// the escape hatch for content whose pixels are written outside the dirt
359/// tracking's sight (a live `<portal>` render target, an app-owned texture).
360/// Opting out of *opacity* promotion is `groupAlpha: false`.
361#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
362pub enum LayerCache {
363    #[default]
364    Auto,
365    Always,
366    Never,
367}
368
369/// A CSS-like style object mapped onto `bevy_ui::Node` and its sibling visual
370/// components. Every field is optional; unset fields keep Bevy's defaults.
371///
372/// Length-valued fields accept a bare number (logical pixels) or a unit string
373/// (`"50%"`, `"100vw"`, `"auto"`, `"10px"`). Rect-valued fields
374/// (`margin`/`padding`/`border`/`borderRadius`) accept a number (uniform), a CSS
375/// shorthand string (`"8px 16px"`), or a `{ top, right, bottom, left }` object.
376/// Keyword-valued fields (`display`, `align*`, `flex*`, …) decode straight into
377/// the `bevy_ui`/`bevy_text` enum they drive (see the `keyword_fields!`
378/// deserializers below); an unrecognized keyword warns and falls back to the
379/// bevy default. Grid tracks/placements likewise parse once at decode.
380#[derive(Debug, Clone, Default, Deserialize)]
381#[serde(rename_all = "camelCase")]
382pub struct Style {
383    // --- display / box model ---
384    #[serde(default, deserialize_with = "de_display")]
385    pub display: Option<Display>,
386    #[serde(default, deserialize_with = "de_box_sizing")]
387    pub box_sizing: Option<BoxSizing>,
388    #[serde(default, deserialize_with = "de_position_type")]
389    pub position_type: Option<PositionType>,
390    #[serde(default, deserialize_with = "de_overflow_axis")]
391    pub overflow_x: Option<OverflowAxis>,
392    #[serde(default, deserialize_with = "de_overflow_axis")]
393    pub overflow_y: Option<OverflowAxis>,
394    #[serde(default)]
395    pub scrollbar_width: Option<f32>,
396
397    // --- inset ---
398    #[serde(default)]
399    pub left: Option<Animatable<Length>>,
400    #[serde(default)]
401    pub right: Option<Animatable<Length>>,
402    #[serde(default)]
403    pub top: Option<Animatable<Length>>,
404    #[serde(default)]
405    pub bottom: Option<Animatable<Length>>,
406
407    // --- size ---
408    #[serde(default)]
409    pub width: Option<Animatable<Length>>,
410    #[serde(default)]
411    pub height: Option<Animatable<Length>>,
412    #[serde(default)]
413    pub min_width: Option<Animatable<Length>>,
414    #[serde(default)]
415    pub min_height: Option<Animatable<Length>>,
416    #[serde(default)]
417    pub max_width: Option<Animatable<Length>>,
418    #[serde(default)]
419    pub max_height: Option<Animatable<Length>>,
420    #[serde(default)]
421    pub aspect_ratio: Option<Animatable<f32>>,
422
423    // --- alignment ---
424    #[serde(default, deserialize_with = "de_align_items")]
425    pub align_items: Option<AlignItems>,
426    #[serde(default, deserialize_with = "de_justify_items")]
427    pub justify_items: Option<JustifyItems>,
428    #[serde(default, deserialize_with = "de_align_self")]
429    pub align_self: Option<AlignSelf>,
430    #[serde(default, deserialize_with = "de_justify_self")]
431    pub justify_self: Option<JustifySelf>,
432    #[serde(default, deserialize_with = "de_align_content")]
433    pub align_content: Option<AlignContent>,
434    #[serde(default, deserialize_with = "de_justify_content")]
435    pub justify_content: Option<JustifyContent>,
436
437    // --- spacing ---
438    #[serde(default)]
439    pub margin: Option<Rect>,
440    #[serde(default)]
441    pub padding: Option<Rect>,
442    #[serde(default)]
443    pub border: Option<Rect>,
444
445    // --- flex ---
446    #[serde(default, deserialize_with = "de_flex_direction")]
447    pub flex_direction: Option<FlexDirection>,
448    #[serde(default, deserialize_with = "de_flex_wrap")]
449    pub flex_wrap: Option<FlexWrap>,
450    #[serde(default)]
451    pub flex_grow: Option<f32>,
452    #[serde(default)]
453    pub flex_shrink: Option<f32>,
454    #[serde(default)]
455    pub flex_basis: Option<Animatable<Length>>,
456    #[serde(default)]
457    pub gap: Option<Animatable<Length>>,
458    #[serde(default)]
459    pub row_gap: Option<Animatable<Length>>,
460    #[serde(default)]
461    pub column_gap: Option<Animatable<Length>>,
462
463    // --- grid ---
464    #[serde(default, deserialize_with = "de_grid_auto_flow")]
465    pub grid_auto_flow: Option<GridAutoFlow>,
466    /// CSS grid template (`"repeat(3, 1fr)"`, `"1fr 2fr 100px"`, `"auto"`).
467    #[serde(default, deserialize_with = "de_grid_template")]
468    pub grid_template_rows: Option<Vec<RepeatedGridTrack>>,
469    #[serde(default, deserialize_with = "de_grid_template")]
470    pub grid_template_columns: Option<Vec<RepeatedGridTrack>>,
471    /// Auto-track sizing (`grid-auto-rows`/`columns`); no `repeat()`.
472    #[serde(default, deserialize_with = "de_grid_auto_tracks")]
473    pub grid_auto_rows: Option<Vec<GridTrack>>,
474    #[serde(default, deserialize_with = "de_grid_auto_tracks")]
475    pub grid_auto_columns: Option<Vec<GridTrack>>,
476    /// Grid line placement (`"1 / 3"`, `"span 2"`, `"2"`, `"auto"`).
477    #[serde(default, deserialize_with = "de_grid_placement")]
478    pub grid_row: Option<GridPlacement>,
479    #[serde(default, deserialize_with = "de_grid_placement")]
480    pub grid_column: Option<GridPlacement>,
481
482    // --- visual (sibling components) ---
483    /// Hex background color (`#rrggbb` / `#rrggbbaa`). Animated via an
484    /// `interpolateColor` binding (`{ animated: … }`).
485    #[serde(default)]
486    pub background_color: Option<Animatable<String>>,
487    /// Border color: a single CSS color (all four sides) or a
488    /// `{ top, right, bottom, left }` object (omitted sides → transparent).
489    /// Only the single-color form is animatable (the binding drives all four
490    /// sides); per-side `{ animated }` wrappers warn and are ignored.
491    #[serde(default)]
492    pub border_color: Option<Animatable<BorderColorSpec>>,
493    /// Corner radii; same forms as the other rect fields (corners are
494    /// top-left, top-right, bottom-right, bottom-left).
495    #[serde(default)]
496    pub border_radius: Option<Rect>,
497    #[serde(default)]
498    pub outline: Option<OutlineSpec>,
499    #[serde(default)]
500    pub box_shadow: Option<BoxShadowList>,
501    /// Layer-based, subtree-wide `filter` chain (see [`crate::filters`]): one
502    /// `{ name, params }` object (a 1-element chain) or an ordered array of
503    /// them (chain order = pass order). Omitted params take the filter's
504    /// CSS-shorthand default (a bare `grayscale` is *full* grayscale, while
505    /// `brightness`/`contrast`/`saturate` default to identity). `params`
506    /// stays an untyped map at decode; it is validated later against the
507    /// registered filters
508    /// ([`FilterRegistry`](crate::filters::FilterRegistry)). A non-empty
509    /// chain promotes the node to a composited layer (see [`crate::layer`]);
510    /// hover/press/focus variants carry the field too (with a
511    /// [`transition`](Self::transition) the swap eases — see
512    /// `crate::filters`). A chain carried *only* by a variant still promotes
513    /// eagerly at mount — promotion is presence-based across the base style
514    /// and every variant, so the layer exists before the first hover.
515    #[serde(default)]
516    pub filter: Option<crate::filters::FilterChain>,
517    /// Layer-based `backdropFilter` chain — same wire shape as
518    /// [`filter`](Self::filter) (one `{ name, params }` or an ordered array,
519    /// validated against the same registry), but it filters what is rendered
520    /// *behind* the node (v1: the camera's post-processed 3D frame — no UI)
521    /// and draws the result as an opaque quad under the node's own content.
522    /// A non-empty chain promotes (presence union across base + variants,
523    /// like `filter`). Unsetting it demotes, so an eased removal needs an
524    /// identity entry left in the base chain (same snap rule as `filter`).
525    #[serde(default)]
526    pub backdrop_filter: Option<crate::filters::FilterChain>,
527    /// Background gradient(s); one gradient or a layered list. bevy paints it
528    /// *over* `backgroundColor` (CSS `background-image` semantics): an opaque
529    /// gradient hides the color (fallback); transparent stops reveal it.
530    #[serde(default)]
531    pub background_gradient: Option<GradientList>,
532    /// Border gradient(s); one gradient or a layered list. Painted *over*
533    /// `borderColor` (needs a `border` width to be visible).
534    #[serde(default)]
535    pub border_gradient: Option<GradientList>,
536    #[serde(default)]
537    pub z_index: Option<i32>,
538    /// Global stacking order: lifts the node (and its subtree) into the UI's
539    /// top-level stack, escaping the parent stacking context. Unlike [`z_index`](Self::z_index),
540    /// which only reorders a node among its siblings.
541    #[serde(default)]
542    pub global_z_index: Option<i32>,
543    /// Pointer pass-through. Maps to `bevy::ui::FocusPolicy`. `"pass"` lets pointer
544    /// interaction fall through to nodes behind this one; `"block"` makes it
545    /// *capture* interaction so siblings, the 3D scene, and portals behind it don't
546    /// receive it. When unset the default is element-dependent (set in the
547    /// reconciler): a `<button>` blocks, a `<node>`/container passes.
548    #[serde(default, deserialize_with = "de_focus_policy")]
549    pub focus_policy: Option<FocusPolicy>,
550    /// Mouse cursor shown while the pointer is over this node (CSS `cursor`).
551    /// A system keyword (winit's `SystemCursorIcon`) or a custom-cursor name
552    /// registered via `ReactUiPlugin::cursor`; the name is resolved (registry first,
553    /// so a custom cursor can override a system keyword) onto the window's
554    /// `CursorIcon` by `crate::cursor::drive_cursor_icon`. Like `font_family`, a raw
555    /// name resolved at drive time. Absent → the node contributes no cursor (its
556    /// ancestor's or the default arrow shows).
557    #[serde(default)]
558    pub cursor: Option<String>,
559
560    // --- transform / opacity (drive `UiTransform` and color alpha) ---
561    /// Static transform (translate/scale/rotate). Mirrors the animated transform
562    /// channels; written to `UiTransform`. With a [`transition`](Self::transition)
563    /// a change eases instead of snapping.
564    #[serde(default)]
565    pub transform: Option<Transform>,
566    /// 3D perspective transform, applied to the subtree's *rendered result* at
567    /// composite time (group semantics, like `opacity`/`filter`). Presence —
568    /// even an identity `{}` — promotes the subtree to a composited layer (see
569    /// [`crate::layer`]); the captured texture is drawn as one quad through the
570    /// matrix, so animating it never re-captures. Unlike `transform` (which
571    /// stays main-world and bakes into the capture), this never touches layout,
572    /// and ancestor clips clamp the transformed result. With a
573    /// [`transition`](Self::transition) a change eases field-wise.
574    #[serde(default)]
575    pub transform3d: Option<Transform3d>,
576    /// Opacity in `0.0..=1.0`, multiplied into the alpha of the background (and
577    /// text) color. With a [`transition`](Self::transition) a change eases.
578    /// On a node with children (unless [`group_alpha`](Self::group_alpha) is
579    /// `false`) the subtree is instead promoted to a composited layer and the
580    /// value applies once to the whole group — see [`crate::layer`].
581    #[serde(default)]
582    pub opacity: Option<Animatable<f32>>,
583    /// Whether `opacity` on a node with children fades the subtree as a group
584    /// (composited layer) rather than folding into each node's own colors.
585    /// Default `true` (web semantics); `false` opts out of layer promotion for
586    /// perf-sensitive spots, keeping the per-node fold. `no_overlay`: a hover/
587    /// press variant must not be able to flip promotion.
588    #[serde(default)]
589    pub group_alpha: Option<bool>,
590    /// Layer-cache hint. `"always"` force-promotes the subtree to a composited
591    /// layer (see [`crate::layer`]) so its capture is cached and re-rendered
592    /// only when its content changes — the `will-change` pattern for static
593    /// or transform/opacity-animated subtrees. `"never"` also force-promotes,
594    /// but the capture re-runs **every frame** — for content written outside
595    /// the dirt tracking's sight (live `<portal>` targets, app-owned textures).
596    /// `"auto"` (or absent, the default) promotes only when another rule does
597    /// (today: `opacity`). `no_overlay`: a variant must not flip promotion.
598    #[serde(default, deserialize_with = "de_layer_cache")]
599    pub cache: Option<LayerCache>,
600    /// CSS-like per-channel transition timing. Present → a change to `transform` /
601    /// `opacity` / `backgroundColor` (via re-render or hover/press) animates over
602    /// time using the same driver/easing engine as `{ animated }` bindings, rather than
603    /// snapping. See [`crate::transition`].
604    #[serde(default)]
605    pub transition: Option<crate::transition::Transition>,
606
607    /// Visible scrollbar for an `overflow: scroll` node: `"none"` (default) /
608    /// `"default"` / a styled object. Present → the reconciler stamps a
609    /// [`crate::scrollbar::ScrollbarConfig`] and the shell spawns Bevy's headless
610    /// scrollbar widget over the container. Pure-serde, module-owned.
611    #[serde(default)]
612    pub scrollbar: Option<crate::scrollbar::ScrollbarSpec>,
613
614    // --- text (only meaningful on `<text>` elements/spans) ---
615    /// Hex text color. Animated via an `interpolateColor` binding.
616    #[serde(default)]
617    pub color: Option<Animatable<String>>,
618    /// Font size: a number (logical pixels) or a unit string (`"24px"`, `"2vw"`,
619    /// `"1.5rem"`). See [`FontSize`].
620    #[serde(default)]
621    pub font_size: Option<FontSize>,
622    /// `"thin" | "light" | "normal" | "medium" | "semibold" | "bold" | "black"`
623    /// or a numeric weight string (e.g. `"600"`).
624    #[serde(default, deserialize_with = "de_font_weight")]
625    pub font_weight: Option<FontWeight>,
626    /// Registered font-family name to render this text with (see the plugin's
627    /// `default_font`/`font` config). Unknown or unset → the configured default
628    /// font.
629    #[serde(default)]
630    pub font_family: Option<String>,
631    /// Horizontal alignment of the text block (`<text>` root only):
632    /// `"left" | "center" | "right" | "justify" | "start" | "end"`.
633    #[serde(default, deserialize_with = "de_text_align")]
634    pub text_align: Option<Justify>,
635    /// Line height. A bare number is a multiple of the font size; `{ "px": n }`
636    /// is an absolute pixel height. Unset → bevy's default (1.2× the font size).
637    #[serde(default)]
638    pub line_height: Option<LineHeightSpec>,
639    /// Letter spacing. A bare number is logical pixels; `{ "rem": n }` is a
640    /// multiple of the font size. Unset → no extra spacing.
641    #[serde(default)]
642    pub letter_spacing: Option<LetterSpacingSpec>,
643    /// A single drop shadow behind the text (`<text>` root only).
644    #[serde(default)]
645    pub text_shadow: Option<TextShadowSpec>,
646    /// How the text wraps when it overflows its bounds (`<text>` root only):
647    /// `"wordBoundary"` (default) | `"anyCharacter"` | `"wordOrCharacter"` |
648    /// `"noWrap"`.
649    #[serde(default, deserialize_with = "de_line_break")]
650    pub line_break: Option<LineBreak>,
651}
652
653/// Bit flags naming the groups of work [`crate::ui_map::apply_style`] (and the
654/// update reconciler) derive from a [`Style`]. Each [`Style`] field belongs to
655/// the group(s) whose output reads it (see [`with_style_fields`]); a delta
656/// update ORs the groups of its touched fields into a [`StyleDirty`] mask so
657/// the apply path can skip every group the delta provably didn't affect.
658pub mod style_groups {
659    /// `bevy_ui::Node` (`node_from_style`): every layout field.
660    pub const LAYOUT: u32 = 1 << 0;
661    /// `BackgroundColor` (reads `background_color`, `opacity`).
662    pub const BACKGROUND: u32 = 1 << 1;
663    /// `UiTransform` (reads `transform`).
664    pub const TRANSFORM: u32 = 1 << 2;
665    /// `BorderColor`.
666    pub const BORDER_COLOR: u32 = 1 << 3;
667    /// `Outline`.
668    pub const OUTLINE: u32 = 1 << 4;
669    /// `BoxShadow`.
670    pub const BOX_SHADOW: u32 = 1 << 5;
671    /// `BackgroundGradient` (reads `background_gradient`, `opacity`).
672    pub const BG_GRADIENT: u32 = 1 << 6;
673    /// `BorderGradient` (reads `border_gradient`, `opacity`).
674    pub const BORDER_GRADIENT: u32 = 1 << 7;
675    /// `TextShadow` (reads `text_shadow`, `opacity`).
676    pub const TEXT_SHADOW: u32 = 1 << 8;
677    /// `ZIndex`.
678    pub const Z_INDEX: u32 = 1 << 9;
679    /// `GlobalZIndex`.
680    pub const GLOBAL_Z_INDEX: u32 = 1 << 10;
681    /// `FocusPolicy` (also `apply_button_focus_default` in the reconciler).
682    pub const FOCUS_POLICY: u32 = 1 << 11;
683    /// The wire `filter` chain → `FilterInput` (the chain resolver's *and*
684    /// the transition filter channel's target; see `crate::filters`).
685    pub const FILTER: u32 = 1 << 12;
686    /// `TransitionInput` (`TransitionInput::from_style` reads `transition` plus
687    /// every transitioned channel: `transform`, `opacity`, `background_color`,
688    /// `width`, `height`, `max_width`, `max_height`). The filter channel's
689    /// timing rides the spec here; its *target* is `FilterInput` (FILTER).
690    pub const TRANSITION: u32 = 1 << 13;
691    /// `ScrollTransitionInput` (reads `transition`).
692    pub const SCROLL_TRANSITION: u32 = 1 << 14;
693    /// The resolved text style (`resolved_text_style`: `color`, `font_size`,
694    /// `font_weight`, `font_family`, `line_height`, `letter_spacing`,
695    /// `opacity`) — includes the `<text>` re-propagation to inheriting spans.
696    pub const TEXT: u32 = 1 << 15;
697    /// `TextLayout` (`text_layout`: `text_align`, `line_break`).
698    pub const TEXT_LAYOUT: u32 = 1 << 16;
699    /// `NodeCursor` (reads `cursor`) — the per-node cursor `drive_cursor_icon`
700    /// writes onto the window's `CursorIcon` on hover.
701    pub const CURSOR: u32 = 1 << 17;
702    /// `ScrollbarConfig` (reads `scrollbar`) — the visible scrollbar shell
703    /// (`crate::scrollbar`) spawns/updates Bevy's scrollbar widget from it. The
704    /// field is *also* in `LAYOUT` because a gutter-positioned bar drives
705    /// `Node.scrollbar_width` (see `node_from_style`).
706    pub const SCROLLBAR: u32 = 1 << 18;
707    /// Layer-promotion inputs (`crate::layer`): fields that change whether a
708    /// subtree composites as a layer (`opacity`, `group_alpha`, `cache`,
709    /// `filter`, `transform3d`). No `apply_style` output reads this group — it
710    /// exists so a delta touching a promotion trigger is visible to the
711    /// promotion evaluator.
712    pub const LAYER: u32 = 1 << 19;
713    /// `LayerTransform3d` (reads `transform3d`) — the composite-time 3D
714    /// transform on a promoted layer (`crate::layer::transform3d`). Never
715    /// content dirt: matrix changes reshape the composite quad only.
716    pub const TRANSFORM3D: u32 = 1 << 20;
717    /// The wire `backdropFilter` chain → `BackdropInput` (the backdrop chain
718    /// resolver's *and* the backdrop transition channel's target; see
719    /// `crate::filters::backdrop`). Composite-side only, like
720    /// [`Self::TRANSFORM3D`]: a backdrop delta re-stages the snapshot filter
721    /// run and reshapes nothing in the subtree — never content dirt.
722    pub const BACKDROP: u32 = 1 << 21;
723}
724
725/// The single source of truth for [`Style`]'s field list. Invokes the callback
726/// macro `$cb` once with one `(ident, "wireName", (group bits), overlay-flag)`
727/// entry per field:
728///
729/// - `ident` / `"wireName"`: the Rust field and its camelCase wire name.
730/// - `(group bits)`: the [`style_groups`] whose derived output reads the field.
731/// - `overlay` / `no_overlay`: whether `overlay_style` (hover/press/focus
732///   merging) carries the field. `focus_policy` is `no_overlay` so a variant
733///   can't silently toggle pointer capture; `group_alpha`/`cache` are
734///   `no_overlay` so interaction can never flip layer promotion. `filter` IS
735///   overlaid: the merged style simply re-stamps `FilterInput`, and promotion
736///   unions variant presence (see `crate::layer::promotion_reasons`), so a
737///   hover filter composites — and, with a `transition`, eases — without ever
738///   flipping the layer.
739///
740/// Consumers: `overlay_style` (ui_map), [`Style::overlay_delta`],
741/// [`Style::unset_field`], and the field-coverage test. Adding a `Style` field
742/// without extending this table is caught by `style_field_table_is_complete`.
743macro_rules! with_style_fields {
744    ($cb:ident) => {
745        $cb! {
746            (display, "display", (LAYOUT), overlay),
747            (box_sizing, "boxSizing", (LAYOUT), overlay),
748            (position_type, "positionType", (LAYOUT), overlay),
749            (overflow_x, "overflowX", (LAYOUT), overlay),
750            (overflow_y, "overflowY", (LAYOUT), overlay),
751            (scrollbar_width, "scrollbarWidth", (LAYOUT), overlay),
752            (left, "left", (LAYOUT), overlay),
753            (right, "right", (LAYOUT), overlay),
754            (top, "top", (LAYOUT), overlay),
755            (bottom, "bottom", (LAYOUT), overlay),
756            (width, "width", (LAYOUT | TRANSITION), overlay),
757            (height, "height", (LAYOUT | TRANSITION), overlay),
758            (min_width, "minWidth", (LAYOUT), overlay),
759            (min_height, "minHeight", (LAYOUT), overlay),
760            (max_width, "maxWidth", (LAYOUT | TRANSITION), overlay),
761            (max_height, "maxHeight", (LAYOUT | TRANSITION), overlay),
762            (aspect_ratio, "aspectRatio", (LAYOUT), overlay),
763            (align_items, "alignItems", (LAYOUT), overlay),
764            (justify_items, "justifyItems", (LAYOUT), overlay),
765            (align_self, "alignSelf", (LAYOUT), overlay),
766            (justify_self, "justifySelf", (LAYOUT), overlay),
767            (align_content, "alignContent", (LAYOUT), overlay),
768            (justify_content, "justifyContent", (LAYOUT), overlay),
769            (margin, "margin", (LAYOUT), overlay),
770            (padding, "padding", (LAYOUT), overlay),
771            (border, "border", (LAYOUT), overlay),
772            (flex_direction, "flexDirection", (LAYOUT), overlay),
773            (flex_wrap, "flexWrap", (LAYOUT), overlay),
774            (flex_grow, "flexGrow", (LAYOUT), overlay),
775            (flex_shrink, "flexShrink", (LAYOUT), overlay),
776            (flex_basis, "flexBasis", (LAYOUT), overlay),
777            (gap, "gap", (LAYOUT), overlay),
778            (row_gap, "rowGap", (LAYOUT), overlay),
779            (column_gap, "columnGap", (LAYOUT), overlay),
780            (grid_auto_flow, "gridAutoFlow", (LAYOUT), overlay),
781            (grid_template_rows, "gridTemplateRows", (LAYOUT), overlay),
782            (grid_template_columns, "gridTemplateColumns", (LAYOUT), overlay),
783            (grid_auto_rows, "gridAutoRows", (LAYOUT), overlay),
784            (grid_auto_columns, "gridAutoColumns", (LAYOUT), overlay),
785            (grid_row, "gridRow", (LAYOUT), overlay),
786            (grid_column, "gridColumn", (LAYOUT), overlay),
787            (background_color, "backgroundColor", (BACKGROUND | TRANSITION), overlay),
788            (border_color, "borderColor", (BORDER_COLOR), overlay),
789            (border_radius, "borderRadius", (LAYOUT), overlay),
790            (outline, "outline", (OUTLINE), overlay),
791            (box_shadow, "boxShadow", (BOX_SHADOW), overlay),
792            (filter, "filter", (FILTER | LAYER), overlay),
793            (backdrop_filter, "backdropFilter", (BACKDROP | LAYER), overlay),
794            (background_gradient, "backgroundGradient", (BG_GRADIENT), overlay),
795            (border_gradient, "borderGradient", (BORDER_GRADIENT), overlay),
796            (z_index, "zIndex", (Z_INDEX), overlay),
797            (global_z_index, "globalZIndex", (GLOBAL_Z_INDEX), overlay),
798            (focus_policy, "focusPolicy", (FOCUS_POLICY), no_overlay),
799            (cursor, "cursor", (CURSOR), overlay),
800            (scrollbar, "scrollbar", (SCROLLBAR | LAYOUT), overlay),
801            (
802                transform,
803                "transform",
804                (TRANSFORM | TRANSITION),
805                overlay
806            ),
807            (
808                transform3d,
809                "transform3d",
810                (TRANSFORM3D | LAYER | TRANSITION),
811                overlay
812            ),
813            (
814                opacity,
815                "opacity",
816                (BACKGROUND | BG_GRADIENT | BORDER_GRADIENT | TEXT_SHADOW | TRANSITION | TEXT
817                    | LAYER),
818                overlay
819            ),
820            (group_alpha, "groupAlpha", (LAYER), no_overlay),
821            (cache, "cache", (LAYER), no_overlay),
822            (
823                transition,
824                "transition",
825                (TRANSITION | SCROLL_TRANSITION),
826                overlay
827            ),
828            (color, "color", (TEXT), overlay),
829            (font_size, "fontSize", (TEXT), overlay),
830            (font_weight, "fontWeight", (TEXT), overlay),
831            (font_family, "fontFamily", (TEXT), overlay),
832            (text_align, "textAlign", (TEXT_LAYOUT), overlay),
833            (line_height, "lineHeight", (TEXT), overlay),
834            (letter_spacing, "letterSpacing", (TEXT), overlay),
835            (text_shadow, "textShadow", (TEXT_SHADOW), overlay),
836            (line_break, "lineBreak", (TEXT_LAYOUT), overlay),
837        }
838    };
839}
840pub(crate) use with_style_fields;
841
842/// Which [`style_groups`] a delta update touched. `ALL` (every bit set) is the
843/// full-reapply mask used by non-delta paths.
844#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
845pub struct StyleDirty(pub u32);
846
847impl StyleDirty {
848    /// Nothing dirty — every style group can be skipped.
849    pub const NONE: Self = Self(0);
850    /// Everything dirty — full re-apply (create, hover/press restyle).
851    pub const ALL: Self = Self(u32::MAX);
852
853    /// Whether any of `groups`' bits is dirty.
854    pub fn intersects(self, groups: u32) -> bool {
855        self.0 & groups != 0
856    }
857
858    /// Whether any style field at all was touched.
859    pub fn any(self) -> bool {
860        self.0 != 0
861    }
862}
863
864/// Which parts of a [`Props`] a delta update touched; drives which of the
865/// reconciler's `apply_*` helpers run. Style granularity lives in
866/// [`StyleDirty`]; the other flags are per prop group.
867#[derive(Debug, Clone, Copy, Default)]
868pub struct PropsDirty {
869    /// Style groups touched via `style` / `style_unset`.
870    pub style: StyleDirty,
871    /// `hoverStyle` set or unset.
872    pub hover_style: bool,
873    /// `pressStyle` set or unset.
874    pub press_style: bool,
875    /// `focusStyle` set or unset.
876    pub focus_style: bool,
877    /// Any of `onClick` / `onPointerDown|Move|Up|Enter|Leave` toggled.
878    pub pointer: bool,
879    /// `onScroll` toggled.
880    pub scroll_listener: bool,
881    /// `onWheel` toggled.
882    pub wheel: bool,
883    /// `scrollStep` changed.
884    pub scroll_step: bool,
885    /// `anchor` changed.
886    pub anchor: bool,
887    /// Any `image` attribute (`src`/`tint`/`flipX`/`flipY`/`imageMode`/
888    /// `sourceRect`/`atlas`/`visualBox`) changed.
889    pub image: bool,
890    /// `target` (portal/surface binding) changed.
891    pub target: bool,
892    /// Any `editableText` handler flag (`onChange`/`onSelect`/`onFocus`/
893    /// `onBlur`) toggled.
894    pub editable_handlers: bool,
895    /// `ariaLabel` changed.
896    pub aria_label: bool,
897}
898
899impl PropsDirty {
900    /// Whether the [`crate::bridge::StyleVariants`] component needs rebuilding:
901    /// its `base` mirrors `style`, so any style-field change counts too.
902    pub fn any_style_variant(&self) -> bool {
903        self.style.any() || self.hover_style || self.press_style || self.focus_style
904    }
905}
906
907/// The "act now" props of an update, split from the retained state: pushed
908/// into the live widget once and never stored, so an unrelated later delta
909/// can't replay them (re-push a controlled value, re-clone a canvas display
910/// list). Absent fields mean "no event", exactly like the pre-delta protocol.
911#[derive(Debug, Default)]
912pub struct UpdateEvents {
913    /// Controlled `editableText` value to push (when diverging).
914    pub value: Option<String>,
915    /// Controlled selection anchor (UTF-8 byte offset).
916    pub selection_start: Option<usize>,
917    /// Controlled selection focus (UTF-8 byte offset).
918    pub selection_end: Option<usize>,
919    /// Controlled vertical scroll offset.
920    pub scroll_top: Option<f32>,
921    /// Controlled horizontal scroll offset.
922    pub scroll_left: Option<f32>,
923    /// A `<canvas>` display list to clear + replay.
924    pub draw: Option<Vec<DrawCmd>>,
925}
926
927impl Style {
928    /// Overlay every `Some` field of `delta` onto `self` and return the OR of
929    /// the touched fields' [`style_groups`] bits. Unlike `overlay_style` this
930    /// carries **all** fields (including the `no_overlay`-tagged ones like
931    /// `focus_policy`): the delta is the app's own base style, not a hover
932    /// variant.
933    pub(crate) fn overlay_delta(&mut self, delta: &Style) -> u32 {
934        let mut groups = 0u32;
935        macro_rules! merge_field {
936            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
937                $(
938                    if delta.$f.is_some() {
939                        self.$f = delta.$f.clone();
940                        groups |= {
941                            use style_groups::*;
942                            $g
943                        };
944                    }
945                )*
946            };
947        }
948        with_style_fields!(merge_field);
949        groups
950    }
951
952    /// Clear the field named by `wire_name` (camelCase) and return its
953    /// [`style_groups`] bits, or `None` (after a `warn!`) for an unknown name.
954    pub(crate) fn unset_field(&mut self, wire_name: &str) -> Option<u32> {
955        macro_rules! unset_match {
956            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
957                match wire_name {
958                    $(
959                        $name => {
960                            self.$f = None;
961                            Some({
962                                use style_groups::*;
963                                $g
964                            })
965                        }
966                    )*
967                    _ => {
968                        tracing::warn!(
969                            target: "bevy_react",
970                            "unknown style field {wire_name:?} in styleUnset; ignoring"
971                        );
972                        None
973                    }
974                }
975            };
976        }
977        with_style_fields!(unset_match)
978    }
979}
980
981impl Props {
982    /// Iterate every present style slot: the base [`Self::style`] plus the
983    /// hover/press/focus variants, in that order. THE definition of "all
984    /// style slots" for presence-based unions (layer promotion's
985    /// opacity/filter reasons, the create-time layer-dirty seed) — a new
986    /// variant slot extends this once, not each call site.
987    pub fn all_styles(&self) -> impl Iterator<Item = &Style> {
988        [
989            &self.style,
990            &self.hover_style,
991            &self.press_style,
992            &self.focus_style,
993        ]
994        .into_iter()
995        .flatten()
996    }
997
998    /// Split the event-like fields (see [`UpdateEvents`]) out of `self`,
999    /// leaving the retained state. Used to seed the per-node props cache from
1000    /// a create.
1001    pub fn split_events(mut self) -> (Props, UpdateEvents) {
1002        let events = UpdateEvents {
1003            value: self.value.take(),
1004            selection_start: self.selection_start.take(),
1005            selection_end: self.selection_end.take(),
1006            scroll_top: self.scroll_top.take(),
1007            scroll_left: self.scroll_left.take(),
1008            draw: self.draw.take(),
1009        };
1010        (self, events)
1011    }
1012
1013    /// Merge an [`Op::Update`] delta (`props` + `unset` + `style_unset`) into
1014    /// `self` (the retained last-applied props), returning what the delta
1015    /// touched and the event-like fields to act on. See the semantics on
1016    /// [`Op::Update`].
1017    pub fn merge_delta(
1018        &mut self,
1019        delta: Props,
1020        unset: &[String],
1021        style_unset: &[String],
1022    ) -> (PropsDirty, UpdateEvents) {
1023        let mut dirty = PropsDirty::default();
1024        let (delta, events) = delta.split_events();
1025
1026        // --- set: fields present in the delta ---
1027        if let Some(style_delta) = &delta.style {
1028            let groups = self
1029                .style
1030                .get_or_insert_default()
1031                .overlay_delta(style_delta);
1032            dirty.style.0 |= groups;
1033        }
1034        if delta.hover_style.is_some() {
1035            self.hover_style = delta.hover_style;
1036            dirty.hover_style = true;
1037        }
1038        if delta.press_style.is_some() {
1039            self.press_style = delta.press_style;
1040            dirty.press_style = true;
1041        }
1042        if delta.focus_style.is_some() {
1043            self.focus_style = delta.focus_style;
1044            dirty.focus_style = true;
1045        }
1046        // Handler/flag booleans: the delta only ever carries `true` (a handler
1047        // appeared / a flag turned on); turning one off rides `unset`.
1048        macro_rules! merge_bool {
1049            ($($f:ident => $flag:ident),* $(,)?) => {
1050                $(
1051                    if delta.$f {
1052                        self.$f = true;
1053                        dirty.$flag = true;
1054                    }
1055                )*
1056            };
1057        }
1058        merge_bool!(
1059            on_click => pointer,
1060            on_pointer_down => pointer,
1061            on_pointer_move => pointer,
1062            on_pointer_up => pointer,
1063            on_pointer_enter => pointer,
1064            on_pointer_leave => pointer,
1065            on_scroll => scroll_listener,
1066            on_wheel => wheel,
1067            on_change => editable_handlers,
1068            on_select => editable_handlers,
1069            on_focus => editable_handlers,
1070            on_blur => editable_handlers,
1071            flip_x => image,
1072            flip_y => image,
1073        );
1074        // `multiline`/`autofocus` are create-time only; keep the cache true to
1075        // the props but no apply work keys off them.
1076        if delta.multiline {
1077            self.multiline = true;
1078        }
1079        if delta.autofocus {
1080            self.autofocus = true;
1081        }
1082        // `onResize` gates nothing Rust-side (resize events are unconditional);
1083        // cached only so the delta stays truthful.
1084        if delta.on_resize {
1085            self.on_resize = true;
1086        }
1087        macro_rules! merge_option {
1088            ($($f:ident => $($flag:ident)?),* $(,)?) => {
1089                $(
1090                    if delta.$f.is_some() {
1091                        self.$f = delta.$f;
1092                        $( dirty.$flag = true; )?
1093                    }
1094                )*
1095            };
1096        }
1097        merge_option!(
1098            scroll_step => scroll_step,
1099            anchor => anchor,
1100            src => image,
1101            tint => image,
1102            image_mode => image,
1103            source_rect => image,
1104            atlas => image,
1105            visual_box => image,
1106            target => target,
1107            aria_label => aria_label,
1108            max_length => , // create-time only, cached for completeness
1109        );
1110
1111        // --- unset: wire names reset to their defaults ---
1112        for name in unset {
1113            match name.as_str() {
1114                "style" => {
1115                    self.style = None;
1116                    dirty.style = StyleDirty::ALL;
1117                }
1118                "hoverStyle" => {
1119                    self.hover_style = None;
1120                    dirty.hover_style = true;
1121                }
1122                "pressStyle" => {
1123                    self.press_style = None;
1124                    dirty.press_style = true;
1125                }
1126                "focusStyle" => {
1127                    self.focus_style = None;
1128                    dirty.focus_style = true;
1129                }
1130                "onClick" => {
1131                    self.on_click = false;
1132                    dirty.pointer = true;
1133                }
1134                "onPointerDown" => {
1135                    self.on_pointer_down = false;
1136                    dirty.pointer = true;
1137                }
1138                "onPointerMove" => {
1139                    self.on_pointer_move = false;
1140                    dirty.pointer = true;
1141                }
1142                "onPointerUp" => {
1143                    self.on_pointer_up = false;
1144                    dirty.pointer = true;
1145                }
1146                "onPointerEnter" => {
1147                    self.on_pointer_enter = false;
1148                    dirty.pointer = true;
1149                }
1150                "onPointerLeave" => {
1151                    self.on_pointer_leave = false;
1152                    dirty.pointer = true;
1153                }
1154                "onScroll" => {
1155                    self.on_scroll = false;
1156                    dirty.scroll_listener = true;
1157                }
1158                "onWheel" => {
1159                    self.on_wheel = false;
1160                    dirty.wheel = true;
1161                }
1162                "onChange" => {
1163                    self.on_change = false;
1164                    dirty.editable_handlers = true;
1165                }
1166                "onSelect" => {
1167                    self.on_select = false;
1168                    dirty.editable_handlers = true;
1169                }
1170                "onFocus" => {
1171                    self.on_focus = false;
1172                    dirty.editable_handlers = true;
1173                }
1174                "onBlur" => {
1175                    self.on_blur = false;
1176                    dirty.editable_handlers = true;
1177                }
1178                "flipX" => {
1179                    self.flip_x = false;
1180                    dirty.image = true;
1181                }
1182                "flipY" => {
1183                    self.flip_y = false;
1184                    dirty.image = true;
1185                }
1186                "multiline" => self.multiline = false,
1187                "autofocus" => self.autofocus = false,
1188                "onResize" => self.on_resize = false,
1189                "scrollStep" => {
1190                    self.scroll_step = None;
1191                    dirty.scroll_step = true;
1192                }
1193                "anchor" => {
1194                    self.anchor = None;
1195                    dirty.anchor = true;
1196                }
1197                "src" => {
1198                    self.src = None;
1199                    dirty.image = true;
1200                }
1201                "tint" => {
1202                    self.tint = None;
1203                    dirty.image = true;
1204                }
1205                "imageMode" => {
1206                    self.image_mode = None;
1207                    dirty.image = true;
1208                }
1209                "sourceRect" => {
1210                    self.source_rect = None;
1211                    dirty.image = true;
1212                }
1213                "atlas" => {
1214                    self.atlas = None;
1215                    dirty.image = true;
1216                }
1217                "visualBox" => {
1218                    self.visual_box = None;
1219                    dirty.image = true;
1220                }
1221                "target" => {
1222                    self.target = None;
1223                    dirty.target = true;
1224                }
1225                "ariaLabel" => {
1226                    self.aria_label = None;
1227                    dirty.aria_label = true;
1228                }
1229                "maxLength" => self.max_length = None,
1230                // Event-like props have no retained state to unset; dropping
1231                // the prop simply stops producing events.
1232                "value" | "selectionStart" | "selectionEnd" | "scrollTop" | "scrollLeft"
1233                | "draw" => {
1234                    tracing::warn!(
1235                        target: "bevy_react",
1236                        "event-like prop {name:?} in unset; nothing to reset"
1237                    );
1238                }
1239                other => {
1240                    tracing::warn!(
1241                        target: "bevy_react",
1242                        "unknown prop {other:?} in unset; ignoring"
1243                    );
1244                }
1245            }
1246        }
1247
1248        // --- style_unset: after the overlay, so a (never-emitted) set+unset of
1249        // the same field resolves to unset ---
1250        if !style_unset.is_empty() {
1251            let style = self.style.get_or_insert_default();
1252            for name in style_unset {
1253                if let Some(groups) = style.unset_field(name) {
1254                    dirty.style.0 |= groups;
1255                }
1256            }
1257        }
1258
1259        (dirty, events)
1260    }
1261}
1262
1263/// Outline drawn around (outside) the node's border box.
1264#[derive(Debug, Clone, Default, Deserialize)]
1265#[serde(rename_all = "camelCase")]
1266pub struct OutlineSpec {
1267    #[serde(default)]
1268    pub width: Option<Length>,
1269    #[serde(default)]
1270    pub offset: Option<Length>,
1271    #[serde(default)]
1272    pub color: Option<String>,
1273}
1274
1275/// A single drop shadow.
1276#[derive(Debug, Clone, Default, Deserialize)]
1277#[serde(rename_all = "camelCase")]
1278pub struct BoxShadowSpec {
1279    #[serde(default)]
1280    pub color: Option<String>,
1281    #[serde(default)]
1282    pub x_offset: Option<Length>,
1283    #[serde(default)]
1284    pub y_offset: Option<Length>,
1285    #[serde(default)]
1286    pub spread_radius: Option<Length>,
1287    #[serde(default)]
1288    pub blur_radius: Option<Length>,
1289}
1290
1291/// A `boxShadow` value: one shadow or a stacked list (CSS `box-shadow: a, b, …`).
1292#[derive(Debug, Clone, Deserialize)]
1293#[serde(untagged)]
1294pub enum BoxShadowList {
1295    One(BoxShadowSpec),
1296    Many(Vec<BoxShadowSpec>),
1297}
1298
1299/// Line height for a `<text>`. A bare number is a multiple of the font size
1300/// (`RelativeToFont`); a string carries a unit (`"20px"` absolute, `"1.5"` / `"1.5em"`
1301/// a multiple); `{ "px": n }` is an absolute pixel height (legacy object form).
1302#[derive(Debug, Clone, Deserialize)]
1303#[serde(untagged)]
1304pub enum LineHeightSpec {
1305    Relative(f32),
1306    Px { px: f32 },
1307    Str(String),
1308}
1309
1310/// Letter spacing for a `<text>`. A bare number is logical pixels; a string carries
1311/// a unit (`"2px"`, `"0.1rem"`/`"0.1em"` for a font-size multiple, or `"normal"`);
1312/// `{ "rem": n }` is a multiple of the font size (legacy object form).
1313#[derive(Debug, Clone, Deserialize)]
1314#[serde(untagged)]
1315pub enum LetterSpacingSpec {
1316    Px(f32),
1317    Rem { rem: f32 },
1318    Str(String),
1319}
1320
1321/// A single text drop shadow. `offsetX`/`offsetY` are displacement in logical
1322/// pixels (absent → bevy's default of `4.0`); `color` defaults to bevy's
1323/// translucent black when unset.
1324#[derive(Debug, Clone, Default, Deserialize)]
1325#[serde(rename_all = "camelCase")]
1326pub struct TextShadowSpec {
1327    #[serde(default)]
1328    pub color: Option<String>,
1329    #[serde(default)]
1330    pub offset_x: Option<f32>,
1331    #[serde(default)]
1332    pub offset_y: Option<f32>,
1333}
1334
1335/// A single color stop for a linear/radial gradient. `position` is where the
1336/// color sits along the gradient line (a [`Length`]); absent → auto-spaced.
1337/// `hint` is the `0.0..=1.0` interpolation midpoint between this stop and the
1338/// next (default `0.5`).
1339#[derive(Debug, Clone, Deserialize)]
1340#[serde(rename_all = "camelCase")]
1341pub struct GradientStop {
1342    pub color: String,
1343    #[serde(default)]
1344    pub position: Option<Length>,
1345    #[serde(default)]
1346    pub hint: Option<f32>,
1347}
1348
1349/// A single color stop for a conic gradient. `angle` is the stop's angle in
1350/// **degrees** (absent → auto-spaced); `hint` as in [`GradientStop`].
1351#[derive(Debug, Clone, Deserialize)]
1352#[serde(rename_all = "camelCase")]
1353pub struct AngularStop {
1354    pub color: String,
1355    #[serde(default)]
1356    pub angle: Option<Angle>,
1357    #[serde(default)]
1358    pub hint: Option<f32>,
1359}
1360
1361/// Radial/conic gradient center, given as a named anchor (`"center"`, `"top"`,
1362/// `"topLeft"`, …). Arbitrary `Val`-offset centers are not yet supported.
1363pub type GradientPosition = String;
1364
1365/// Color space the gradient interpolates in (`"oklab"` (default), `"oklch"`,
1366/// `"oklchLong"`, `"srgb"`, `"linearRgb"`, `"hsl"`, `"hslLong"`, `"hsv"`,
1367/// `"hsvLong"`).
1368pub type ColorSpace = String;
1369
1370/// The size/shape of a radial gradient. Either a keyword
1371/// (`"closestSide" | "farthestSide" | "closestCorner" | "farthestCorner"`,
1372/// default `"closestCorner"`) or an explicit `{ circle }` / `{ ellipse }`.
1373#[derive(Debug, Clone, Deserialize)]
1374#[serde(rename_all = "camelCase")]
1375pub enum RadialShapeSpec {
1376    Keyword(String),
1377    Circle { circle: Length },
1378    Ellipse { ellipse: [Length; 2] },
1379}
1380
1381#[derive(Debug, Clone, Default, Deserialize)]
1382#[serde(rename_all = "camelCase")]
1383pub struct LinearGradientSpec {
1384    /// Gradient line angle (number = degrees, or a unit string; `0` = to top,
1385    /// increasing clockwise).
1386    #[serde(default)]
1387    pub angle: Option<Angle>,
1388    #[serde(default)]
1389    pub stops: Vec<GradientStop>,
1390    #[serde(default)]
1391    pub color_space: Option<ColorSpace>,
1392}
1393
1394#[derive(Debug, Clone, Default, Deserialize)]
1395#[serde(rename_all = "camelCase")]
1396pub struct RadialGradientSpec {
1397    #[serde(default)]
1398    pub position: Option<GradientPosition>,
1399    #[serde(default)]
1400    pub shape: Option<RadialShapeSpec>,
1401    #[serde(default)]
1402    pub stops: Vec<GradientStop>,
1403    #[serde(default)]
1404    pub color_space: Option<ColorSpace>,
1405}
1406
1407#[derive(Debug, Clone, Default, Deserialize)]
1408#[serde(rename_all = "camelCase")]
1409pub struct ConicGradientSpec {
1410    /// Start angle (number = degrees, or a unit string).
1411    #[serde(default)]
1412    pub start: Option<Angle>,
1413    #[serde(default)]
1414    pub position: Option<GradientPosition>,
1415    #[serde(default)]
1416    pub stops: Vec<AngularStop>,
1417    #[serde(default)]
1418    pub color_space: Option<ColorSpace>,
1419}
1420
1421/// One gradient, discriminated by its `type` field on the wire.
1422#[derive(Debug, Clone, Deserialize)]
1423#[serde(tag = "type", rename_all = "camelCase")]
1424pub enum GradientSpec {
1425    Linear(LinearGradientSpec),
1426    Radial(RadialGradientSpec),
1427    Conic(ConicGradientSpec),
1428}
1429
1430/// A `backgroundGradient`/`borderGradient` value: one gradient or a layered list.
1431#[derive(Debug, Clone, Deserialize)]
1432#[serde(untagged)]
1433pub enum GradientList {
1434    One(GradientSpec),
1435    Many(Vec<GradientSpec>),
1436}
1437
1438/// How an `image` fits its node. A bare string (`"auto"`/`"stretch"`) maps to the
1439/// trivial `bevy_ui` modes; the `type`-tagged object forms map to bevy's 9-slice
1440/// (`"sliced"`) and `"tiled"` scaling. Bevy-free; converted to `NodeImageMode` in
1441/// `ui_map`.
1442#[derive(Debug, Clone, Deserialize)]
1443#[serde(untagged)]
1444pub enum ImageMode {
1445    /// `"auto"` or `"stretch"` (any unknown keyword falls back to `Auto`).
1446    Keyword(String),
1447    Spec(ImageModeSpec),
1448}
1449
1450/// The object forms of [`ImageMode`], discriminated by their `type` field.
1451#[derive(Debug, Clone, Deserialize)]
1452#[serde(tag = "type", rename_all = "camelCase")]
1453pub enum ImageModeSpec {
1454    Sliced(SliceSpec),
1455    Tiled(TiledSpec),
1456}
1457
1458/// 9-slice scaling parameters, mirroring `bevy_sprite::TextureSlicer`.
1459#[derive(Debug, Clone, Default, Deserialize)]
1460#[serde(rename_all = "camelCase")]
1461pub struct SliceSpec {
1462    /// Border insets, in *source-texture pixels*, dividing the texture into nine
1463    /// sections.
1464    #[serde(default)]
1465    pub border: SliceBorder,
1466    /// How the center section scales (default: stretch).
1467    #[serde(default)]
1468    pub center_scale_mode: Option<SliceScale>,
1469    /// How the four side sections scale (default: stretch).
1470    #[serde(default)]
1471    pub sides_scale_mode: Option<SliceScale>,
1472    /// Maximum scale of the four corner sections (bevy default `1.0`).
1473    #[serde(default)]
1474    pub max_corner_scale: Option<f32>,
1475}
1476
1477/// 9-slice border insets: a single number (uniform) or per-side, in *source-texture
1478/// pixels*.
1479#[derive(Debug, Clone, Default, Deserialize)]
1480#[serde(untagged)]
1481pub enum SliceBorder {
1482    /// No border supplied → zero insets.
1483    #[default]
1484    Zero,
1485    /// The same inset along every edge.
1486    Uniform(f32),
1487    /// Per-edge insets.
1488    Sides {
1489        #[serde(default)]
1490        top: f32,
1491        #[serde(default)]
1492        right: f32,
1493        #[serde(default)]
1494        bottom: f32,
1495        #[serde(default)]
1496        left: f32,
1497    },
1498}
1499
1500/// How a 9-slice section scales when resized: `"stretch"` (the keyword) or
1501/// `{ tile }`, where `tile` is the repeat `stretch_value`.
1502#[derive(Debug, Clone, Deserialize)]
1503#[serde(untagged)]
1504pub enum SliceScale {
1505    Keyword(String),
1506    Tile { tile: f32 },
1507}
1508
1509/// `"tiled"` scaling: the whole image repeats once stretched beyond `stretch_value`.
1510#[derive(Debug, Clone, Default, Deserialize)]
1511#[serde(rename_all = "camelCase")]
1512pub struct TiledSpec {
1513    #[serde(default)]
1514    pub tile_x: bool,
1515    #[serde(default)]
1516    pub tile_y: bool,
1517    /// Repeat threshold (bevy default `1.0`).
1518    #[serde(default)]
1519    pub stretch_value: Option<f32>,
1520}
1521
1522/// A source sub-rect in texture pixels: top-left (`x`, `y`) plus `width`/`height`.
1523/// Converted to a `bevy_math::Rect` (min/max corners) in `ui_map`.
1524#[derive(Debug, Clone, Copy, Deserialize)]
1525#[serde(rename_all = "camelCase")]
1526pub struct SourceRect {
1527    pub x: f32,
1528    pub y: f32,
1529    pub width: f32,
1530    pub height: f32,
1531}
1532
1533/// A uniform sprite-sheet grid plus the selected cell. Mirrors
1534/// `TextureAtlasLayout::from_grid` (tile size, columns, rows, optional padding /
1535/// offset, all in source-texture pixels) + `TextureAtlas.index`. Bevy-free;
1536/// turned into a cached `TextureAtlasLayout` asset in `ui_map`.
1537#[derive(Debug, Clone, Deserialize)]
1538#[serde(rename_all = "camelCase")]
1539pub struct AtlasSpec {
1540    pub tile_width: u32,
1541    pub tile_height: u32,
1542    pub columns: u32,
1543    pub rows: u32,
1544    /// Padding between cells (`[x, y]` px), if any.
1545    #[serde(default)]
1546    pub padding: Option<[u32; 2]>,
1547    /// Offset of the grid's top-left from the texture origin (`[x, y]` px).
1548    #[serde(default)]
1549    pub offset: Option<[u32; 2]>,
1550    /// Which cell to display (row-major). Default `0`.
1551    #[serde(default)]
1552    pub index: usize,
1553}
1554
1555/// A style field that is either a static `T` or an inline animation binding —
1556/// the `{ animated: <shared value | interpolate descriptor> }` wire form. The
1557/// animated variant carries **no static value**: read sites see the field as
1558/// absent (the animation applier drives the target every frame), and
1559/// `crate::animations` derives the node's `AnimatedBindings` from the merged
1560/// style. `{ animated: sv }` reaches the wire with the shared value's `id`
1561/// (its other enumerable props are ignored); a descriptor object is told apart
1562/// by its `type` tag. A malformed wrapper warns (`styleBinding`) and decodes
1563/// to an inert binding (shared id `0` is never allocated by JS), so one typo
1564/// can't abort the batch.
1565///
1566/// Filter/backdrop chain params never decode through this type (their param
1567/// maps stay raw); their wrappers additionally accept a static **`seed`**
1568/// (`{ animated: sv, seed: 10 }`) that the chain resolver decodes in the
1569/// wrapper's place — see `crate::style_bindings::animated_param_seed`.
1570#[derive(Debug, Clone, PartialEq)]
1571pub enum Animatable<T> {
1572    Static(T),
1573    Animated(crate::animations::protocol::Binding),
1574}
1575
1576impl<T> Animatable<T> {
1577    /// The static value; `None` while animated.
1578    pub fn value(&self) -> Option<&T> {
1579        match self {
1580            Animatable::Static(v) => Some(v),
1581            Animatable::Animated(_) => None,
1582        }
1583    }
1584
1585    /// The binding; `None` when static.
1586    pub fn binding(&self) -> Option<&crate::animations::protocol::Binding> {
1587        match self {
1588            Animatable::Static(_) => None,
1589            Animatable::Animated(b) => Some(b),
1590        }
1591    }
1592}
1593
1594/// Read helpers for the `Option<Animatable<T>>` style fields, so read sites
1595/// stay as terse as the plain `Option<T>` they replaced.
1596pub trait AnimatableField<T> {
1597    /// The static value by copy; `None` when absent **or** animated.
1598    fn static_val(&self) -> Option<T>
1599    where
1600        T: Copy;
1601    /// The static value by reference; `None` when absent or animated.
1602    fn static_ref(&self) -> Option<&T>;
1603    /// The binding; `None` when absent or static.
1604    fn binding(&self) -> Option<&crate::animations::protocol::Binding>;
1605}
1606
1607impl<T> AnimatableField<T> for Option<Animatable<T>> {
1608    fn static_val(&self) -> Option<T>
1609    where
1610        T: Copy,
1611    {
1612        self.static_ref().copied()
1613    }
1614    fn static_ref(&self) -> Option<&T> {
1615        self.as_ref().and_then(Animatable::value)
1616    }
1617    fn binding(&self) -> Option<&crate::animations::protocol::Binding> {
1618        self.as_ref().and_then(Animatable::binding)
1619    }
1620}
1621
1622impl<'de, T: de::DeserializeOwned> Deserialize<'de> for Animatable<T> {
1623    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1624        let v = serde_json::Value::deserialize(d)?;
1625        if let Some(inner) = v.as_object().and_then(|m| m.get("animated")) {
1626            return Ok(Animatable::Animated(binding_from_wrapper(inner)));
1627        }
1628        T::deserialize(v)
1629            .map(Animatable::Static)
1630            .map_err(de::Error::custom)
1631    }
1632}
1633
1634/// Decode the payload of an `{ animated: … }` wrapper: a descriptor object
1635/// (tagged by `type`) decodes as a [`Binding`](crate::animations::protocol::Binding);
1636/// a bare shared value is recognized by its numeric `id` (every other
1637/// enumerable field of the JS handle is ignored). Malformed → warn + inert.
1638pub(crate) fn binding_from_wrapper(
1639    inner: &serde_json::Value,
1640) -> crate::animations::protocol::Binding {
1641    use crate::animations::protocol::Binding;
1642    let inert = Binding::Shared { id: 0 };
1643    let Some(map) = inner.as_object() else {
1644        decode_warn(
1645            "styleBinding",
1646            &inner.to_string(),
1647            "animated must be a shared value or an interpolate/interpolateColor descriptor",
1648        );
1649        return inert;
1650    };
1651    if map.contains_key("type") {
1652        match Binding::deserialize(inner) {
1653            Ok(b) => b,
1654            Err(e) => {
1655                decode_warn("styleBinding", &inner.to_string(), &e.to_string());
1656                inert
1657            }
1658        }
1659    } else if let Some(id) = map.get("id").and_then(serde_json::Value::as_u64) {
1660        Binding::Shared { id: id as u32 }
1661    } else {
1662        decode_warn(
1663            "styleBinding",
1664            &inner.to_string(),
1665            "animated needs a shared value ({id}) or a descriptor ({type, id, …})",
1666        );
1667        inert
1668    }
1669}
1670
1671/// A static 2D transform mirroring the animated transform channels. Every field
1672/// is optional; unset channels stay at identity (no translation, unit scale, no
1673/// rotation). `scale` is uniform; `scaleX`/`scaleY` override a single axis.
1674#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1675#[serde(rename_all = "camelCase")]
1676pub struct Transform {
1677    /// Translation along x — a length (number = logical pixels, or a unit string
1678    /// like `"50%"`, resolved against the node's own size by `bevy_ui`).
1679    pub translate_x: Option<Animatable<Length>>,
1680    /// Translation along y — a length (number = logical pixels, or a unit string
1681    /// like `"50%"`).
1682    pub translate_y: Option<Animatable<Length>>,
1683    /// Uniform scale (both axes), unless overridden by `scale_x`/`scale_y`.
1684    pub scale: Option<Animatable<f32>>,
1685    pub scale_x: Option<Animatable<f32>>,
1686    pub scale_y: Option<Animatable<f32>>,
1687    /// Clockwise rotation (number = degrees, or a unit string like `"1.5rad"`).
1688    /// An animated binding's per-frame values are read as **degrees** too.
1689    pub rotate: Option<Animatable<Angle>>,
1690}
1691
1692/// The `transform3d` style: a 3D perspective transform composited onto a
1693/// promoted layer's quad (see [`Style::transform3d`]). Every field is optional;
1694/// unset channels stay at identity. Canonical application order around
1695/// [`origin`](Self::origin): scale → rotateX → rotateY → rotateZ → translate,
1696/// then the self-`perspective` projection.
1697#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1698#[serde(rename_all = "camelCase")]
1699pub struct Transform3d {
1700    /// Self-perspective focal distance in logical pixels (CSS
1701    /// `transform: perspective(d)`), the vanishing point at `origin`. Unset →
1702    /// orthographic (rotations foreshorten but nothing diverges with depth).
1703    pub perspective: Option<Animatable<f32>>,
1704    /// Translation along x in logical pixels.
1705    pub translate_x: Option<Animatable<f32>>,
1706    /// Translation along y in logical pixels.
1707    pub translate_y: Option<Animatable<f32>>,
1708    /// Translation along z in logical pixels. Positive is toward the viewer —
1709    /// visible only with `perspective`.
1710    pub translate_z: Option<Animatable<f32>>,
1711    /// Rotation about the x axis (number = degrees, or `"1.5rad"`).
1712    pub rotate_x: Option<Animatable<Angle>>,
1713    /// Rotation about the y axis (number = degrees, or `"1.5rad"`).
1714    pub rotate_y: Option<Animatable<Angle>>,
1715    /// Rotation about the z axis (number = degrees, or `"1.5rad"`).
1716    pub rotate_z: Option<Animatable<Angle>>,
1717    /// Uniform scale (x and y), unless overridden by `scale_x`/`scale_y`.
1718    pub scale: Option<Animatable<f32>>,
1719    pub scale_x: Option<Animatable<f32>>,
1720    pub scale_y: Option<Animatable<f32>>,
1721    /// Pivot for rotation/scale and the perspective vanishing point, relative
1722    /// to the node's border box. Defaults to the center (`50%`/`50%`).
1723    pub origin: Option<Transform3dOrigin>,
1724}
1725
1726impl Transform3d {
1727    /// Whether every channel is at identity (an empty `{}` or explicit identity
1728    /// values). Promotion is presence-based and ignores this; the render and
1729    /// picking paths use it to skip transform work entirely.
1730    pub fn is_identity(&self) -> bool {
1731        let Self {
1732            perspective,
1733            translate_x,
1734            translate_y,
1735            translate_z,
1736            rotate_x,
1737            rotate_y,
1738            rotate_z,
1739            scale,
1740            scale_x,
1741            scale_y,
1742            origin: _, // the pivot of an identity transform is irrelevant
1743        } = self;
1744        // An animated channel is never identity: the binding drives it to
1745        // arbitrary values each frame (`static_val()` is `None`, so the
1746        // `unwrap_or(identity)` shortcut would wrongly report identity).
1747        let no_binding = |f: &Option<Animatable<f32>>| f.binding().is_none();
1748        let no_angle_binding = |f: &Option<Animatable<Angle>>| f.binding().is_none();
1749        perspective.is_none()
1750            && [
1751                translate_x,
1752                translate_y,
1753                translate_z,
1754                scale,
1755                scale_x,
1756                scale_y,
1757            ]
1758            .iter()
1759            .all(|f| no_binding(f))
1760            && [rotate_x, rotate_y, rotate_z]
1761                .iter()
1762                .all(|f| no_angle_binding(f))
1763            && translate_x.static_val().unwrap_or(0.0) == 0.0
1764            && translate_y.static_val().unwrap_or(0.0) == 0.0
1765            && translate_z.static_val().unwrap_or(0.0) == 0.0
1766            && rotate_x.static_val().unwrap_or_default().radians() == 0.0
1767            && rotate_y.static_val().unwrap_or_default().radians() == 0.0
1768            && rotate_z.static_val().unwrap_or_default().radians() == 0.0
1769            && scale.static_val().unwrap_or(1.0) == 1.0
1770            && scale_x.static_val().unwrap_or(1.0) == 1.0
1771            && scale_y.static_val().unwrap_or(1.0) == 1.0
1772    }
1773}
1774
1775/// The `transform3d` pivot: a per-axis [`Length`] resolved against the node's
1776/// border box (`"50%"` = center, a number = logical pixels from the top-left).
1777#[derive(Debug, Clone, PartialEq, Deserialize)]
1778pub struct Transform3dOrigin {
1779    pub x: Animatable<Length>,
1780    pub y: Animatable<Length>,
1781}
1782
1783impl Default for Transform3dOrigin {
1784    fn default() -> Self {
1785        Self {
1786            x: Animatable::Static(Length::Percent(50.0)),
1787            y: Animatable::Static(Length::Percent(50.0)),
1788        }
1789    }
1790}
1791
1792/// A length value mirroring `bevy_ui::Val`, parsed from the wire form (a number
1793/// is logical pixels; a string carries an explicit unit).
1794#[derive(Debug, Clone, Copy, PartialEq)]
1795pub enum Length {
1796    Auto,
1797    Px(f32),
1798    Percent(f32),
1799    Vw(f32),
1800    Vh(f32),
1801    VMin(f32),
1802    VMax(f32),
1803}
1804
1805impl Default for Length {
1806    fn default() -> Self {
1807        Length::Px(0.0)
1808    }
1809}
1810
1811/// Parse a CSS-ish length token (`"auto"`, `"10px"`, `"50%"`, `"100vw"`, `"5"`).
1812fn parse_length(s: &str) -> Result<Length, String> {
1813    let s = s.trim();
1814    if s.eq_ignore_ascii_case("auto") {
1815        return Ok(Length::Auto);
1816    }
1817    // `vmin`/`vmax` before `vw`/`vh` is unnecessary (suffixes are distinct), but
1818    // `%` is checked last so numeric parsing handles the bare-number case.
1819    type LengthCtor = fn(f32) -> Length;
1820    let units: [(&str, LengthCtor); 6] = [
1821        ("px", Length::Px),
1822        ("vmin", Length::VMin),
1823        ("vmax", Length::VMax),
1824        ("vw", Length::Vw),
1825        ("vh", Length::Vh),
1826        ("%", Length::Percent),
1827    ];
1828    for (suffix, ctor) in units {
1829        if let Some(num) = s.strip_suffix(suffix) {
1830            let v: f32 = num
1831                .trim()
1832                .parse()
1833                .map_err(|_| format!("invalid length {s:?}"))?;
1834            return Ok(ctor(v));
1835        }
1836    }
1837    s.parse::<f32>()
1838        .map(Length::Px)
1839        .map_err(|_| format!("invalid length {s:?}"))
1840}
1841
1842impl<'de> Deserialize<'de> for Length {
1843    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1844        struct LengthVisitor;
1845        impl<'de> Visitor<'de> for LengthVisitor {
1846            type Value = Length;
1847            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1848                f.write_str("a number (logical pixels) or a CSS length string")
1849            }
1850            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Length, E> {
1851                Ok(Length::Px(v as f32))
1852            }
1853            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Length, E> {
1854                Ok(Length::Px(v as f32))
1855            }
1856            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Length, E> {
1857                Ok(Length::Px(v as f32))
1858            }
1859            fn visit_str<E: de::Error>(self, s: &str) -> Result<Length, E> {
1860                Ok(parse_length(s).unwrap_or_else(|e| {
1861                    decode_warn("length", s, &e);
1862                    Length::default()
1863                }))
1864            }
1865        }
1866        d.deserialize_any(LengthVisitor)
1867    }
1868}
1869
1870/// An angle, parsed from the wire as a number (read as **degrees**, the CSS
1871/// convention) or a unit string (`"45deg"`, `"1.5rad"`, `"0.25turn"`, `"100grad"`).
1872/// Stored internally as radians — the unit Bevy's gradient and transform APIs want.
1873#[derive(Debug, Clone, Copy, PartialEq, Default)]
1874pub struct Angle(f32);
1875
1876impl Angle {
1877    /// This angle in radians.
1878    pub fn radians(self) -> f32 {
1879        self.0
1880    }
1881
1882    /// An angle from radians (the internal unit) — the write half of
1883    /// [`Self::radians`], for engine code re-emitting eased values.
1884    pub fn from_radians(radians: f32) -> Self {
1885        Angle(radians)
1886    }
1887}
1888
1889/// Parse a CSS angle token into radians. A bare number is degrees; a suffix of
1890/// `deg`/`grad`/`turn`/`rad` selects the unit (`grad` is matched before `rad`
1891/// since `"100grad"` also ends in `"rad"`).
1892fn parse_angle(s: &str) -> Result<f32, String> {
1893    use std::f32::consts::{PI, TAU};
1894    let s = s.trim();
1895    type AngleConv = fn(f32) -> f32;
1896    let units: [(&str, AngleConv); 4] = [
1897        ("deg", f32::to_radians),
1898        ("grad", |v| v * PI / 200.0),
1899        ("turn", |v| v * TAU),
1900        ("rad", |v| v),
1901    ];
1902    for (suffix, conv) in units {
1903        if let Some(num) = s.strip_suffix(suffix) {
1904            let v: f32 = num
1905                .trim()
1906                .parse()
1907                .map_err(|_| format!("invalid angle {s:?}"))?;
1908            return Ok(conv(v));
1909        }
1910    }
1911    s.parse::<f32>()
1912        .map(f32::to_radians)
1913        .map_err(|_| format!("invalid angle {s:?}"))
1914}
1915
1916impl<'de> Deserialize<'de> for Angle {
1917    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1918        struct AngleVisitor;
1919        impl Visitor<'_> for AngleVisitor {
1920            type Value = Angle;
1921            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1922                f.write_str("a number (degrees) or a CSS angle string")
1923            }
1924            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Angle, E> {
1925                Ok(Angle((v as f32).to_radians()))
1926            }
1927            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Angle, E> {
1928                Ok(Angle((v as f32).to_radians()))
1929            }
1930            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Angle, E> {
1931                Ok(Angle((v as f32).to_radians()))
1932            }
1933            fn visit_str<E: de::Error>(self, s: &str) -> Result<Angle, E> {
1934                Ok(parse_angle(s).map(Angle).unwrap_or_else(|e| {
1935                    decode_warn("angle", s, &e);
1936                    Angle::default()
1937                }))
1938            }
1939        }
1940        d.deserialize_any(AngleVisitor)
1941    }
1942}
1943
1944/// A time/duration, parsed from the wire as a number (read as **milliseconds**,
1945/// the JS-facing unit) or a unit string (`"200ms"`, `"0.2s"`). Stored as seconds —
1946/// the unit the animations engine and the transition driver consume.
1947#[derive(Debug, Clone, Copy, PartialEq, Default)]
1948pub struct Time(f32);
1949
1950impl Time {
1951    /// Construct from a value already in seconds.
1952    pub fn from_secs(secs: f32) -> Self {
1953        Time(secs)
1954    }
1955    /// This duration in seconds.
1956    pub fn seconds(self) -> f32 {
1957        self.0
1958    }
1959}
1960
1961/// Parse a CSS time token into seconds. A bare number is milliseconds; a suffix of
1962/// `ms`/`s` selects the unit (`ms` is matched before `s` since `"200ms"` also ends
1963/// in `"s"`).
1964fn parse_time(s: &str) -> Result<f32, String> {
1965    let s = s.trim();
1966    if let Some(num) = s.strip_suffix("ms") {
1967        return num
1968            .trim()
1969            .parse::<f32>()
1970            .map(|v| v / 1000.0)
1971            .map_err(|_| format!("invalid time {s:?}"));
1972    }
1973    if let Some(num) = s.strip_suffix('s') {
1974        return num
1975            .trim()
1976            .parse::<f32>()
1977            .map_err(|_| format!("invalid time {s:?}"));
1978    }
1979    s.parse::<f32>()
1980        .map(|v| v / 1000.0)
1981        .map_err(|_| format!("invalid time {s:?}"))
1982}
1983
1984impl<'de> Deserialize<'de> for Time {
1985    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1986        struct TimeVisitor;
1987        impl Visitor<'_> for TimeVisitor {
1988            type Value = Time;
1989            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1990                f.write_str("a number (milliseconds) or a CSS time string")
1991            }
1992            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Time, E> {
1993                Ok(Time(v as f32 / 1000.0))
1994            }
1995            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Time, E> {
1996                Ok(Time(v as f32 / 1000.0))
1997            }
1998            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Time, E> {
1999                Ok(Time(v as f32 / 1000.0))
2000            }
2001            fn visit_str<E: de::Error>(self, s: &str) -> Result<Time, E> {
2002                Ok(parse_time(s).map(Time).unwrap_or_else(|e| {
2003                    decode_warn("time", s, &e);
2004                    Time::default()
2005                }))
2006            }
2007        }
2008        d.deserialize_any(TimeVisitor)
2009    }
2010}
2011
2012/// A font size mirroring `bevy_text::FontSize`, parsed from the wire as a number
2013/// (logical pixels) or a unit string (`"24px"`, `"100vw"`/`vh`/`vmin`/`vmax`,
2014/// `"1.5rem"`). `rem` is relative to bevy's `RemSize` resource (default 20px).
2015/// (CSS `em` has no `bevy_text` equivalent, so it is not accepted.)
2016#[derive(Debug, Clone, Copy, PartialEq)]
2017pub enum FontSize {
2018    Px(f32),
2019    Vw(f32),
2020    Vh(f32),
2021    VMin(f32),
2022    VMax(f32),
2023    Rem(f32),
2024}
2025
2026/// Parse a font-size token (`"24px"`, `"100vw"`, `"1.5rem"`, or a bare number read
2027/// as pixels). Suffixes are checked longest-first where they'd otherwise alias
2028/// (`vmin`/`vmax` before `vw`/`vh`).
2029fn parse_font_size(s: &str) -> Result<FontSize, String> {
2030    let s = s.trim();
2031    type FsCtor = fn(f32) -> FontSize;
2032    let units: [(&str, FsCtor); 6] = [
2033        ("px", FontSize::Px),
2034        ("rem", FontSize::Rem),
2035        ("vmin", FontSize::VMin),
2036        ("vmax", FontSize::VMax),
2037        ("vw", FontSize::Vw),
2038        ("vh", FontSize::Vh),
2039    ];
2040    for (suffix, ctor) in units {
2041        if let Some(num) = s.strip_suffix(suffix) {
2042            let v: f32 = num
2043                .trim()
2044                .parse()
2045                .map_err(|_| format!("invalid fontSize {s:?}"))?;
2046            return Ok(ctor(v));
2047        }
2048    }
2049    s.parse::<f32>()
2050        .map(FontSize::Px)
2051        .map_err(|_| format!("invalid fontSize {s:?}"))
2052}
2053
2054impl<'de> Deserialize<'de> for FontSize {
2055    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2056        struct FontSizeVisitor;
2057        impl Visitor<'_> for FontSizeVisitor {
2058            type Value = FontSize;
2059            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2060                f.write_str("a number (logical pixels) or a font-size unit string")
2061            }
2062            fn visit_f64<E: de::Error>(self, v: f64) -> Result<FontSize, E> {
2063                Ok(FontSize::Px(v as f32))
2064            }
2065            fn visit_i64<E: de::Error>(self, v: i64) -> Result<FontSize, E> {
2066                Ok(FontSize::Px(v as f32))
2067            }
2068            fn visit_u64<E: de::Error>(self, v: u64) -> Result<FontSize, E> {
2069                Ok(FontSize::Px(v as f32))
2070            }
2071            fn visit_str<E: de::Error>(self, s: &str) -> Result<FontSize, E> {
2072                Ok(parse_font_size(s).unwrap_or_else(|e| {
2073                    decode_warn("fontSize", s, &e);
2074                    FontSize::Px(0.0)
2075                }))
2076            }
2077        }
2078        d.deserialize_any(FontSizeVisitor)
2079    }
2080}
2081
2082/// Four sides (or corners), each a [`Length`]. Accepts a number, a CSS shorthand
2083/// string, or a `{ top, right, bottom, left }` object on the wire.
2084#[derive(Debug, Clone, Copy, PartialEq, Default)]
2085pub struct Rect {
2086    pub top: Length,
2087    pub right: Length,
2088    pub bottom: Length,
2089    pub left: Length,
2090}
2091
2092impl Rect {
2093    fn uniform(v: Length) -> Self {
2094        Rect {
2095            top: v,
2096            right: v,
2097            bottom: v,
2098            left: v,
2099        }
2100    }
2101
2102    /// Expand 1–4 CSS values into four sides (top, right, bottom, left).
2103    fn from_shorthand(values: &[Length]) -> Result<Self, String> {
2104        Ok(match values {
2105            [a] => Rect::uniform(*a),
2106            [a, b] => Rect {
2107                top: *a,
2108                bottom: *a,
2109                right: *b,
2110                left: *b,
2111            },
2112            [a, b, c] => Rect {
2113                top: *a,
2114                right: *b,
2115                left: *b,
2116                bottom: *c,
2117            },
2118            [a, b, c, d] => Rect {
2119                top: *a,
2120                right: *b,
2121                bottom: *c,
2122                left: *d,
2123            },
2124            _ => return Err("expected 1–4 length values".into()),
2125        })
2126    }
2127}
2128
2129impl<'de> Deserialize<'de> for Rect {
2130    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2131        struct RectVisitor;
2132        impl<'de> Visitor<'de> for RectVisitor {
2133            type Value = Rect;
2134            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2135                f.write_str("a number, a CSS shorthand string, or a {top,right,bottom,left} object")
2136            }
2137            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Rect, E> {
2138                Ok(Rect::uniform(Length::Px(v as f32)))
2139            }
2140            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Rect, E> {
2141                Ok(Rect::uniform(Length::Px(v as f32)))
2142            }
2143            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Rect, E> {
2144                Ok(Rect::uniform(Length::Px(v as f32)))
2145            }
2146            fn visit_str<E: de::Error>(self, s: &str) -> Result<Rect, E> {
2147                // A bad token or value-count must not throw (that aborts the whole
2148                // commit batch and wedges the reconciler) — warn and fall back.
2149                let values: Vec<Length> = s
2150                    .split_whitespace()
2151                    .map(|tok| {
2152                        parse_length(tok).unwrap_or_else(|e| {
2153                            decode_warn("rect", tok, &e);
2154                            Length::default()
2155                        })
2156                    })
2157                    .collect();
2158                Ok(Rect::from_shorthand(&values).unwrap_or_else(|e| {
2159                    decode_warn("rect", s, &format!("invalid rect {s:?}: {e}"));
2160                    Rect::default()
2161                }))
2162            }
2163            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Rect, A::Error> {
2164                let mut rect = Rect::default();
2165                while let Some(key) = map.next_key::<String>()? {
2166                    let v = map.next_value::<Length>()?;
2167                    match key.as_str() {
2168                        "top" => rect.top = v,
2169                        "right" => rect.right = v,
2170                        "bottom" => rect.bottom = v,
2171                        "left" => rect.left = v,
2172                        // An unknown side key must not throw (that aborts the whole
2173                        // commit batch) — `v` is already consumed, so warn and skip.
2174                        _ => decode_warn(
2175                            "rect",
2176                            &key,
2177                            &format!(
2178                                "unknown rect side {key:?}; ignoring (expected top/right/bottom/left)"
2179                            ),
2180                        ),
2181                    }
2182                }
2183                Ok(rect)
2184            }
2185        }
2186        d.deserialize_any(RectVisitor)
2187    }
2188}
2189
2190/// Declares one `deserialize_with` fn per keyword-valued [`Style`] field,
2191/// decoding the wire keyword straight into the `bevy_ui`/`bevy_text` enum it
2192/// drives. An unrecognized keyword warns (naming the field and value) and falls
2193/// back to the enum's bevy default — a typo must not abort the commit batch. A
2194/// JSON `null` decodes to `None` (matching the former `Option<String>` fields);
2195/// any other non-string value keeps hard-erroring, like [`Length`].
2196macro_rules! keyword_fields {
2197    ( $(
2198        $(#[$meta:meta])*
2199        fn $fn_name:ident($kind:literal) -> $ty:ty {
2200            $( $($kw:literal)|+ => $variant:ident ),+ $(,)?
2201        }
2202    )+ ) => { $(
2203        $(#[$meta])*
2204        fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
2205            struct V;
2206            impl<'de> Visitor<'de> for V {
2207                type Value = Option<$ty>;
2208                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2209                    f.write_str(concat!("a `", $kind, "` keyword string"))
2210                }
2211                fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
2212                    Ok(Some(match s {
2213                        $( $($kw)|+ => <$ty>::$variant, )+
2214                        _ => {
2215                            decode_warn(
2216                                $kind,
2217                                s,
2218                                &format!("unrecognized {} {s:?}", $kind),
2219                            );
2220                            <$ty>::default()
2221                        }
2222                    }))
2223                }
2224                fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2225                    Ok(None)
2226                }
2227                fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2228                    Ok(None)
2229                }
2230            }
2231            d.deserialize_any(V)
2232        }
2233    )+ };
2234}
2235
2236keyword_fields! {
2237    fn de_display("display") -> Display {
2238        "flex" => Flex, "grid" => Grid, "block" => Block, "none" => None,
2239    }
2240    fn de_layer_cache("cache") -> LayerCache {
2241        "auto" => Auto, "always" => Always, "never" => Never,
2242    }
2243    fn de_box_sizing("boxSizing") -> BoxSizing {
2244        "borderBox" | "border-box" => BorderBox,
2245        "contentBox" | "content-box" => ContentBox,
2246    }
2247    fn de_position_type("positionType") -> PositionType {
2248        "absolute" => Absolute, "relative" => Relative,
2249    }
2250    fn de_overflow_axis("overflow") -> OverflowAxis {
2251        "visible" => Visible, "clip" => Clip, "hidden" => Hidden, "scroll" => Scroll,
2252    }
2253    // `start`/`end` are the physical variants, `flexStart`/`flexEnd` the
2254    // flow-relative ones — they diverge in grid and reversed-flex containers,
2255    // so the keywords must not collapse together. The alignment enums' bevy
2256    // default is the keyword-less `Default` variant ("align per the layout
2257    // spec"), which is also the unrecognized-keyword fallback.
2258    fn de_align_items("alignItems") -> AlignItems {
2259        "start" => Start, "end" => End,
2260        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
2261        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
2262    }
2263    fn de_justify_items("justifyItems") -> JustifyItems {
2264        "start" => Start, "end" => End,
2265        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
2266    }
2267    fn de_align_self("alignSelf") -> AlignSelf {
2268        "auto" => Auto, "start" => Start, "end" => End,
2269        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
2270        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
2271    }
2272    fn de_justify_self("justifySelf") -> JustifySelf {
2273        "auto" => Auto, "start" => Start, "end" => End,
2274        "center" => Center, "baseline" => Baseline, "stretch" => Stretch,
2275    }
2276    fn de_align_content("alignContent") -> AlignContent {
2277        "start" => Start, "end" => End,
2278        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
2279        "center" => Center, "stretch" => Stretch,
2280        "spaceBetween" => SpaceBetween, "spaceEvenly" => SpaceEvenly,
2281        "spaceAround" => SpaceAround,
2282    }
2283    fn de_justify_content("justifyContent") -> JustifyContent {
2284        "start" => Start, "end" => End,
2285        "flexStart" => FlexStart, "flexEnd" => FlexEnd,
2286        "center" => Center, "stretch" => Stretch,
2287        "spaceBetween" => SpaceBetween, "spaceEvenly" => SpaceEvenly,
2288        "spaceAround" => SpaceAround,
2289    }
2290    fn de_flex_direction("flexDirection") -> FlexDirection {
2291        "row" => Row, "column" => Column,
2292        "rowReverse" => RowReverse, "columnReverse" => ColumnReverse,
2293    }
2294    fn de_flex_wrap("flexWrap") -> FlexWrap {
2295        "nowrap" | "noWrap" => NoWrap, "wrap" => Wrap, "wrapReverse" => WrapReverse,
2296    }
2297    fn de_grid_auto_flow("gridAutoFlow") -> GridAutoFlow {
2298        "row" => Row, "column" => Column,
2299        "rowDense" => RowDense, "columnDense" => ColumnDense,
2300    }
2301    // Unknown values fall back to `Pass` (bevy's default) so a typo stays
2302    // click-through rather than silently swallowing pointer interaction.
2303    fn de_focus_policy("focusPolicy") -> FocusPolicy {
2304        "block" => Block, "pass" => Pass,
2305    }
2306    fn de_text_align("textAlign") -> Justify {
2307        "left" => Left, "center" => Center, "right" => Right,
2308        "justify" => Justified, "start" => Start, "end" => End,
2309    }
2310    fn de_line_break("lineBreak") -> LineBreak {
2311        "wordBoundary" => WordBoundary, "anyCharacter" => AnyCharacter,
2312        "wordOrCharacter" => WordOrCharacter, "noWrap" => NoWrap,
2313    }
2314}
2315
2316/// `fontWeight`: a named keyword or a numeric weight string (`"600"`). Not a
2317/// [`keyword_fields!`] entry because of the numeric form. Unrecognized → warn +
2318/// `NORMAL` (400).
2319fn de_font_weight<'de, D: Deserializer<'de>>(d: D) -> Result<Option<FontWeight>, D::Error> {
2320    struct V;
2321    impl<'de> Visitor<'de> for V {
2322        type Value = Option<FontWeight>;
2323        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2324            f.write_str("a `fontWeight` keyword or numeric weight string")
2325        }
2326        fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
2327            Ok(Some(match s {
2328                "thin" => FontWeight::THIN,
2329                "light" => FontWeight(300),
2330                "normal" => FontWeight::NORMAL,
2331                "medium" => FontWeight(500),
2332                "semibold" => FontWeight(600),
2333                "bold" => FontWeight::BOLD,
2334                "black" => FontWeight::BLACK,
2335                other => other.parse::<u16>().map(FontWeight).unwrap_or_else(|_| {
2336                    decode_warn(
2337                        "fontWeight",
2338                        other,
2339                        &format!("unrecognized fontWeight {other:?}"),
2340                    );
2341                    FontWeight::NORMAL
2342                }),
2343            }))
2344        }
2345        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2346            Ok(None)
2347        }
2348        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2349            Ok(None)
2350        }
2351    }
2352    d.deserialize_any(V)
2353}
2354
2355/// Split a grid track list on whitespace while keeping `repeat(...)` groups
2356/// (which contain spaces) intact.
2357fn split_tracks(s: &str) -> Vec<String> {
2358    let mut out = Vec::new();
2359    let mut depth = 0usize;
2360    let mut cur = String::new();
2361    for ch in s.chars() {
2362        match ch {
2363            '(' => {
2364                depth += 1;
2365                cur.push(ch);
2366            }
2367            ')' => {
2368                depth = depth.saturating_sub(1);
2369                cur.push(ch);
2370            }
2371            c if c.is_whitespace() && depth == 0 => {
2372                if !cur.is_empty() {
2373                    out.push(std::mem::take(&mut cur));
2374                }
2375            }
2376            c => cur.push(c),
2377        }
2378    }
2379    if !cur.is_empty() {
2380        out.push(cur);
2381    }
2382    out
2383}
2384
2385/// Parse one sizing token (`"1fr"`, `"100px"`, `"50%"`, `"auto"`,
2386/// `"min-content"`, `"max-content"`, `"2flex"`) into a `GridTrack`.
2387fn single_track(token: &str) -> Option<GridTrack> {
2388    let t = token.trim();
2389    match t {
2390        "auto" => return Some(GridTrack::auto()),
2391        "min-content" => return Some(GridTrack::min_content()),
2392        "max-content" => return Some(GridTrack::max_content()),
2393        _ => {}
2394    }
2395    let parse = |num: &str| num.trim().parse::<f32>().ok();
2396    if let Some(v) = t.strip_suffix("fr").and_then(parse) {
2397        Some(GridTrack::fr(v))
2398    } else if let Some(v) = t.strip_suffix("flex").and_then(parse) {
2399        Some(GridTrack::flex(v))
2400    } else if let Some(v) = t.strip_suffix("px").and_then(parse) {
2401        Some(GridTrack::px(v))
2402    } else {
2403        t.strip_suffix('%').and_then(parse).map(GridTrack::percent)
2404    }
2405}
2406
2407/// Build a repeated track (`repeat(count, token)`), dispatching on the unit.
2408fn repeated_track(count: u16, token: &str) -> Option<RepeatedGridTrack> {
2409    let t = token.trim();
2410    match t {
2411        "auto" => return Some(RepeatedGridTrack::auto(count)),
2412        "min-content" => return Some(RepeatedGridTrack::min_content(count)),
2413        "max-content" => return Some(RepeatedGridTrack::max_content(count)),
2414        _ => {}
2415    }
2416    let parse = |num: &str| num.trim().parse::<f32>().ok();
2417    if let Some(v) = t.strip_suffix("fr").and_then(parse) {
2418        Some(RepeatedGridTrack::fr(count, v))
2419    } else if let Some(v) = t.strip_suffix("flex").and_then(parse) {
2420        Some(RepeatedGridTrack::flex(count, v))
2421    } else if let Some(v) = t.strip_suffix("px").and_then(parse) {
2422        Some(RepeatedGridTrack::px(count as usize, v))
2423    } else {
2424        t.strip_suffix('%')
2425            .and_then(parse)
2426            .map(|v| RepeatedGridTrack::percent(count as usize, v))
2427    }
2428}
2429
2430/// Parse a CSS grid template (`"repeat(3, 1fr)"`, `"1fr 2fr 100px"`, `"auto"`).
2431/// An unparsable token warns and is skipped; the rest of the template survives.
2432fn parse_template(s: &str) -> Vec<RepeatedGridTrack> {
2433    split_tracks(s)
2434        .into_iter()
2435        .filter_map(|tok| {
2436            let parse_one = || {
2437                if let Some(inner) = tok
2438                    .strip_prefix("repeat(")
2439                    .and_then(|t| t.strip_suffix(')'))
2440                {
2441                    let (count, track) = inner.split_once(',')?;
2442                    repeated_track(count.trim().parse().ok()?, track)
2443                } else {
2444                    single_track(&tok).map(Into::into)
2445                }
2446            };
2447            let parsed = parse_one();
2448            if parsed.is_none() {
2449                decode_warn(
2450                    "gridTrack",
2451                    &tok,
2452                    &format!("ignoring unparsable grid track {tok:?}"),
2453                );
2454            }
2455            parsed
2456        })
2457        .collect()
2458}
2459
2460/// Parse an auto-track list (`grid-auto-rows`/`columns`); no `repeat()`.
2461fn parse_auto_tracks(s: &str) -> Vec<GridTrack> {
2462    split_tracks(s)
2463        .iter()
2464        .filter_map(|t| {
2465            let parsed = single_track(t);
2466            if parsed.is_none() {
2467                decode_warn(
2468                    "gridTrack",
2469                    t,
2470                    &format!("ignoring unparsable grid track {t:?}"),
2471                );
2472            }
2473            parsed
2474        })
2475        .collect()
2476}
2477
2478/// Fallible half of [`de_grid_placement`]: `None` on anything that must not
2479/// reach `GridPlacement`'s panicking constructors. A zero anywhere in the value
2480/// (invalid in CSS) aborts the whole placement (rather than degrading to a
2481/// partial one, which would silently mis-place the item).
2482fn try_grid_placement(s: &str) -> Option<GridPlacement> {
2483    enum Token {
2484        Num(i16),  // a nonzero line number
2485        Span(u16), // a nonzero `span N`
2486        Auto,
2487        Invalid, // a zero line/span, or an unrecognized token
2488    }
2489    fn token(t: &str) -> Token {
2490        let t = t.trim();
2491        if t == "auto" {
2492            return Token::Auto;
2493        }
2494        if let Some(n) = t.strip_prefix("span") {
2495            return match n.trim().parse::<u16>() {
2496                Ok(0) | Err(_) => Token::Invalid,
2497                Ok(n) => Token::Span(n),
2498            };
2499        }
2500        match t.parse::<i16>() {
2501            Ok(0) | Err(_) => Token::Invalid,
2502            Ok(n) => Token::Num(n),
2503        }
2504    }
2505    use Token::*;
2506    if let Some((a, b)) = s.split_once('/') {
2507        return Some(match (token(a), token(b)) {
2508            (Num(start), Span(span)) => GridPlacement::start_span(start, span),
2509            (Auto, Span(span)) => GridPlacement::span(span),
2510            (Num(start), Num(end)) => GridPlacement::start_end(start, end),
2511            (Num(start), Auto) => GridPlacement::start(start),
2512            (Auto, Num(end)) => GridPlacement::end(end),
2513            (Auto, Auto) => GridPlacement::auto(),
2514            _ => return None,
2515        });
2516    }
2517    match token(s) {
2518        Auto => Some(GridPlacement::auto()),
2519        Span(span) => Some(GridPlacement::span(span)),
2520        Num(line) => Some(GridPlacement::start(line)),
2521        Invalid => None,
2522    }
2523}
2524
2525/// Shared shape of the three grid deserializers: string in, parsed value out,
2526/// `null` → `None`, non-string → hard error (like the keyword fields).
2527macro_rules! grid_fields {
2528    ( $(
2529        $(#[$meta:meta])*
2530        fn $fn_name:ident($expect:literal) -> $ty:ty { $parse:expr }
2531    )+ ) => { $(
2532        $(#[$meta])*
2533        fn $fn_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
2534            struct V;
2535            impl<'de> Visitor<'de> for V {
2536                type Value = Option<$ty>;
2537                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2538                    f.write_str($expect)
2539                }
2540                fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
2541                    let parse: fn(&str) -> $ty = $parse;
2542                    Ok(Some(parse(s)))
2543                }
2544                fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2545                    Ok(None)
2546                }
2547                fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2548                    Ok(None)
2549                }
2550            }
2551            d.deserialize_any(V)
2552        }
2553    )+ };
2554}
2555
2556grid_fields! {
2557    fn de_grid_template("a CSS grid template string") -> Vec<RepeatedGridTrack> {
2558        parse_template
2559    }
2560    fn de_grid_auto_tracks("a grid auto-track list string") -> Vec<GridTrack> {
2561        parse_auto_tracks
2562    }
2563    /// A zero grid line/span (invalid in CSS — and `GridPlacement`'s
2564    /// constructors panic on it) or an unrecognized token warns and falls back
2565    /// to `auto`.
2566    fn de_grid_placement("a grid line placement string") -> GridPlacement {
2567        |s| {
2568            try_grid_placement(s).unwrap_or_else(|| {
2569                decode_warn(
2570                    "gridPlacement",
2571                    s,
2572                    &format!("unrecognized grid placement {s:?}"),
2573                );
2574                GridPlacement::default()
2575            })
2576        }
2577    }
2578}
2579
2580/// Border color: a single CSS color applied to all four sides, or a
2581/// `{ top, right, bottom, left }` object setting sides individually. Omitted
2582/// sides decode to `None` (painted transparent — bevy's `BorderColor` default).
2583///
2584/// Unlike [`Rect`], a multi-value string (`"red green blue"`) is **not** accepted:
2585/// CSS color functions contain spaces (`rgb(1 2 3)`), so whitespace-splitting
2586/// would be ambiguous. Per-side colors go through the object form only.
2587#[derive(Debug, Clone, PartialEq, Default)]
2588pub struct BorderColorSpec {
2589    pub top: Option<String>,
2590    pub right: Option<String>,
2591    pub bottom: Option<String>,
2592    pub left: Option<String>,
2593}
2594
2595impl BorderColorSpec {
2596    /// One color on every side (the back-compat scalar form).
2597    fn uniform(s: String) -> Self {
2598        BorderColorSpec {
2599            top: Some(s.clone()),
2600            right: Some(s.clone()),
2601            bottom: Some(s.clone()),
2602            left: Some(s),
2603        }
2604    }
2605}
2606
2607impl<'de> Deserialize<'de> for BorderColorSpec {
2608    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2609        struct BorderColorVisitor;
2610        impl<'de> Visitor<'de> for BorderColorVisitor {
2611            type Value = BorderColorSpec;
2612            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2613                f.write_str("a CSS color string or a {top,right,bottom,left} object of colors")
2614            }
2615            fn visit_str<E: de::Error>(self, s: &str) -> Result<BorderColorSpec, E> {
2616                Ok(BorderColorSpec::uniform(s.to_owned()))
2617            }
2618            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BorderColorSpec, A::Error> {
2619                let mut spec = BorderColorSpec::default();
2620                while let Some(key) = map.next_key::<String>()? {
2621                    let v = map.next_value::<String>()?;
2622                    match key.as_str() {
2623                        "top" => spec.top = Some(v),
2624                        "right" => spec.right = Some(v),
2625                        "bottom" => spec.bottom = Some(v),
2626                        "left" => spec.left = Some(v),
2627                        // An unknown side key must not throw (that aborts the whole
2628                        // commit batch) — `v` is already consumed, so warn and skip.
2629                        _ => decode_warn(
2630                            "borderColor",
2631                            &key,
2632                            &format!(
2633                                "unknown borderColor side {key:?}; ignoring (expected top/right/bottom/left)"
2634                            ),
2635                        ),
2636                    }
2637                }
2638                Ok(spec)
2639            }
2640        }
2641        d.deserialize_any(BorderColorVisitor)
2642    }
2643}
2644
2645/// An interaction event sent from Bevy back into JS, where the reconciler
2646/// dispatches it to the matching React handler.
2647#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2648#[serde(rename_all = "camelCase")]
2649pub struct UiEvent {
2650    pub id: NodeId,
2651    /// `"click"`, a pointer kind (`"pointerDown"` / `"pointerMove"` /
2652    /// `"pointerUp"` / `"pointerEnter"` / `"pointerLeave"`), `"scroll"`,
2653    /// `"wheel"`, a `canvas`'s `"resize"`, or one of an `editableText`'s
2654    /// `"change"` / `"select"` / `"focus"` / `"blur"` events.
2655    pub kind: String,
2656    /// Cursor x within the node, normalized to `0..1` (left→right). Present only
2657    /// for pointer events; `None` for `"click"`.
2658    #[serde(default, skip_serializing_if = "Option::is_none")]
2659    pub x: Option<f32>,
2660    /// Cursor y within the node, normalized to `0..1` (top→bottom). Present only
2661    /// for pointer events; `None` for `"click"`.
2662    #[serde(default, skip_serializing_if = "Option::is_none")]
2663    pub y: Option<f32>,
2664    /// Absolute cursor x in window logical pixels (left→right, top-left origin).
2665    /// Present only for pointer events; lets a handler drag a node across the
2666    /// screen (the normalized `x`/`y` are clamped to the node and can't).
2667    #[serde(default, skip_serializing_if = "Option::is_none")]
2668    pub client_x: Option<f32>,
2669    /// Absolute cursor y in window logical pixels (top→bottom). Present only for
2670    /// pointer events; see [`client_x`](Self::client_x).
2671    #[serde(default, skip_serializing_if = "Option::is_none")]
2672    pub client_y: Option<f32>,
2673    /// Which mouse button fired, in DOM `MouseEvent.button` numbering:
2674    /// `0` left/primary, `1` middle/auxiliary, `2` right/secondary. Present for
2675    /// `"pointerDown"`/`"pointerMove"`/`"pointerUp"`; absent for `"click"`
2676    /// (primary-only, like DOM `click`) and hover/scroll/text events.
2677    #[serde(default, skip_serializing_if = "Option::is_none")]
2678    pub button: Option<u8>,
2679    /// The new text of an `editableText`. Present only for `"change"` events.
2680    #[serde(default, skip_serializing_if = "Option::is_none")]
2681    pub value: Option<String>,
2682    /// Selection anchor, a UTF-8 **byte** offset. Present only for `"select"`.
2683    #[serde(default, skip_serializing_if = "Option::is_none")]
2684    pub selection_start: Option<usize>,
2685    /// Selection focus, a UTF-8 **byte** offset. Present only for `"select"`.
2686    #[serde(default, skip_serializing_if = "Option::is_none")]
2687    pub selection_end: Option<usize>,
2688    /// `"forward"` (anchor ≤ focus), `"backward"`, or `"none"` (collapsed).
2689    /// Present only for `"select"`.
2690    #[serde(default, skip_serializing_if = "Option::is_none")]
2691    pub selection_direction: Option<String>,
2692    /// Whether an IME composition is in progress. Present on an `editableText`'s
2693    /// `"change"` / `"select"` events.
2694    #[serde(default, skip_serializing_if = "Option::is_none")]
2695    pub composing: Option<bool>,
2696    /// Vertical scroll offset (logical px) → `ScrollPosition.y`. Present only for
2697    /// `"scroll"` events.
2698    #[serde(default, skip_serializing_if = "Option::is_none")]
2699    pub scroll_top: Option<f32>,
2700    /// Horizontal scroll offset (logical px) → `ScrollPosition.x`. Present only for
2701    /// `"scroll"` events.
2702    #[serde(default, skip_serializing_if = "Option::is_none")]
2703    pub scroll_left: Option<f32>,
2704    /// Raw horizontal wheel delta (the frame's accumulated scroll). Present only
2705    /// for `"wheel"` events; interpret with [`delta_mode`](Self::delta_mode).
2706    #[serde(default, skip_serializing_if = "Option::is_none")]
2707    pub delta_x: Option<f32>,
2708    /// Raw vertical wheel delta. Present only for `"wheel"` events; positive is a
2709    /// wheel-down / scroll-forward gesture, matching DOM `WheelEvent.deltaY`.
2710    #[serde(default, skip_serializing_if = "Option::is_none")]
2711    pub delta_y: Option<f32>,
2712    /// How to read the wheel deltas: `"line"` (mouse notches — scale by your own
2713    /// per-line distance) or `"pixel"` (trackpad — already in pixels). Mirrors
2714    /// DOM `WheelEvent.deltaMode`. Present only for `"wheel"` events.
2715    #[serde(default, skip_serializing_if = "Option::is_none")]
2716    pub delta_mode: Option<String>,
2717    /// New logical (CSS px) width of a `canvas`'s laid-out box. Present only for
2718    /// `"resize"` events, which fire on first layout (0 → W×H) and whenever the
2719    /// physical pixel size changes (including a DPR change at constant logical
2720    /// size). The surface was cleared — redraw.
2721    #[serde(default, skip_serializing_if = "Option::is_none")]
2722    pub width: Option<f32>,
2723    /// New logical height of a `canvas`'s laid-out box. Present only for
2724    /// `"resize"` events; see [`width`](Self::width).
2725    #[serde(default, skip_serializing_if = "Option::is_none")]
2726    pub height: Option<f32>,
2727}
2728
2729/// Everything that flows Bevy -> JS over the single outbound channel. Internally
2730/// tagged (`t`) so `serde_v8` produces a plain JS object the JS event loop can
2731/// `switch` on. Each variant serializes to a map, as internal tagging requires.
2732#[derive(Debug, Clone, Serialize)]
2733#[serde(tag = "t", rename_all = "camelCase")]
2734pub enum Outbound {
2735    /// A UI interaction on a reconciler node (the original click path).
2736    UiEvent { event: UiEvent },
2737    /// A named Bevy -> React app event (e.g. `"user.disconnected"`). `value` is
2738    /// the payload, pre-serialized so this channel stays a single concrete type.
2739    Event {
2740        name: String,
2741        value: serde_json::Value,
2742    },
2743    /// A reply to a React -> Bevy request, correlated by the request `id`.
2744    Response { id: u64, result: ResponseResult },
2745    /// A token-tagged animation driver settled: `finished` is `true` on natural
2746    /// completion, `false` on interruption. `token` correlates the JS completion
2747    /// callback registered when the driver was assigned.
2748    AnimationFinished {
2749        id: crate::animations::SharedId,
2750        token: u64,
2751        finished: bool,
2752    },
2753    /// Hot-reload sentinel: make the JS event loop exit so the runtime rebuilds.
2754    Reload,
2755}
2756
2757/// The outcome of a React -> Bevy request. Internally tagged (`status`) so JS
2758/// reads `result.status === "ok"`. The error is a message, surfaced to JS as a
2759/// rejected promise — the typed success value is the only thing in the schema.
2760#[derive(Debug, Clone, Serialize)]
2761#[serde(tag = "status", rename_all = "camelCase")]
2762pub enum ResponseResult {
2763    Ok { value: serde_json::Value },
2764    Err { message: String },
2765}
2766
2767#[cfg(test)]
2768mod tests {
2769    use super::*;
2770
2771    /// `OpBatch` stamps decode-fallback warnings with the op that carried
2772    /// them, so devtools can attribute "invalid length" to a node id even
2773    /// though the field visitors can't see one. The decode sink is
2774    /// thread-local (and cleared at batch start), so this is parallel-safe.
2775    #[cfg(all(feature = "devtools", debug_assertions))]
2776    #[test]
2777    fn op_batch_attributes_decode_warnings() {
2778        // A leftover from an earlier decode on this thread must not leak in.
2779        crate::diag::decode_report("length", "stale", "stale entry");
2780        let json = r#"[
2781            {"op":"update","id":7,"props":{"style":{"width":"aa16"}}},
2782            {"op":"append","parent":0,"child":7},
2783            {"op":"update","id":9,"props":{"style":{"display":"flexx","padding":"1px bogus"}}}
2784        ]"#;
2785        let batch: OpBatch = serde_json::from_str(json).expect("batch decodes");
2786        assert_eq!(batch.0.len(), 3, "fallbacks must not drop ops");
2787        let warns = crate::diag::take_decode_warnings();
2788        let brief: Vec<_> = warns
2789            .iter()
2790            .map(|w| (w.node, w.kind, w.value.as_str()))
2791            .collect();
2792        assert_eq!(
2793            brief,
2794            vec![
2795                (Some(7), "length", "aa16"),
2796                (Some(9), "display", "flexx"),
2797                (Some(9), "rect", "bogus"),
2798            ],
2799        );
2800        assert!(warns.iter().all(|w| !w.message.is_empty()));
2801        assert!(
2802            crate::diag::take_decode_warnings().is_empty(),
2803            "drain empties the sink"
2804        );
2805    }
2806
2807    /// An `<editableText>` create op carries its controlled value and attributes.
2808    #[test]
2809    fn deserializes_editable_text_create() {
2810        let json = r#"{"op":"create","id":7,"kind":"editableText","props":{
2811            "value":"hi","maxLength":40,"multiline":true,"onChange":true,
2812            "autofocus":true,"selectionStart":0,"selectionEnd":2,
2813            "ariaLabel":"Name","onSelect":true,"onFocus":true,"onBlur":true,
2814            "focusStyle":{"borderColor":"white"}}}"#;
2815        match serde_json::from_str::<Op>(json).expect("valid op") {
2816            Op::Create {
2817                id, kind, props, ..
2818            } => {
2819                assert_eq!(id, 7);
2820                assert_eq!(kind, "editableText");
2821                assert_eq!(props.value.as_deref(), Some("hi"));
2822                assert_eq!(props.max_length, Some(40));
2823                assert!(props.multiline);
2824                assert!(props.on_change);
2825                assert!(props.autofocus);
2826                assert_eq!(props.selection_start, Some(0));
2827                assert_eq!(props.selection_end, Some(2));
2828                assert_eq!(props.aria_label.as_deref(), Some("Name"));
2829                assert!(props.on_select);
2830                assert!(props.on_focus);
2831                assert!(props.on_blur);
2832                assert!(props.focus_style.is_some());
2833            }
2834            other => panic!("expected create, got {other:?}"),
2835        }
2836    }
2837
2838    /// A style carries `transform`/`opacity`/`transition` over the wire (transform
2839    /// as a nested object, transition's `transform` entry resolving to a timing).
2840    #[test]
2841    fn deserializes_transform_opacity_and_transition() {
2842        let s: Style = serde_json::from_str(
2843            r#"{
2844                "transform": { "scale": 0.95, "translateX": 4, "translateY": "50%" },
2845                "opacity": 0.5,
2846                "transition": { "transform": { "duration": 0.15, "easing": "easeOut" } }
2847            }"#,
2848        )
2849        .expect("style decodes");
2850        let t = s.transform.expect("transform present");
2851        assert_eq!(t.scale.static_val(), Some(0.95));
2852        // A bare number is logical pixels; a unit string carries an explicit unit.
2853        assert_eq!(t.translate_x.static_val(), Some(Length::Px(4.0)));
2854        assert_eq!(t.translate_y.static_val(), Some(Length::Percent(50.0)));
2855        assert_eq!(t.scale_x, None);
2856        assert_eq!(s.opacity.static_val(), Some(0.5));
2857        let transition = s.transition.expect("transition present");
2858        assert!(transition.for_transform().is_some());
2859        assert!(transition.for_opacity().is_none());
2860    }
2861
2862    /// `transform3d` decodes its full field set (degrees and rad-string angles,
2863    /// percent-or-px origin), a malformed angle falls back to identity, an
2864    /// empty object is identity, and a wire delta dirties
2865    /// `TRANSFORM3D | LAYER | TRANSITION`.
2866    #[test]
2867    fn deserializes_transform3d() {
2868        let s: Style = serde_json::from_str(
2869            r#"{
2870                "transform3d": {
2871                    "perspective": 800,
2872                    "translateZ": -20,
2873                    "rotateY": 45,
2874                    "rotateX": "1.5rad",
2875                    "rotateZ": "not-an-angle",
2876                    "scale": 1.25,
2877                    "origin": { "x": "50%", "y": 10 }
2878                }
2879            }"#,
2880        )
2881        .expect("style decodes");
2882        let t = s.transform3d.clone().expect("transform3d present");
2883        assert_eq!(t.perspective.static_val(), Some(800.0));
2884        assert_eq!(t.translate_z.static_val(), Some(-20.0));
2885        assert_eq!(
2886            t.rotate_y.static_val().unwrap().radians(),
2887            45f32.to_radians()
2888        );
2889        assert_eq!(t.rotate_x.static_val().unwrap().radians(), 1.5);
2890        // Malformed angle → warn-and-identity, never a decode failure.
2891        assert_eq!(t.rotate_z.static_val().unwrap().radians(), 0.0);
2892        assert_eq!(t.scale.static_val(), Some(1.25));
2893        let origin = t.origin.clone().expect("origin present");
2894        assert_eq!(origin.x.value(), Some(&Length::Percent(50.0)));
2895        assert_eq!(origin.y.value(), Some(&Length::Px(10.0)));
2896        assert!(!t.is_identity());
2897
2898        let s: Style = serde_json::from_str(r#"{ "transform3d": {} }"#).expect("style decodes");
2899        assert!(s.transform3d.expect("present").is_identity());
2900
2901        let mut cached = Props::default();
2902        let (dirty, _) = cached.merge_delta(
2903            props(serde_json::json!({ "style": { "transform3d": { "rotateY": 45 } } })),
2904            &[],
2905            &[],
2906        );
2907        assert!(dirty.style.intersects(style_groups::TRANSFORM3D));
2908        assert!(dirty.style.intersects(style_groups::LAYER));
2909        assert!(dirty.style.intersects(style_groups::TRANSITION));
2910    }
2911
2912    /// `groupAlpha` decodes as a plain bool, defaults to absent, and its wire
2913    /// delta dirties the `LAYER` group (the promotion evaluator's trigger),
2914    /// as does `opacity`.
2915    #[test]
2916    fn group_alpha_decodes_and_dirties_layer() {
2917        let s: Style = serde_json::from_str(r#"{ "groupAlpha": false }"#).expect("style decodes");
2918        assert_eq!(s.group_alpha, Some(false));
2919        let s: Style = serde_json::from_str("{}").expect("style decodes");
2920        assert_eq!(s.group_alpha, None);
2921
2922        // Delta-merge marks the LAYER group for both trigger fields.
2923        let mut cached = Props::default();
2924        let (dirty, _) = cached.merge_delta(
2925            props(serde_json::json!({ "style": { "groupAlpha": false } })),
2926            &[],
2927            &[],
2928        );
2929        assert!(dirty.style.intersects(style_groups::LAYER));
2930        let (dirty, _) = cached.merge_delta(
2931            props(serde_json::json!({ "style": { "opacity": 0.5 } })),
2932            &[],
2933            &[],
2934        );
2935        assert!(dirty.style.intersects(style_groups::LAYER));
2936        let style = cached.style.as_ref().expect("style retained");
2937        assert_eq!(style.group_alpha, Some(false));
2938        assert_eq!(style.opacity.static_val(), Some(0.5));
2939    }
2940
2941    /// `cache` decodes its keywords (unknown → warn + default) and a delta
2942    /// touching it marks the LAYER group, driving promotion re-evaluation.
2943    #[test]
2944    fn cache_keyword_decodes_and_dirties_layer() {
2945        let s: Style = serde_json::from_str(r#"{ "cache": "always" }"#).expect("style decodes");
2946        assert_eq!(s.cache, Some(LayerCache::Always));
2947        let s: Style = serde_json::from_str(r#"{ "cache": "auto" }"#).expect("style decodes");
2948        assert_eq!(s.cache, Some(LayerCache::Auto));
2949        let s: Style = serde_json::from_str(r#"{ "cache": "never" }"#).expect("style decodes");
2950        assert_eq!(s.cache, Some(LayerCache::Never));
2951        let s: Style = serde_json::from_str("{}").expect("style decodes");
2952        assert_eq!(s.cache, None);
2953        // Unrecognized keyword: warn + fall back to the default (`auto`).
2954        let s: Style = serde_json::from_str(r#"{ "cache": "sometimes" }"#).expect("style decodes");
2955        assert_eq!(s.cache, Some(LayerCache::Auto));
2956
2957        let mut cached = Props::default();
2958        let (dirty, _) = cached.merge_delta(
2959            props(serde_json::json!({ "style": { "cache": "always" } })),
2960            &[],
2961            &[],
2962        );
2963        assert!(dirty.style.intersects(style_groups::LAYER));
2964        assert_eq!(
2965            cached.style.as_ref().and_then(|s| s.cache),
2966            Some(LayerCache::Always)
2967        );
2968    }
2969
2970    /// Angles parse from a bare number (degrees) or a unit string, always landing
2971    /// in radians.
2972    #[test]
2973    fn angle_units() {
2974        use std::f32::consts::{PI, TAU};
2975        let parse = |v: serde_json::Value| serde_json::from_value::<Angle>(v).unwrap().radians();
2976        assert!((parse(serde_json::json!(180)) - PI).abs() < 1e-5);
2977        assert!((parse(serde_json::json!("180deg")) - PI).abs() < 1e-5);
2978        assert!((parse(serde_json::json!("3.14159rad")) - PI).abs() < 1e-4);
2979        assert!((parse(serde_json::json!("0.5turn")) - PI).abs() < 1e-5);
2980        assert!((parse(serde_json::json!("400grad")) - TAU).abs() < 1e-5);
2981    }
2982
2983    /// `borderColor` decodes from a scalar (uniform, back-compat) or a per-side
2984    /// object; omitted sides stay `None`, and an unknown side key is rejected.
2985    #[test]
2986    fn border_color_scalar_and_per_side() {
2987        // Scalar string → every side set (the historical form).
2988        let uniform: Style =
2989            serde_json::from_str(r#"{ "borderColor": "white" }"#).expect("scalar decodes");
2990        let bc = uniform
2991            .border_color
2992            .static_ref()
2993            .expect("border_color present");
2994        assert_eq!(bc.top.as_deref(), Some("white"));
2995        assert_eq!(bc.right.as_deref(), Some("white"));
2996        assert_eq!(bc.bottom.as_deref(), Some("white"));
2997        assert_eq!(bc.left.as_deref(), Some("white"));
2998
2999        // Object form sets only the named sides; the rest stay None (transparent).
3000        let sided: Style =
3001            serde_json::from_str(r##"{ "borderColor": { "top": "#f00", "left": "blue" } }"##)
3002                .expect("object decodes");
3003        let bc = sided
3004            .border_color
3005            .static_ref()
3006            .expect("border_color present");
3007        assert_eq!(bc.top.as_deref(), Some("#f00"));
3008        assert_eq!(bc.left.as_deref(), Some("blue"));
3009        assert_eq!(bc.right, None);
3010        assert_eq!(bc.bottom, None);
3011
3012        // An unknown side key is ignored (warned), not rejected: throwing here would
3013        // abort the whole commit batch and wedge the reconciler. A valid sibling key
3014        // still applies; the unknown one leaves all sides at their default (None).
3015        let bogus: Style =
3016            serde_json::from_str(r#"{ "borderColor": { "middle": "red", "top": "blue" } }"#)
3017                .expect("unknown side key must not abort deserialization");
3018        let bc = bogus
3019            .border_color
3020            .static_ref()
3021            .expect("border_color present");
3022        assert_eq!(bc.top.as_deref(), Some("blue"));
3023        assert_eq!(bc.right, None);
3024        assert_eq!(bc.bottom, None);
3025        assert_eq!(bc.left, None);
3026    }
3027
3028    /// A malformed unit string in any unit-bearing field must **not** fail the
3029    /// whole `Style` (and thus the whole commit batch): it decodes to the type's
3030    /// default and warns. A good value alongside it still decodes correctly.
3031    #[test]
3032    fn bad_unit_values_fall_back_instead_of_aborting() {
3033        // Bad `width` (unknown unit) → default, sibling `height` intact.
3034        let s: Style = serde_json::from_str(r#"{ "width": "100pixels", "height": "40px" }"#)
3035            .expect("a bad length must not abort deserialization");
3036        assert_eq!(s.width.static_val(), Some(Length::default()));
3037        assert_eq!(s.height.static_val(), Some(Length::Px(40.0)));
3038
3039        // Bad `fontSize` → default `Px(0.0)`.
3040        let s: Style = serde_json::from_str(r#"{ "fontSize": "16pxx" }"#)
3041            .expect("bad fontSize must not abort");
3042        assert_eq!(s.font_size, Some(FontSize::Px(0.0)));
3043
3044        // Bad transform `rotate` (angle) → default `Angle(0)`, valid `translateX` intact.
3045        let t: Transform = serde_json::from_str(r#"{ "rotate": "45degg", "translateX": "50%" }"#)
3046            .expect("bad angle must not abort");
3047        assert_eq!(t.rotate.static_val(), Some(Angle::default()));
3048        assert_eq!(t.translate_x.static_val(), Some(Length::Percent(50.0)));
3049
3050        // Rect shorthand (`padding`/`margin`/`border`/`borderRadius`): a bad token
3051        // defaults just that side; a good shorthand still decodes; a bad value-count
3052        // defaults the whole rect. None of these abort (the reported `padding: "16asd"`).
3053        let s: Style =
3054            serde_json::from_str(r#"{ "padding": "16asd" }"#).expect("bad rect must not abort");
3055        assert_eq!(s.padding, Some(Rect::default()));
3056
3057        let s: Style = serde_json::from_str(r#"{ "padding": "8px 16asd" }"#)
3058            .expect("partial-bad rect must not abort");
3059        // top/bottom = 8px (good), right/left = default (the bad token).
3060        assert_eq!(
3061            s.padding,
3062            Some(Rect {
3063                top: Length::Px(8.0),
3064                bottom: Length::Px(8.0),
3065                right: Length::default(),
3066                left: Length::default(),
3067            })
3068        );
3069
3070        let s: Style = serde_json::from_str(r#"{ "padding": "8px 16px" }"#)
3071            .expect("valid two-value shorthand decodes");
3072        assert_eq!(
3073            s.padding,
3074            Some(Rect {
3075                top: Length::Px(8.0),
3076                bottom: Length::Px(8.0),
3077                right: Length::Px(16.0),
3078                left: Length::Px(16.0),
3079            })
3080        );
3081
3082        // Too many values (>4) → whole rect falls back to default, no abort.
3083        let s: Style = serde_json::from_str(r#"{ "padding": "1px 2px 3px 4px 5px" }"#)
3084            .expect("bad value-count must not abort");
3085        assert_eq!(s.padding, Some(Rect::default()));
3086    }
3087
3088    /// Keyword style fields decode straight into their `bevy_ui`/`bevy_text`
3089    /// enums; `start`/`end` map to the physical `Start`/`End` variants while
3090    /// `flexStart`/`flexEnd` map to the flow-relative `FlexStart`/`FlexEnd`.
3091    /// They diverge in grid and reversed-flex containers, so the keywords must
3092    /// not collapse together.
3093    #[test]
3094    fn keyword_fields_decode_to_bevy_enums() {
3095        let s: Style = serde_json::from_value(serde_json::json!({
3096            "display": "grid",
3097            "alignItems": "start",
3098            "alignSelf": "flexStart",
3099            "alignContent": "spaceBetween",
3100            "justifyContent": "flexEnd",
3101            "flexWrap": "nowrap",
3102            "focusPolicy": "block",
3103            "textAlign": "justify",
3104            "lineBreak": "anyCharacter",
3105        }))
3106        .expect("keyword style decodes");
3107        assert_eq!(s.display, Some(Display::Grid));
3108        assert_eq!(s.align_items, Some(AlignItems::Start));
3109        assert_eq!(s.align_self, Some(AlignSelf::FlexStart));
3110        assert_eq!(s.align_content, Some(AlignContent::SpaceBetween));
3111        assert_eq!(s.justify_content, Some(JustifyContent::FlexEnd));
3112        assert_eq!(s.flex_wrap, Some(FlexWrap::NoWrap));
3113        assert_eq!(s.focus_policy, Some(FocusPolicy::Block));
3114        assert_eq!(s.text_align, Some(Justify::Justified));
3115        assert_eq!(s.line_break, Some(LineBreak::AnyCharacter));
3116
3117        let s: Style = serde_json::from_value(serde_json::json!({
3118            "alignItems": "flexStart",
3119            "justifyContent": "start",
3120            // both keyword spellings of boxSizing are accepted
3121            "boxSizing": "border-box",
3122            "flexWrap": "noWrap",
3123        }))
3124        .expect("alias keywords decode");
3125        assert_eq!(s.align_items, Some(AlignItems::FlexStart));
3126        assert_eq!(s.justify_content, Some(JustifyContent::Start));
3127        assert_eq!(s.box_sizing, Some(BoxSizing::BorderBox));
3128        assert_eq!(s.flex_wrap, Some(FlexWrap::NoWrap));
3129    }
3130
3131    /// An unrecognized enum keyword falls back to the bevy default (and warns)
3132    /// rather than aborting the batch or being silently dropped — a valid
3133    /// sibling field still decodes.
3134    #[test]
3135    fn unknown_enum_keywords_fall_back_to_default() {
3136        let s: Style = serde_json::from_value(serde_json::json!({
3137            "display": "flx",
3138            "alignItems": "centre",
3139            "flexDirection": "sideways",
3140            "textAlign": "middle",
3141            "fontWeight": "heavyish",
3142            "focusPolicy": "weird",
3143            // A valid sibling proves the fallbacks didn't abort the Style.
3144            "lineBreak": "wordBoundary",
3145        }))
3146        .expect("bad keywords must not abort deserialization");
3147        assert_eq!(s.display, Some(Display::default()));
3148        assert_eq!(s.align_items, Some(AlignItems::default()));
3149        assert_eq!(s.flex_direction, Some(FlexDirection::default()));
3150        assert_eq!(s.text_align, Some(Justify::default()));
3151        assert_eq!(s.font_weight, Some(FontWeight::NORMAL));
3152        assert_eq!(s.focus_policy, Some(FocusPolicy::Pass));
3153        assert_eq!(s.line_break, Some(LineBreak::WordBoundary));
3154    }
3155
3156    /// `fontWeight` takes a named keyword or a numeric weight string.
3157    #[test]
3158    fn font_weight_keywords_and_numeric() {
3159        let fw = |v: serde_json::Value| {
3160            serde_json::from_value::<Style>(serde_json::json!({ "fontWeight": v }))
3161                .expect("fontWeight decodes")
3162                .font_weight
3163        };
3164        assert_eq!(fw("bold".into()), Some(FontWeight::BOLD));
3165        assert_eq!(fw("600".into()), Some(FontWeight(600)));
3166        assert_eq!(fw("thin".into()), Some(FontWeight::THIN));
3167    }
3168
3169    /// Grid templates/placements parse once at decode into the bevy types.
3170    #[test]
3171    fn grid_templates_and_placement_decode() {
3172        let s: Style = serde_json::from_value(serde_json::json!({
3173            "gridTemplateColumns": "1fr 2fr 100px",
3174            "gridTemplateRows": "repeat(3, 1fr)",
3175            "gridAutoRows": "auto 40px",
3176        }))
3177        .expect("grid template decodes");
3178        assert_eq!(s.grid_template_columns.map(|t| t.len()), Some(3));
3179        assert_eq!(s.grid_template_rows.map(|t| t.len()), Some(1));
3180        assert_eq!(s.grid_auto_rows.map(|t| t.len()), Some(2));
3181
3182        // An unparsable track is skipped (warned); the rest survive.
3183        let s: Style =
3184            serde_json::from_value(serde_json::json!({ "gridTemplateRows": "1fr bogus 2fr" }))
3185                .expect("bad track must not abort");
3186        assert_eq!(s.grid_template_rows.map(|t| t.len()), Some(2));
3187
3188        let placed = |v: &str| {
3189            let s: Style = serde_json::from_value(serde_json::json!({ "gridRow": v }))
3190                .expect("grid placement decodes");
3191            format!("{:?}", s.grid_row.unwrap())
3192        };
3193        let expect = |p: GridPlacement| format!("{p:?}");
3194        assert_eq!(placed("1 / 3"), expect(GridPlacement::start_end(1, 3)));
3195        assert_eq!(placed("span 2"), expect(GridPlacement::span(2)));
3196        assert_eq!(
3197            placed("2 / span 3"),
3198            expect(GridPlacement::start_span(2, 3))
3199        );
3200        assert_eq!(placed("2 / 2"), expect(GridPlacement::start_end(2, 2)));
3201        assert_eq!(placed("-1"), expect(GridPlacement::start(-1)));
3202        assert_eq!(placed("2 / auto"), expect(GridPlacement::start(2)));
3203        assert_eq!(placed("auto / 3"), expect(GridPlacement::end(3)));
3204    }
3205
3206    /// A zero grid line/span is invalid CSS and panics `GridPlacement`'s
3207    /// constructors — every zero-bearing form must warn and fall back to `auto`
3208    /// at decode, never reach the constructor or degrade to a partial placement.
3209    #[test]
3210    fn grid_placement_zero_falls_back_to_auto() {
3211        let placed = |v: &str| {
3212            let s: Style = serde_json::from_value(serde_json::json!({ "gridRow": v }))
3213                .expect("zero placement must not abort");
3214            format!("{:?}", s.grid_row.unwrap())
3215        };
3216        let auto = format!("{:?}", GridPlacement::auto());
3217        for s in ["0", "span 0", "0 / 2", "2 / 0", "0 / span 2", "2 / span 0"] {
3218            assert_eq!(placed(s), auto, "input {s:?}");
3219        }
3220        // Unrecognized garbage also falls back rather than panicking.
3221        assert_eq!(placed("garbage"), auto);
3222    }
3223
3224    /// A `filter` decodes *through* `Style` into the layer-based chain (the
3225    /// chain's own decode is unit-tested in `crate::filters`): a single
3226    /// `{name, params}` object is a 1-element chain, an array preserves order,
3227    /// and a malformed entry degrades the whole chain to empty without
3228    /// aborting the containing `Style`.
3229    #[test]
3230    fn deserializes_filter_chain() {
3231        use crate::filters::FilterChain;
3232
3233        // A single object is a 1-element chain; params stay a raw map.
3234        let s: Style =
3235            serde_json::from_str(r#"{ "filter": { "name": "blur", "params": { "radius": 4 } } }"#)
3236                .expect("filter decodes");
3237        let chain = s.filter.expect("filter present");
3238        assert_eq!(chain.0.len(), 1);
3239        assert_eq!(chain.0[0].name, "blur");
3240        assert_eq!(chain.0[0].params["radius"], serde_json::json!(4));
3241
3242        // An array preserves declaration order (chain order = pass order).
3243        let s: Style =
3244            serde_json::from_str(r#"{ "filter": [{ "name": "blur" }, { "name": "grayscale" }] }"#)
3245                .expect("filter decodes");
3246        let names: Vec<&str> = s
3247            .filter
3248            .as_ref()
3249            .expect("filter present")
3250            .0
3251            .iter()
3252            .map(|u| u.name.as_str())
3253            .collect();
3254        assert_eq!(names, ["blur", "grayscale"]);
3255
3256        // A malformed entry degrades the whole chain to empty without
3257        // aborting the Style — the sibling field still decodes.
3258        let s: Style =
3259            serde_json::from_str(r#"{ "filter": [{ "name": "blur" }, 3], "opacity": 0.5 }"#)
3260                .expect("a bad filter entry must not abort the style");
3261        assert_eq!(s.filter, Some(FilterChain::default()));
3262        assert_eq!(s.opacity.static_val(), Some(0.5));
3263    }
3264
3265    /// A `filter` delta dirties FILTER (the `FilterInput` re-stamp) and LAYER
3266    /// (the promotion evaluator's trigger); a variant carrying a filter rides
3267    /// the `hover_style` flag, which the reconciler also treats as a layer
3268    /// trigger (variant filters promote — the field is `overlay`).
3269    #[test]
3270    fn filter_delta_dirties_filter_and_layer() {
3271        let mut cached = Props::default();
3272        let (dirty, _) = cached.merge_delta(
3273            props(serde_json::json!({ "style": { "filter": { "name": "blur" } } })),
3274            &[],
3275            &[],
3276        );
3277        assert!(dirty.style.intersects(style_groups::FILTER));
3278        assert!(dirty.style.intersects(style_groups::LAYER));
3279
3280        let (dirty, _) = cached.merge_delta(
3281            props(serde_json::json!({ "hoverStyle": { "filter": { "name": "blur" } } })),
3282            &[],
3283            &[],
3284        );
3285        assert!(dirty.hover_style);
3286        let hover = cached.hover_style.as_ref().expect("variant retained");
3287        assert!(hover.filter.is_some(), "variant carries the chain");
3288    }
3289
3290    /// A `backdropFilter` delta dirties BACKDROP (the `BackdropInput`
3291    /// re-stamp) and LAYER (the promotion trigger) — and never FILTER: the
3292    /// two chains are independent channels. `styleUnset` re-fires the same
3293    /// groups so the removal reaches the apply arm and the evaluator.
3294    #[test]
3295    fn backdrop_filter_delta_dirties_backdrop_and_layer() {
3296        let mut cached = Props::default();
3297        let (dirty, _) = cached.merge_delta(
3298            props(serde_json::json!({ "style": { "backdropFilter": { "name": "blur" } } })),
3299            &[],
3300            &[],
3301        );
3302        assert!(dirty.style.intersects(style_groups::BACKDROP));
3303        assert!(dirty.style.intersects(style_groups::LAYER));
3304        assert!(!dirty.style.intersects(style_groups::FILTER));
3305        assert!(
3306            cached
3307                .style
3308                .as_ref()
3309                .is_some_and(|s| s.backdrop_filter.is_some())
3310        );
3311
3312        let (dirty, _) = cached.merge_delta(Props::default(), &[], &["backdropFilter".into()]);
3313        assert!(dirty.style.intersects(style_groups::BACKDROP));
3314        assert!(dirty.style.intersects(style_groups::LAYER));
3315        assert!(
3316            cached
3317                .style
3318                .as_ref()
3319                .is_some_and(|s| s.backdrop_filter.is_none())
3320        );
3321    }
3322
3323    /// A `change` event serializes its new text as camelCase `value`, while the
3324    /// pointer-only fields stay omitted.
3325    #[test]
3326    fn serializes_change_event_with_value() {
3327        let ev = UiEvent {
3328            id: 7,
3329            kind: "change".into(),
3330            value: Some("hello".into()),
3331            ..Default::default()
3332        };
3333        let v = serde_json::to_value(&ev).expect("serializable");
3334        assert_eq!(v["kind"], "change");
3335        assert_eq!(v["value"], "hello");
3336        assert!(v.get("clientX").is_none(), "pointer fields omitted");
3337        assert!(v.get("button").is_none(), "button omitted on text events");
3338    }
3339
3340    /// A pointer event carries the DOM button number; button-less events omit it
3341    /// entirely (see the `serializes_change_event_with_value` assertion above).
3342    #[test]
3343    fn serializes_pointer_event_with_button() {
3344        let ev = UiEvent {
3345            id: 3,
3346            kind: "pointerDown".into(),
3347            button: Some(2),
3348            ..Default::default()
3349        };
3350        let v = serde_json::to_value(&ev).expect("serializable");
3351        assert_eq!(v["kind"], "pointerDown");
3352        assert_eq!(v["button"], 2);
3353    }
3354
3355    /// Compile-time completeness guard: a `Style` struct literal built from the
3356    /// field table must name every field — adding a `Style` field without
3357    /// extending `with_style_fields!` fails this with E0063 (missing field).
3358    #[test]
3359    fn style_field_table_is_complete() {
3360        macro_rules! build_full {
3361            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
3362                Style { $($f: None,)* }
3363            };
3364        }
3365        let _style: Style = with_style_fields!(build_full);
3366    }
3367
3368    /// Every table wire name must equal serde's `rename_all = "camelCase"`
3369    /// rendering of the field ident, or `unset_field`/the JS delta builder
3370    /// would miss the field.
3371    #[test]
3372    fn style_wire_names_match_serde_rename() {
3373        fn camel(s: &str) -> String {
3374            let mut out = String::new();
3375            let mut up = false;
3376            for c in s.chars() {
3377                if c == '_' {
3378                    up = true;
3379                } else if up {
3380                    out.extend(c.to_uppercase());
3381                    up = false;
3382                } else {
3383                    out.push(c);
3384                }
3385            }
3386            out
3387        }
3388        macro_rules! check {
3389            ($(($f:ident, $name:literal, $g:tt, $ov:ident),)*) => {
3390                $( assert_eq!(camel(stringify!($f)), $name, "table wire name for `{}`", stringify!($f)); )*
3391            };
3392        }
3393        with_style_fields!(check);
3394    }
3395
3396    fn props(json: serde_json::Value) -> Props {
3397        serde_json::from_value(json).expect("valid props")
3398    }
3399
3400    /// A delta sets exactly the supplied fields; everything else is preserved.
3401    #[test]
3402    fn merge_delta_sets_and_preserves() {
3403        let mut cached = props(serde_json::json!({
3404            "style": { "backgroundColor": "red", "outline": { "color": "white" } },
3405            "hoverStyle": { "backgroundColor": "blue" },
3406            "onClick": true,
3407            "src": "a.png",
3408        }));
3409        let (dirty, ev) = cached.merge_delta(
3410            props(serde_json::json!({ "style": { "width": 100 } })),
3411            &[],
3412            &[],
3413        );
3414
3415        let style = cached.style.as_ref().unwrap();
3416        assert_eq!(style.width.static_val(), Some(Length::Px(100.0)));
3417        assert_eq!(
3418            style.background_color.static_ref().map(String::as_str),
3419            Some("red")
3420        );
3421        assert!(style.outline.is_some(), "untouched style fields preserved");
3422        assert!(cached.hover_style.is_some(), "untouched props preserved");
3423        assert!(cached.on_click);
3424        assert_eq!(cached.src.as_deref(), Some("a.png"));
3425
3426        assert!(dirty.style.intersects(style_groups::LAYOUT));
3427        assert!(
3428            !dirty
3429                .style
3430                .intersects(style_groups::BACKGROUND | style_groups::OUTLINE),
3431            "untouched groups must stay clean"
3432        );
3433        assert!(!dirty.hover_style && !dirty.pointer && !dirty.image);
3434        // `width` is a transitioned channel, so the transition group re-arms.
3435        assert!(dirty.style.intersects(style_groups::TRANSITION));
3436        assert!(ev.value.is_none() && ev.draw.is_none());
3437    }
3438
3439    /// `unset` resets props (bools to false, options to None); `style_unset`
3440    /// clears style fields — even when the delta carries no `style` object.
3441    #[test]
3442    fn merge_delta_unsets() {
3443        let mut cached = props(serde_json::json!({
3444            "style": { "backgroundColor": "red", "width": 50 },
3445            "hoverStyle": { "backgroundColor": "blue" },
3446            "onClick": true,
3447        }));
3448        let (dirty, _) = cached.merge_delta(
3449            Props::default(),
3450            &["hoverStyle".into(), "onClick".into()],
3451            &["backgroundColor".into()],
3452        );
3453
3454        let style = cached.style.as_ref().unwrap();
3455        assert_eq!(style.background_color, None);
3456        assert_eq!(
3457            style.width.static_val(),
3458            Some(Length::Px(50.0)),
3459            "other style fields kept"
3460        );
3461        assert!(cached.hover_style.is_none());
3462        assert!(!cached.on_click);
3463        assert!(dirty.style.intersects(style_groups::BACKGROUND));
3464        assert!(!dirty.style.intersects(style_groups::LAYOUT));
3465        assert!(dirty.hover_style && dirty.pointer);
3466        assert!(dirty.any_style_variant());
3467    }
3468
3469    /// The bool-flag contract the JS diff relies on (bridge.ts
3470    /// `BOOL_PROP_KEYS`): a plain-`bool` field can't distinguish an explicit
3471    /// `false` from absent on the wire, so a `false` in the delta is a no-op —
3472    /// turning a flag off must ride `unset`, which resets it and dirties its
3473    /// group.
3474    #[test]
3475    fn merge_delta_bool_false_is_noop_off_rides_unset() {
3476        let mut cached = props(serde_json::json!({ "flipX": true, "flipY": true }));
3477
3478        // `{"flipX": false}` decodes identically to an absent field: no-op.
3479        let (dirty, _) = cached.merge_delta(props(serde_json::json!({ "flipX": false })), &[], &[]);
3480        assert!(cached.flip_x, "explicit false in a delta must not clear");
3481        assert!(!dirty.image);
3482
3483        // The off path: `unset` resets the flag and dirties the image group.
3484        let (dirty, _) = cached.merge_delta(Props::default(), &["flipX".into()], &[]);
3485        assert!(!cached.flip_x);
3486        assert!(cached.flip_y, "sibling flag untouched");
3487        assert!(dirty.image);
3488    }
3489
3490    /// `"style"` in `unset` drops the whole style and dirties every group.
3491    #[test]
3492    fn merge_delta_unsets_style_wholesale() {
3493        let mut cached = props(serde_json::json!({
3494            "style": { "backgroundColor": "red", "width": 50 },
3495        }));
3496        let (dirty, _) = cached.merge_delta(Props::default(), &["style".into()], &[]);
3497        assert!(cached.style.is_none());
3498        assert_eq!(dirty.style, StyleDirty::ALL);
3499    }
3500
3501    /// Event-like fields ride out through `UpdateEvents` and are never retained.
3502    #[test]
3503    fn merge_delta_events_not_cached() {
3504        let mut cached = Props::default();
3505        let (dirty, ev) = cached.merge_delta(
3506            props(serde_json::json!({
3507                "value": "hi", "selectionStart": 1, "selectionEnd": 3,
3508                "scrollTop": 40.0, "scrollLeft": 2.0,
3509            })),
3510            &[],
3511            &[],
3512        );
3513        assert_eq!(ev.value.as_deref(), Some("hi"));
3514        assert_eq!((ev.selection_start, ev.selection_end), (Some(1), Some(3)));
3515        assert_eq!((ev.scroll_top, ev.scroll_left), (Some(40.0), Some(2.0)));
3516        assert!(cached.value.is_none() && cached.scroll_top.is_none());
3517        assert!(cached.selection_start.is_none());
3518        // Event fields alone dirty nothing.
3519        assert!(!dirty.style.any() && !dirty.image && !dirty.anchor);
3520    }
3521
3522    /// Variant styles replace atomically: a delta `hoverStyle` is the whole new
3523    /// value, not a merge into the previous one.
3524    #[test]
3525    fn merge_delta_replaces_variants_atomically() {
3526        let mut cached = props(serde_json::json!({
3527            "hoverStyle": { "backgroundColor": "blue", "width": 10 },
3528        }));
3529        let (dirty, _) = cached.merge_delta(
3530            props(serde_json::json!({ "hoverStyle": { "outline": { "color": "white" } } })),
3531            &[],
3532            &[],
3533        );
3534        let hover = cached.hover_style.as_ref().unwrap();
3535        assert!(hover.outline.is_some());
3536        assert_eq!(hover.background_color, None, "atomic replace, not a merge");
3537        assert_eq!(hover.width, None);
3538        assert!(dirty.hover_style);
3539    }
3540
3541    /// Unknown names in `unset`/`style_unset` warn and are ignored — a delta
3542    /// from a newer/older bundle must never panic the op drain.
3543    #[test]
3544    fn merge_delta_ignores_unknown_names() {
3545        let mut cached = props(serde_json::json!({ "style": { "width": 10 } }));
3546        let (dirty, _) = cached.merge_delta(
3547            Props::default(),
3548            &["nope".into(), "value".into()],
3549            &["alsoNope".into()],
3550        );
3551        assert_eq!(
3552            cached.style.as_ref().unwrap().width.static_val(),
3553            Some(Length::Px(10.0))
3554        );
3555        assert!(!dirty.style.any());
3556    }
3557
3558    /// Two sequential deltas converge to the same state as one combined delta.
3559    #[test]
3560    fn merge_delta_converges() {
3561        let base = serde_json::json!({
3562            "style": { "backgroundColor": "red", "width": 10 }, "onClick": true,
3563        });
3564        let mut two_steps = props(base.clone());
3565        two_steps.merge_delta(
3566            props(serde_json::json!({ "style": { "width": 20 } })),
3567            &[],
3568            &[],
3569        );
3570        two_steps.merge_delta(
3571            props(serde_json::json!({ "style": { "height": 5 } })),
3572            &[],
3573            &["backgroundColor".into()],
3574        );
3575
3576        let mut one_step = props(base);
3577        one_step.merge_delta(
3578            props(serde_json::json!({ "style": { "width": 20, "height": 5 } })),
3579            &[],
3580            &["backgroundColor".into()],
3581        );
3582
3583        let a = two_steps.style.as_ref().unwrap();
3584        let b = one_step.style.as_ref().unwrap();
3585        assert_eq!(a.width, b.width);
3586        assert_eq!(a.height, b.height);
3587        assert_eq!(a.background_color, b.background_color);
3588        assert!(two_steps.on_click && one_step.on_click);
3589    }
3590
3591    /// `split_events` strips exactly the event-like fields, leaving state.
3592    #[test]
3593    fn split_events_strips_event_fields() {
3594        let full = props(serde_json::json!({
3595            "style": { "width": 10 }, "onClick": true, "value": "v",
3596            "selectionStart": 0, "selectionEnd": 1, "scrollTop": 5.0,
3597        }));
3598        let (state, ev) = full.split_events();
3599        assert!(state.style.is_some() && state.on_click);
3600        assert!(state.value.is_none() && state.selection_start.is_none());
3601        assert!(state.scroll_top.is_none());
3602        assert_eq!(ev.value.as_deref(), Some("v"));
3603        assert_eq!(ev.scroll_top, Some(5.0));
3604    }
3605
3606    /// An `update` op decodes with and without the unset lists — `styleUnset`
3607    /// in particular must land in `style_unset` (the enum's `rename_all`
3608    /// doesn't cover variant fields).
3609    #[test]
3610    fn deserializes_update_delta_form() {
3611        let minimal: Op = serde_json::from_str(r#"{"op":"update","id":3,"props":{}}"#).unwrap();
3612        match minimal {
3613            Op::Update {
3614                unset, style_unset, ..
3615            } => {
3616                assert!(unset.is_empty() && style_unset.is_empty());
3617            }
3618            other => panic!("expected update, got {other:?}"),
3619        }
3620        let full: Op = serde_json::from_str(
3621            r#"{"op":"update","id":3,"props":{"style":{"width":1}},
3622                "unset":["onClick"],"styleUnset":["backgroundColor"]}"#,
3623        )
3624        .unwrap();
3625        match full {
3626            Op::Update {
3627                unset, style_unset, ..
3628            } => {
3629                assert_eq!(unset, vec!["onClick"]);
3630                assert_eq!(style_unset, vec!["backgroundColor"]);
3631            }
3632            other => panic!("expected update, got {other:?}"),
3633        }
3634    }
3635
3636    /// A `draw` op decodes, including the clear commands (the imperative
3637    /// canvas path). Struct-variant fields aren't renamed by the enum's
3638    /// `rename_all`, so the wire form is pinned here.
3639    #[test]
3640    fn deserializes_draw_op() {
3641        let op: Op = serde_json::from_str(
3642            r##"{"op":"draw","id":7,"cmds":[
3643                {"cmd":"clear"},
3644                {"cmd":"clearRect","x":1.0,"y":2.0,"w":3.0,"h":4.0},
3645                {"cmd":"fillStyle","color":"#f00"}
3646            ]}"##,
3647        )
3648        .unwrap();
3649        match op {
3650            Op::Draw { id, cmds } => {
3651                assert_eq!(id, 7);
3652                assert_eq!(cmds.len(), 3);
3653                assert_eq!(cmds[0], DrawCmd::Clear);
3654                assert_eq!(
3655                    cmds[1],
3656                    DrawCmd::ClearRect {
3657                        x: 1.0,
3658                        y: 2.0,
3659                        w: 3.0,
3660                        h: 4.0
3661                    }
3662                );
3663                assert_eq!(
3664                    cmds[2],
3665                    DrawCmd::FillStyle {
3666                        color: "#f00".into()
3667                    }
3668                );
3669            }
3670            other => panic!("expected draw, got {other:?}"),
3671        }
3672    }
3673
3674    /// A `"resize"` UI event serializes its logical size and omits every other
3675    /// optional field.
3676    #[test]
3677    fn serializes_resize_ui_event() {
3678        let v = serde_json::to_value(Outbound::UiEvent {
3679            event: UiEvent {
3680                id: 5,
3681                kind: "resize".into(),
3682                width: Some(300.0),
3683                height: Some(150.0),
3684                ..Default::default()
3685            },
3686        })
3687        .unwrap();
3688        assert_eq!(v["t"], "uiEvent");
3689        let ev = &v["event"];
3690        assert_eq!(ev["id"], 5);
3691        assert_eq!(ev["kind"], "resize");
3692        assert_eq!(ev["width"], 300.0);
3693        assert_eq!(ev["height"], 150.0);
3694        assert!(ev.get("x").is_none() && ev.get("scrollTop").is_none());
3695    }
3696
3697    /// `onResize` decodes, merges into the cache, and unsets without warning —
3698    /// it gates nothing Rust-side, so it dirties nothing.
3699    #[test]
3700    fn merge_delta_on_resize_flag() {
3701        let mut cached = Props::default();
3702        let (dirty, _) =
3703            cached.merge_delta(props(serde_json::json!({ "onResize": true })), &[], &[]);
3704        assert!(cached.on_resize);
3705        assert!(!dirty.pointer && !dirty.scroll_listener);
3706        cached.merge_delta(Props::default(), &["onResize".into()], &[]);
3707        assert!(!cached.on_resize);
3708    }
3709
3710    /// `onWheel` sets the `wheel` dirty flag on appearance and clears it on `unset`,
3711    /// independent of the scroll flags.
3712    #[test]
3713    fn merge_delta_wheel_flag() {
3714        let mut cached = Props::default();
3715        let (dirty, _) =
3716            cached.merge_delta(props(serde_json::json!({ "onWheel": true })), &[], &[]);
3717        assert!(cached.on_wheel);
3718        assert!(dirty.wheel);
3719        assert!(!dirty.pointer && !dirty.scroll_listener);
3720
3721        let (dirty, _) = cached.merge_delta(Props::default(), &["onWheel".into()], &[]);
3722        assert!(!cached.on_wheel);
3723        assert!(dirty.wheel);
3724    }
3725
3726    /// `cursor` decodes to the raw name (keyword or custom); resolution (registry
3727    /// first, then system keyword) is deferred to `drive_cursor_icon`, like `fontFamily`.
3728    #[test]
3729    fn deserializes_cursor_name() {
3730        let s: Style = serde_json::from_str(r#"{ "cursor": "pointer" }"#).expect("cursor decodes");
3731        assert_eq!(s.cursor.as_deref(), Some("pointer"));
3732
3733        let s: Style =
3734            serde_json::from_str(r#"{ "cursor": "hand" }"#).expect("custom name decodes");
3735        assert_eq!(s.cursor.as_deref(), Some("hand"));
3736    }
3737
3738    /// A `cursor` delta sets the `CURSOR` dirty group; a `style` unset of it clears
3739    /// the field and re-arms the group.
3740    #[test]
3741    fn merge_delta_cursor_group() {
3742        let mut cached = Props::default();
3743        let (dirty, _) = cached.merge_delta(
3744            props(serde_json::json!({ "style": { "cursor": "pointer" } })),
3745            &[],
3746            &[],
3747        );
3748        assert_eq!(
3749            cached.style.as_ref().unwrap().cursor.as_deref(),
3750            Some("pointer")
3751        );
3752        assert!(dirty.style.intersects(style_groups::CURSOR));
3753        assert!(!dirty.style.intersects(style_groups::LAYOUT));
3754
3755        let (dirty, _) = cached.merge_delta(Props::default(), &[], &["cursor".into()]);
3756        assert_eq!(cached.style.as_ref().unwrap().cursor, None);
3757        assert!(dirty.style.intersects(style_groups::CURSOR));
3758    }
3759}