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