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