Skip to main content

rustmotion_core/css/
style.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::computed;
5use super::units::{Length, LengthContext, LengthPercentage, ParsedLength};
6use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow};
7
8pub const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012;
9
10pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0;
11
12/// Top-level CSS style block. All fields are optional; `None` means "not set"
13/// and lets the cascade fill in inherited / initial values.
14///
15/// # `Deserialize` is hand-written, not derived
16///
17/// See [`computed`]'s module doc for why: a `"= ..."` expression on
18/// `opacity`/`width`/`height`/a covered `transform` leaf has to survive
19/// deserialization without retyping the field it was written on (every
20/// other property still deserializes through the ordinary derived logic,
21/// via the private `CssStyleWire` mirror below — `#[serde(remote =
22/// "CssStyle")]` lets that derive construct a real `CssStyle` directly, so
23/// this hand-written impl only has to do two things: peel `"= ..."` strings
24/// off the raw JSON first ([`computed::extract`]), then hand the cleaned
25/// JSON to the derived logic and attach the extracted [`computed::ComputedStyle`]
26/// afterwards. `Serialize` stays derived, unaffected — `expr` is
27/// `#[serde(skip)]` and never round-trips through JSON.
28#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
29#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
30pub struct CssStyle {
31    pub display: Option<Display>,
32    pub position: Option<Position>,
33    pub top: Option<LengthPercentage>,
34    pub right: Option<LengthPercentage>,
35    pub bottom: Option<LengthPercentage>,
36    pub left: Option<LengthPercentage>,
37
38    pub width: Option<Size>,
39    pub height: Option<Size>,
40    pub min_width: Option<Size>,
41    pub min_height: Option<Size>,
42    pub max_width: Option<Size>,
43    pub max_height: Option<Size>,
44
45    pub margin: Option<Edges>,
46    pub padding: Option<Edges>,
47    pub border: Option<BorderEdges>,
48    pub box_sizing: Option<BoxSizing>,
49    pub aspect_ratio: Option<f32>,
50
51    pub flex_direction: Option<FlexDirection>,
52    pub flex_wrap: Option<FlexWrap>,
53    pub justify_content: Option<JustifyContent>,
54    pub align_items: Option<AlignItems>,
55    pub align_self: Option<AlignSelf>,
56    pub align_content: Option<AlignContent>,
57    pub gap: Option<Gap>,
58    pub flex_grow: Option<f32>,
59    pub flex_shrink: Option<f32>,
60    pub flex_basis: Option<Size>,
61    pub order: Option<i32>,
62
63    pub grid_template_columns: Option<Vec<GridTrack>>,
64    pub grid_template_rows: Option<Vec<GridTrack>>,
65    pub grid_column: Option<GridLine>,
66    pub grid_row: Option<GridLine>,
67    pub grid_auto_flow: Option<GridAutoFlow>,
68    pub justify_items: Option<JustifyItems>,
69    pub justify_self: Option<JustifySelf>,
70
71    pub font_family: Option<String>,
72    pub font_size: Option<Length>,
73    pub font_weight: Option<FontWeight>,
74    pub font_style: Option<FontStyle>,
75    pub line_height: Option<LineHeight>,
76    pub letter_spacing: Option<Length>,
77    pub text_align: Option<TextAlign>,
78    pub color: Option<Color>,
79    pub white_space: Option<WhiteSpace>,
80    pub overflow_wrap: Option<OverflowWrap>,
81    pub text_overflow: Option<TextOverflow>,
82    pub text_decoration: Option<TextDecoration>,
83    /// When `true` on `text`/`gradient_text`, the effective `font-size` is
84    /// shrunk (never grown) until the content fits the box it was assigned,
85    /// instead of overflowing it. This is what lets an author declare "this
86    /// text must fit here" and closes `ContentOverflowsBox` as a possible
87    /// validator failure for that node — see `apply_fixes`
88    /// (`rustmotion/src/cli/commands/validate.rs`)'s comment on why it
89    /// deliberately refuses to auto-fix that violation today: growing the
90    /// box, shrinking the font, and shortening the copy are all legitimate
91    /// fixes, and picking one was never this tool's call to make silently.
92    /// `text-autofit` removes that ambiguity by having the *author* pick
93    /// "shrink the font" up front.
94    ///
95    /// **Which box.** Two independent axes, each opt-in on its own:
96    /// - *Width*: the box's resolved width — its own `width`/`max-width` if
97    ///   set, else whatever it inherited from its parent (exactly the value
98    ///   `text`/`gradient_text` already wrap against — see
99    ///   `TextIntrinsic`/`Text::paint`). Always present once the node is
100    ///   laid out, so the width axis is always a candidate for shrinking.
101    /// - *Height*: only when the node's own box resolves to a **definite**
102    ///   height (an explicit `height`, or a parent that hands it one, e.g. a
103    ///   fixed `flex-basis`) — never an implicit/inherited one. A box that
104    ///   grows to fit its content has, by construction, nothing to overflow
105    ///   on the height axis, so there is nothing to shrink for. See
106    ///   `TextIntrinsic::measure` / `Text::paint`'s `content_height` for
107    ///   exactly how this is read (the same taffy-resolved, padding/border-
108    ///   already-subtracted content-box value on both the pre-layout measure
109    ///   path and the post-layout paint path — this is what guarantees the
110    ///   two agree on the target, not just the algorithm).
111    ///
112    /// **`white-space: nowrap`.** Does not change: nowrap still means "never
113    /// break this into multiple lines". `text-autofit` composes with it
114    /// rather than overriding it — a `nowrap` line combined with
115    /// `text-autofit: true` shrinks the *one* line until it fits the box's
116    /// width (this is `text`/`gradient_text`'s answer to Remotion's
117    /// `fitText()`), it does not start wrapping.
118    ///
119    /// **The floor.** Never shrinks below [`TEXT_AUTOFIT_MIN_FONT_PX`] — the
120    /// same calibrated legibility ratio `check_legibility`
121    /// (`rustmotion/src/cli/commands/geometry.rs`) already enforces, not a
122    /// new threshold. If the content still doesn't fit at the floor, the
123    /// floor size is used anyway (illegible-but-smallest beats an even
124    /// larger overflow) and the geometry validator's `ContentOverflowsBox`
125    /// still fires — `text-autofit` narrows that failure class, it does not
126    /// silence it.
127    pub text_autofit: Option<bool>,
128
129    pub background: Option<Background>,
130    pub border_radius: Option<BorderRadius>,
131    pub box_shadow: Option<Vec<BoxShadow>>,
132    pub text_shadow: Option<Vec<TextShadow>>,
133    pub opacity: Option<f32>,
134    pub mix_blend_mode: Option<BlendMode>,
135    pub clip_path: Option<ClipPath>,
136    /// Glossy surface treatment: a highlight on the lit side, a specular
137    /// edge along it, and a soft shade opposite. All three are derived from
138    /// the scene's `light`, so several materials in one scene agree about
139    /// where the light is.
140    ///
141    /// Follows the node's **box**, clipped by `border-radius` and by
142    /// `clip-path` when set — not by a `shape` component's own geometry,
143    /// which paints itself. Give the box the silhouette.
144    pub material: Option<Material>,
145    /// Gradient-colored border painted instead of `border` when present.
146    /// `{ "colors": [...], "width": 2, "angle": 0 }` — angle follows the same
147    /// convention as `background` linear gradients.
148    pub gradient_border: Option<GradientBorder>,
149
150    /// Deprecated: use `backdrop-filter: [{ "fn": "blur", "radius": N }]`.
151    pub backdrop_blur: Option<f32>,
152    /// Deprecated: use `box-shadow` with `"inset": true`.
153    pub inner_shadow: Option<InnerShadow>,
154
155    pub filter: Option<Vec<FilterFn>>,
156    pub backdrop_filter: Option<Vec<FilterFn>>,
157
158    pub transform: Option<Vec<TransformFn>>,
159    pub transform_origin: Option<TransformOrigin>,
160    pub perspective: Option<Length>,
161    pub perspective_origin: Option<TransformOrigin>,
162
163    /// Parallax plane depth for the scene camera (issue #90). 0 = locked
164    /// plane (the camera does not affect it), 1 = normal plane (default),
165    /// above 1 = amplified foreground. v1: effective on direct children of
166    /// the scene root only (each top-level child is one plane whose depth
167    /// governs its whole subtree). Not inherited via cascade.
168    pub depth: Option<f32>,
169
170    /// Projects this container's direct children onto a cylinder or sphere
171    /// instead of the flat plane taffy laid them out on (issue #387). Layout
172    /// itself is untouched — columns and rows are still computed flat — this
173    /// only bends where each child is *painted*, around one vanishing point
174    /// shared by the whole container, which is what makes it different from
175    /// giving every child its own `transform`/`perspective`: those never
176    /// agree on a horizon, so a tilted grid of them stays a flat trapezoid.
177    /// See `rules/layout-surface.md`.
178    pub layout_surface: Option<LayoutSurface>,
179
180    pub overflow: Option<Overflow>,
181    pub overflow_x: Option<Overflow>,
182    pub overflow_y: Option<Overflow>,
183    pub z_index: Option<i32>,
184    pub visibility: Option<Visibility>,
185
186    #[serde(default, deserialize_with = "deserialize_animation_effects")]
187    pub animation: Vec<AnimationEffect>,
188    /// Smoothing for `timeline` style-state changes. Supported properties:
189    /// `opacity`; `color` on text/counter; `background` (solid colour only)
190    /// and `border-radius` (single uniform, absolute-px value only) — see
191    /// `box_builder.rs`'s `transition_keyframes`/
192    /// `resolve_transition_css_overrides`. Everything else — including a
193    /// `background`/`border-radius` value outside the shape those two
194    /// support (gradients, per-corner radii, `%`/`em`/`rem`/`vw`/`vh`) —
195    /// still snaps at the step's `at`; `rustmotion validate`'s
196    /// `check_transition_smoothing` (`validate_schema.rs`) reports exactly
197    /// which property and why whenever it would otherwise snap silently.
198    pub transition: Option<StyleTransition>,
199
200    #[serde(default)]
201    pub audio_reactive: Option<AudioReactive>,
202
203    /// The `"= ..."` expressions [`computed::extract`] pulled off this
204    /// node's `opacity`/`width`/`height`/covered `transform` leaves at
205    /// deserialize time — never part of the wire format (`#[serde(skip)]`:
206    /// it neither reads from nor writes to JSON), populated only by
207    /// `CssStyle`'s own hand-written `Deserialize` impl below. See
208    /// [`computed`]'s module doc.
209    #[serde(skip)]
210    #[schemars(skip)]
211    pub expr: computed::ComputedStyle,
212}
213
214#[derive(Deserialize, Default)]
215#[serde(
216    remote = "CssStyle",
217    default,
218    deny_unknown_fields,
219    rename_all = "kebab-case"
220)]
221struct CssStyleWire {
222    display: Option<Display>,
223    position: Option<Position>,
224    top: Option<LengthPercentage>,
225    right: Option<LengthPercentage>,
226    bottom: Option<LengthPercentage>,
227    left: Option<LengthPercentage>,
228
229    width: Option<Size>,
230    height: Option<Size>,
231    min_width: Option<Size>,
232    min_height: Option<Size>,
233    max_width: Option<Size>,
234    max_height: Option<Size>,
235
236    margin: Option<Edges>,
237    padding: Option<Edges>,
238    border: Option<BorderEdges>,
239    box_sizing: Option<BoxSizing>,
240    aspect_ratio: Option<f32>,
241
242    flex_direction: Option<FlexDirection>,
243    flex_wrap: Option<FlexWrap>,
244    justify_content: Option<JustifyContent>,
245    align_items: Option<AlignItems>,
246    align_self: Option<AlignSelf>,
247    align_content: Option<AlignContent>,
248    gap: Option<Gap>,
249    flex_grow: Option<f32>,
250    flex_shrink: Option<f32>,
251    flex_basis: Option<Size>,
252    order: Option<i32>,
253
254    grid_template_columns: Option<Vec<GridTrack>>,
255    grid_template_rows: Option<Vec<GridTrack>>,
256    grid_column: Option<GridLine>,
257    grid_row: Option<GridLine>,
258    grid_auto_flow: Option<GridAutoFlow>,
259    justify_items: Option<JustifyItems>,
260    justify_self: Option<JustifySelf>,
261
262    font_family: Option<String>,
263    font_size: Option<Length>,
264    font_weight: Option<FontWeight>,
265    font_style: Option<FontStyle>,
266    line_height: Option<LineHeight>,
267    letter_spacing: Option<Length>,
268    text_align: Option<TextAlign>,
269    color: Option<Color>,
270    white_space: Option<WhiteSpace>,
271    overflow_wrap: Option<OverflowWrap>,
272    text_overflow: Option<TextOverflow>,
273    text_decoration: Option<TextDecoration>,
274    text_autofit: Option<bool>,
275
276    background: Option<Background>,
277    border_radius: Option<BorderRadius>,
278    box_shadow: Option<Vec<BoxShadow>>,
279    text_shadow: Option<Vec<TextShadow>>,
280    opacity: Option<f32>,
281    mix_blend_mode: Option<BlendMode>,
282    clip_path: Option<ClipPath>,
283    material: Option<Material>,
284    gradient_border: Option<GradientBorder>,
285
286    backdrop_blur: Option<f32>,
287    inner_shadow: Option<InnerShadow>,
288
289    filter: Option<Vec<FilterFn>>,
290    backdrop_filter: Option<Vec<FilterFn>>,
291
292    transform: Option<Vec<TransformFn>>,
293    transform_origin: Option<TransformOrigin>,
294    perspective: Option<Length>,
295    perspective_origin: Option<TransformOrigin>,
296
297    depth: Option<f32>,
298
299    layout_surface: Option<LayoutSurface>,
300
301    overflow: Option<Overflow>,
302    overflow_x: Option<Overflow>,
303    overflow_y: Option<Overflow>,
304    z_index: Option<i32>,
305    visibility: Option<Visibility>,
306
307    #[serde(default, deserialize_with = "deserialize_animation_effects")]
308    animation: Vec<AnimationEffect>,
309    transition: Option<StyleTransition>,
310
311    #[serde(default)]
312    audio_reactive: Option<AudioReactive>,
313
314    #[serde(skip)]
315    expr: computed::ComputedStyle,
316}
317
318impl<'de> Deserialize<'de> for CssStyle {
319    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
320    where
321        D: serde::Deserializer<'de>,
322    {
323        let mut value = serde_json::Value::deserialize(deserializer)?;
324        let expr = match value.as_object_mut() {
325            Some(obj) => computed::extract(obj).map_err(serde::de::Error::custom)?,
326            None => computed::ComputedStyle::default(),
327        };
328        let mut style = CssStyleWire::deserialize(value).map_err(serde::de::Error::custom)?;
329        style.expr = expr;
330        Ok(style)
331    }
332}
333
334/// `transition` config: bare number = duration in seconds with the default
335/// easing, or a `{ duration, easing }` object.
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
337#[serde(untagged)]
338pub enum StyleTransition {
339    Duration(f64),
340    Config {
341        duration: f64,
342        #[serde(default = "default_transition_easing")]
343        easing: crate::schema::EasingType,
344    },
345}
346
347fn default_transition_easing() -> crate::schema::EasingType {
348    crate::schema::EasingType::EaseInOut
349}
350
351/// Bind a CSS property to audio analysis data.
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
353#[serde(deny_unknown_fields)]
354pub struct AudioReactive {
355    /// Audio track src key. If None, uses the first entry in the cache.
356    #[serde(default)]
357    pub track: Option<String>,
358    /// Which audio data source to use.
359    pub source: AudioSource,
360    /// Which CSS property to modulate.
361    pub property: AudioReactiveProperty,
362    /// Value when audio is at 0.
363    pub min: f64,
364    /// Value when audio is at 1.
365    pub max: f64,
366    /// Number of previous frames to average (0 = no smoothing).
367    #[serde(default)]
368    pub smoothing_frames: u32,
369}
370
371/// The audio data source for an AudioReactive binding.
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
373#[serde(untagged)]
374pub enum AudioSource {
375    /// Overall amplitude (RMS). JSON: `"amplitude"`.
376    Amplitude(AudioSourceTag),
377    /// A specific frequency band (0..15). JSON: `{"band": 3}`.
378    Band { band: u8 },
379}
380
381/// Tag-only variant for the amplitude source.
382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
383#[serde(rename_all = "snake_case")]
384pub enum AudioSourceTag {
385    Amplitude,
386}
387
388/// Which CSS property to modulate with audio.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
390#[serde(rename_all = "snake_case")]
391pub enum AudioReactiveProperty {
392    Opacity,
393    Scale,
394    TranslateY,
395    Rotation,
396}
397
398impl StyleTransition {
399    pub fn duration(&self) -> f64 {
400        match self {
401            StyleTransition::Duration(d) => *d,
402            StyleTransition::Config { duration, .. } => *duration,
403        }
404    }
405
406    pub fn easing(&self) -> crate::schema::EasingType {
407        match self {
408            StyleTransition::Duration(_) => default_transition_easing(),
409            StyleTransition::Config { easing, .. } => easing.clone(),
410        }
411    }
412}
413
414impl CssStyle {
415    pub fn font_size_px_or(&self, default: f32) -> f32 {
416        self.font_size.as_ref().map(|l| l.px()).unwrap_or(default)
417    }
418
419    pub fn font_size_px(&self) -> Option<f32> {
420        self.font_size.as_ref().map(|l| l.px())
421    }
422
423    pub fn color_str(&self) -> Option<&str> {
424        match &self.color {
425            Some(Color::String(s)) => Some(s.as_str()),
426            _ => None,
427        }
428    }
429
430    pub fn color_str_or<'a>(&'a self, default: &'a str) -> &'a str {
431        self.color_str().unwrap_or(default)
432    }
433
434    pub fn font_family_str(&self) -> Option<&str> {
435        self.font_family.as_deref()
436    }
437
438    pub fn font_family_or<'a>(&'a self, default: &'a str) -> &'a str {
439        self.font_family.as_deref().unwrap_or(default)
440    }
441
442    pub fn letter_spacing_px(&self) -> f32 {
443        self.letter_spacing.as_ref().map(|l| l.px()).unwrap_or(0.0)
444    }
445
446    pub fn line_height_for(&self, font_size: f32) -> f32 {
447        match &self.line_height {
448            Some(LineHeight::Number(n)) => n * font_size,
449            Some(LineHeight::Length(l)) => l.px(),
450            _ => font_size * 1.3,
451        }
452    }
453
454    pub fn font_size_px_ctx(&self, ctx: &LengthContext, default: f32) -> f32 {
455        self.font_size
456            .as_ref()
457            .and_then(|l| l.parse().resolve(ctx))
458            .unwrap_or(default)
459    }
460
461    pub fn letter_spacing_px_ctx(&self, ctx: &LengthContext) -> f32 {
462        self.letter_spacing
463            .as_ref()
464            .and_then(|l| l.parse().resolve(ctx))
465            .unwrap_or(0.0)
466    }
467
468    pub fn line_height_for_ctx(&self, font_size: f32, ctx: &LengthContext) -> f32 {
469        match &self.line_height {
470            Some(LineHeight::Number(n)) => n * font_size,
471            Some(LineHeight::Length(lp)) => match lp.parse() {
472                ParsedLength::Percent(p) => p / 100.0 * font_size,
473                other => other.resolve(ctx).unwrap_or(font_size * 1.3),
474            },
475            _ => font_size * 1.3,
476        }
477    }
478
479    pub fn typography_px_ctx(
480        &self,
481        ctx: &LengthContext,
482        default_font_size: f32,
483    ) -> (f32, f32, f32) {
484        let font_size = self.font_size_px_ctx(ctx, default_font_size);
485        let own_ctx = LengthContext { font_size, ..*ctx };
486        let letter_spacing = self.letter_spacing_px_ctx(&own_ctx);
487        let line_height = self.line_height_for_ctx(font_size, &own_ctx);
488        (font_size, letter_spacing, line_height)
489    }
490
491    pub fn opacity_or(&self, default: f32) -> f32 {
492        self.opacity.unwrap_or(default)
493    }
494
495    pub fn border_radius_px(&self) -> Option<f32> {
496        match &self.border_radius {
497            Some(BorderRadius::Uniform(lp)) => Some(lp.px()),
498            Some(BorderRadius::Corners { top_left, .. }) => Some(top_left.px()),
499            None => None,
500        }
501    }
502
503    pub fn border_radius_px_or(&self, default: f32) -> f32 {
504        self.border_radius_px().unwrap_or(default)
505    }
506
507    pub fn padding_px(&self) -> (f32, f32, f32, f32) {
508        edges_px(self.padding.as_ref())
509    }
510
511    pub fn margin_px(&self) -> (f32, f32, f32, f32) {
512        edges_px(self.margin.as_ref())
513    }
514
515    pub fn background_color_str(&self) -> Option<&str> {
516        match &self.background {
517            Some(Background::Color(Color::String(s))) => Some(s.as_str()),
518            _ => None,
519        }
520    }
521}
522
523fn edges_px(e: Option<&Edges>) -> (f32, f32, f32, f32) {
524    match e {
525        Some(Edges::Uniform(v)) => {
526            let p = v.px();
527            (p, p, p, p)
528        }
529        Some(Edges::Sides {
530            top,
531            right,
532            bottom,
533            left,
534        }) => (top.px(), right.px(), bottom.px(), left.px()),
535        None => (0.0, 0.0, 0.0, 0.0),
536    }
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
540#[serde(rename_all = "kebab-case")]
541pub enum Display {
542    Block,
543    Flex,
544    Grid,
545    InlineBlock,
546    None,
547    Contents,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
551#[serde(rename_all = "kebab-case")]
552pub enum Position {
553    Static,
554    Relative,
555    Absolute,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
559#[serde(rename_all = "kebab-case")]
560pub enum BoxSizing {
561    ContentBox,
562    BorderBox,
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
566#[serde(rename_all = "kebab-case")]
567pub enum Overflow {
568    Visible,
569    Hidden,
570    Auto,
571    Scroll,
572    Clip,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
576#[serde(rename_all = "kebab-case")]
577pub enum Visibility {
578    Visible,
579    Hidden,
580}
581
582/// `width: <length>` / `width: auto` / `width: 50%` / `width: max-content` / etc.
583///
584/// Constat #6: `#[serde(untagged)]` tries variants in declaration order and
585/// keeps the first that succeeds. `Length(LengthPercentage)` has its own
586/// `String` catch-all variant that accepts *any* string — so with `Keyword`
587/// declared after `Length` (as this used to be), `"max-content"` matched
588/// `Length(String("max-content"))` before `Keyword` was ever tried:
589/// `max-content`/`min-content`/`fit-content` were unreachable, dead schema.
590/// `Keyword` must come before the `Length` catch-all; `Auto` before either
591/// is fine since it needs an exact `"auto"` match nothing else claims first.
592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
593#[serde(untagged)]
594pub enum Size {
595    Auto(AutoKw),
596    Keyword(SizeKeyword),
597    Length(LengthPercentage),
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
601#[serde(rename_all = "kebab-case")]
602pub enum AutoKw {
603    Auto,
604}
605
606#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
607#[serde(rename_all = "kebab-case")]
608pub enum SizeKeyword {
609    MaxContent,
610    MinContent,
611    FitContent,
612}
613
614/// Edge values for `margin` / `padding`. Either uniform or per-side.
615///
616/// Constat #2: `CssStyle` itself has `deny_unknown_fields`, which gives the
617/// impression that any bad key under `style` is rejected — but one level
618/// down, `Sides`'s four fields are all `#[serde(default)]` with no
619/// `deny_unknown_fields` of its own. Since this is an untagged enum, a
620/// well-meaning but unsupported shape like `{"horizontal": 20}` (the exact
621/// form the LAYOUT `margin-left` rule teaches LLMs to reach for) fails to
622/// match `Uniform` (not a scalar) and then matches `Sides` anyway — every
623/// side defaults to 0, no error. `deny_unknown_fields` here closes that: an
624/// object that isn't a recognised `{top,right,bottom,left}` shape now fails
625/// to match either variant, and the untagged enum reports it.
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
627#[serde(untagged, deny_unknown_fields)]
628pub enum Edges {
629    Uniform(LengthPercentage),
630    Sides {
631        #[serde(default)]
632        top: LengthPercentage,
633        #[serde(default)]
634        right: LengthPercentage,
635        #[serde(default)]
636        bottom: LengthPercentage,
637        #[serde(default)]
638        left: LengthPercentage,
639    },
640}
641
642impl Edges {
643    pub fn resolve(
644        &self,
645    ) -> (
646        LengthPercentage,
647        LengthPercentage,
648        LengthPercentage,
649        LengthPercentage,
650    ) {
651        match self {
652            Edges::Uniform(v) => (v.clone(), v.clone(), v.clone(), v.clone()),
653            Edges::Sides {
654                top,
655                right,
656                bottom,
657                left,
658            } => (top.clone(), right.clone(), bottom.clone(), left.clone()),
659        }
660    }
661}
662
663/// `border: 1px solid red` modeled per side.
664#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
665#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
666pub struct BorderEdges {
667    pub width: Option<Edges>,
668    pub style: Option<BorderStyle>,
669    pub color: Option<Color>,
670    /// Per-side overrides.
671    pub top: Option<BorderSide>,
672    pub right: Option<BorderSide>,
673    pub bottom: Option<BorderSide>,
674    pub left: Option<BorderSide>,
675}
676
677#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
678#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
679pub struct BorderSide {
680    pub width: Option<Length>,
681    pub style: Option<BorderStyle>,
682    pub color: Option<Color>,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
686#[serde(rename_all = "kebab-case")]
687pub enum BorderStyle {
688    None,
689    Solid,
690    Dashed,
691    Dotted,
692    Double,
693}
694
695/// Border-radius: uniform or per-corner.
696///
697/// Constat #1: every other composite in this file is kebab-case on the wire
698/// (`box-shadow` -> `offset-x`/`offset-y`, `transform-origin` -> `x`/`y`,
699/// etc. — see `rules/component-field-placement.md`). `Corners` used to be
700/// the sole snake_case outlier (`top_left`/...), with no `deny_unknown_fields`
701/// and every field defaulted — so the kebab form a CSS-literate author (or
702/// LLM) naturally writes matched *zero* declared fields, and being an
703/// untagged enum, serde didn't complain: it just produced `Corners` with
704/// every corner at 0px, silently. `rename_all = "kebab-case"` makes kebab
705/// the canonical wire form (matching every neighbour); `alias` keeps the
706/// original snake_case working for any scenario already written that way;
707/// `deny_unknown_fields` turns any other spelling (a genuine typo) into a
708/// named parse error instead of a third silent zero.
709#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
710#[serde(untagged, deny_unknown_fields)]
711pub enum BorderRadius {
712    Uniform(LengthPercentage),
713    Corners {
714        #[serde(default, alias = "top_left")]
715        #[serde(rename = "top-left")]
716        top_left: LengthPercentage,
717        #[serde(default, alias = "top_right")]
718        #[serde(rename = "top-right")]
719        top_right: LengthPercentage,
720        #[serde(default, alias = "bottom_right")]
721        #[serde(rename = "bottom-right")]
722        bottom_right: LengthPercentage,
723        #[serde(default, alias = "bottom_left")]
724        #[serde(rename = "bottom-left")]
725        bottom_left: LengthPercentage,
726    },
727}
728
729impl BorderRadius {
730    pub fn absolute_px(&self) -> Option<f32> {
731        match self {
732            BorderRadius::Uniform(lp) => match lp.try_parse() {
733                Some(crate::css::units::ParsedLength::Px(v)) => Some(v),
734                _ => None,
735            },
736            BorderRadius::Corners { .. } => None,
737        }
738    }
739}
740
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
742#[serde(rename_all = "kebab-case")]
743pub enum FlexDirection {
744    Row,
745    RowReverse,
746    Column,
747    ColumnReverse,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
751#[serde(rename_all = "kebab-case")]
752pub enum FlexWrap {
753    Nowrap,
754    Wrap,
755    WrapReverse,
756}
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
759#[serde(rename_all = "kebab-case")]
760pub enum JustifyContent {
761    FlexStart,
762    FlexEnd,
763    Center,
764    SpaceBetween,
765    SpaceAround,
766    SpaceEvenly,
767    Start,
768    End,
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
772#[serde(rename_all = "kebab-case")]
773pub enum AlignItems {
774    Stretch,
775    FlexStart,
776    FlexEnd,
777    Center,
778    Baseline,
779    Start,
780    End,
781}
782
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
784#[serde(rename_all = "kebab-case")]
785pub enum AlignSelf {
786    Auto,
787    Stretch,
788    FlexStart,
789    FlexEnd,
790    Center,
791    Baseline,
792    Start,
793    End,
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
797#[serde(rename_all = "kebab-case")]
798pub enum AlignContent {
799    Stretch,
800    FlexStart,
801    FlexEnd,
802    Center,
803    SpaceBetween,
804    SpaceAround,
805    SpaceEvenly,
806    Start,
807    End,
808}
809
810/// `gap: 8px` (uniform) or `gap: 8px 16px` (row-gap, column-gap).
811#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
812#[serde(untagged)]
813pub enum Gap {
814    Uniform(LengthPercentage),
815    RowColumn {
816        row: LengthPercentage,
817        column: LengthPercentage,
818    },
819}
820
821/// A single grid track (column or row) sizing function.
822///
823/// Variant order matters here: this is `#[serde(untagged)]`, and serde tries
824/// each variant in declaration order, keeping the first that deserializes
825/// successfully. `Length(LengthPercentage)` accepts *any* JSON number or
826/// string (its own `String` fallback variant is a catch-all), so it must be
827/// tried last — otherwise it silently swallows bare numbers (meant to be
828/// `Fr`, matching the `flex-grow` convention) and keyword strings like
829/// `"auto"` (meant to be `Keyword`).
830#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
831#[serde(untagged)]
832pub enum GridTrack {
833    /// Bare JSON number, e.g. `1` — a flex fraction, same convention as
834    /// `flex-grow`. Equivalent to the string form `"1fr"`.
835    Fr(f32),
836    /// `"auto"` / `"min-content"` / `"max-content"`.
837    Keyword(GridTrackKeyword),
838    /// Any other length/percentage, including the explicit string form of a
839    /// flex fraction (`"1fr"`), which `LengthPercentage::parse()` resolves
840    /// to the same `ParsedLength::Fr` as the bare-number form above.
841    Length(LengthPercentage),
842    /// `minmax(min, max)`
843    Minmax {
844        min: Box<GridTrack>,
845        max: Box<GridTrack>,
846    },
847}
848
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
850#[serde(rename_all = "kebab-case")]
851pub enum GridTrackKeyword {
852    Auto,
853    MinContent,
854    MaxContent,
855}
856
857#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
858#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
859#[derive(Default)]
860pub struct GridLine {
861    pub start: Option<GridLineEnd>,
862    pub end: Option<GridLineEnd>,
863    pub span: Option<u16>,
864}
865
866#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
867#[serde(untagged)]
868pub enum GridLineEnd {
869    Index(i32),
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
873#[serde(rename_all = "kebab-case")]
874pub enum GridAutoFlow {
875    Row,
876    Column,
877    RowDense,
878    ColumnDense,
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
882#[serde(rename_all = "kebab-case")]
883pub enum JustifyItems {
884    Stretch,
885    Start,
886    End,
887    Center,
888    Legacy,
889}
890
891#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
892#[serde(rename_all = "kebab-case")]
893pub enum JustifySelf {
894    Auto,
895    Stretch,
896    Start,
897    End,
898    Center,
899}
900
901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
902#[serde(untagged)]
903pub enum FontWeight {
904    Keyword(FontWeightKw),
905    Number(u16),
906}
907
908#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
909#[serde(rename_all = "kebab-case")]
910pub enum FontWeightKw {
911    Normal,
912    Bold,
913    Bolder,
914    Lighter,
915}
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
918#[serde(rename_all = "kebab-case")]
919pub enum FontStyle {
920    Normal,
921    Italic,
922    Oblique,
923}
924
925/// `line-height: 1.5` (number) or `line-height: 24px` (length).
926///
927/// Same class of bug as constat #6 on [`Size`], found while auditing this
928/// file for other untagged enums with a catch-all before a specific variant:
929/// `Length(LengthPercentage)`'s `String` fallback accepts any string, so
930/// with `Keyword` declared after it, `"normal"` matched
931/// `Length(String("normal"))` — which then resolves through
932/// `Length::px()`/`.parse()` as an unparseable length, falling back to 0 —
933/// instead of `Keyword(LineHeightKw::Normal)`. `Keyword` now comes first.
934#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
935#[serde(untagged)]
936pub enum LineHeight {
937    Number(f32),
938    Keyword(LineHeightKw),
939    Length(LengthPercentage),
940}
941
942#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
943#[serde(rename_all = "kebab-case")]
944pub enum LineHeightKw {
945    Normal,
946}
947
948#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
949#[serde(rename_all = "kebab-case")]
950pub enum TextAlign {
951    Left,
952    Right,
953    Center,
954    Justify,
955    Start,
956    End,
957}
958
959#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
960#[serde(rename_all = "kebab-case")]
961pub enum WhiteSpace {
962    Normal,
963    Nowrap,
964    Pre,
965    PreLine,
966    PreWrap,
967    BreakSpaces,
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
971#[serde(rename_all = "kebab-case")]
972pub enum OverflowWrap {
973    Normal,
974    BreakWord,
975    Anywhere,
976}
977
978#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
979#[serde(rename_all = "kebab-case")]
980pub enum TextOverflow {
981    Clip,
982    Ellipsis,
983}
984
985#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
986#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
987pub struct TextDecoration {
988    pub line: Option<TextDecorationLine>,
989    pub style: Option<TextDecorationStyle>,
990    pub color: Option<Color>,
991    pub thickness: Option<Length>,
992}
993
994#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
995#[serde(rename_all = "kebab-case")]
996pub enum TextDecorationLine {
997    None,
998    Underline,
999    Overline,
1000    LineThrough,
1001}
1002
1003#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1004#[serde(rename_all = "kebab-case")]
1005pub enum TextDecorationStyle {
1006    Solid,
1007    Double,
1008    Dotted,
1009    Dashed,
1010    Wavy,
1011}
1012
1013/// Typed color. Strings are parsed lazily ("#rgb", "rgba(..)", named colors).
1014#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1015#[serde(untagged)]
1016pub enum Color {
1017    String(String),
1018    Rgba {
1019        r: u8,
1020        g: u8,
1021        b: u8,
1022        #[serde(default = "one_f32")]
1023        a: f32,
1024    },
1025}
1026
1027fn one_f32() -> f32 {
1028    1.0
1029}
1030
1031impl Color {
1032    pub fn to_css_string(&self) -> String {
1033        match self {
1034            Color::String(s) => s.clone(),
1035            Color::Rgba { r, g, b, a } => {
1036                if *a >= 1.0 {
1037                    format!("#{r:02x}{g:02x}{b:02x}")
1038                } else {
1039                    let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
1040                    format!("#{r:02x}{g:02x}{b:02x}{alpha:02x}")
1041                }
1042            }
1043        }
1044    }
1045}
1046
1047#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1048#[serde(untagged)]
1049pub enum Background {
1050    Color(Color),
1051    Layers(Vec<BackgroundLayer>),
1052    Single(BackgroundLayer),
1053}
1054
1055impl Background {
1056    pub fn solid_hex(&self) -> Option<String> {
1057        match self {
1058            Background::Color(c) => Some(c.to_css_string()),
1059            Background::Layers(_) | Background::Single(_) => None,
1060        }
1061    }
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1065#[serde(tag = "kind", rename_all = "kebab-case")]
1066pub enum BackgroundLayer {
1067    Color {
1068        color: Color,
1069    },
1070    LinearGradient {
1071        #[serde(default)]
1072        angle: Option<f32>,
1073        stops: Vec<GradientStop>,
1074    },
1075    RadialGradient {
1076        #[serde(default)]
1077        shape: Option<RadialShape>,
1078        #[serde(default)]
1079        position: Option<TransformOrigin>,
1080        stops: Vec<GradientStop>,
1081    },
1082    ConicGradient {
1083        #[serde(default)]
1084        from: Option<f32>,
1085        #[serde(default)]
1086        position: Option<TransformOrigin>,
1087        stops: Vec<GradientStop>,
1088    },
1089    Image {
1090        url: String,
1091        #[serde(default)]
1092        size: Option<BackgroundSize>,
1093        #[serde(default)]
1094        position: Option<TransformOrigin>,
1095        #[serde(default)]
1096        repeat: Option<BackgroundRepeat>,
1097    },
1098}
1099
1100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1101#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1102pub struct GradientStop {
1103    pub color: Color,
1104    pub offset: Option<f32>,
1105}
1106
1107impl Default for GradientStop {
1108    fn default() -> Self {
1109        Self {
1110            color: Color::String("#000000".into()),
1111            offset: None,
1112        }
1113    }
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1117#[serde(rename_all = "kebab-case")]
1118pub enum RadialShape {
1119    Circle,
1120    Ellipse,
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1124#[serde(rename_all = "kebab-case")]
1125pub enum BackgroundSize {
1126    Cover,
1127    Contain,
1128    Auto,
1129    Length {
1130        width: LengthPercentage,
1131        height: LengthPercentage,
1132    },
1133}
1134
1135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1136#[serde(rename_all = "kebab-case")]
1137pub enum BackgroundRepeat {
1138    Repeat,
1139    NoRepeat,
1140    RepeatX,
1141    RepeatY,
1142    Round,
1143    Space,
1144}
1145
1146#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1147#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1148pub struct BoxShadow {
1149    pub offset_x: Length,
1150    pub offset_y: Length,
1151    pub blur: Option<Length>,
1152    pub spread: Option<Length>,
1153    pub color: Option<Color>,
1154    pub inset: Option<bool>,
1155}
1156
1157#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1158#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1159pub struct TextShadow {
1160    pub offset_x: Length,
1161    pub offset_y: Length,
1162    pub blur: Option<Length>,
1163    pub color: Option<Color>,
1164}
1165
1166impl TextShadow {
1167    pub fn to_schema(&self, ctx: &crate::css::units::LengthContext) -> crate::schema::TextShadow {
1168        crate::schema::TextShadow {
1169            color: self
1170                .color
1171                .as_ref()
1172                .map(Color::to_css_string)
1173                .unwrap_or_else(|| "#000000".to_string()),
1174            offset_x: self.offset_x.resolve(ctx),
1175            offset_y: self.offset_y.resolve(ctx),
1176            blur: self.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0),
1177        }
1178    }
1179}
1180
1181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1182#[serde(tag = "fn", rename_all = "kebab-case")]
1183pub enum TransformFn {
1184    Translate {
1185        x: LengthPercentage,
1186        #[serde(default)]
1187        y: LengthPercentage,
1188    },
1189    TranslateX {
1190        x: LengthPercentage,
1191    },
1192    TranslateY {
1193        y: LengthPercentage,
1194    },
1195    TranslateZ {
1196        z: Length,
1197    },
1198    Translate3d {
1199        x: LengthPercentage,
1200        y: LengthPercentage,
1201        z: Length,
1202    },
1203    Scale {
1204        x: f32,
1205        #[serde(default = "one_f32")]
1206        y: f32,
1207    },
1208    ScaleX {
1209        x: f32,
1210    },
1211    ScaleY {
1212        y: f32,
1213    },
1214    ScaleZ {
1215        z: f32,
1216    },
1217    Scale3d {
1218        x: f32,
1219        y: f32,
1220        z: f32,
1221    },
1222    Rotate {
1223        deg: f32,
1224    },
1225    RotateX {
1226        deg: f32,
1227    },
1228    RotateY {
1229        deg: f32,
1230    },
1231    RotateZ {
1232        deg: f32,
1233    },
1234    Rotate3d {
1235        x: f32,
1236        y: f32,
1237        z: f32,
1238        deg: f32,
1239    },
1240    Skew {
1241        x: f32,
1242        #[serde(default)]
1243        y: f32,
1244    },
1245    SkewX {
1246        x: f32,
1247    },
1248    SkewY {
1249        y: f32,
1250    },
1251    Perspective {
1252        length: Length,
1253    },
1254    Matrix {
1255        values: [f32; 6],
1256    },
1257    Matrix3d {
1258        values: [f32; 16],
1259    },
1260}
1261
1262/// `style.layout-surface` (issue #387): the shape a container's direct
1263/// children are projected onto at paint time, plus the one perspective and
1264/// turntable rotation shared by all of them. `radius`/`arc-x`/`arc-y` are
1265/// pixels/degrees, not `Length`s — this bends a fixed layout, it does not
1266/// participate in it.
1267///
1268/// `Cylinder` only curves around its `arc-x` (a horizontal wrap, like a
1269/// scroll); a child's vertical position is left exactly where taffy put it.
1270/// `Sphere` curves around both `arc-x` and `arc-y`, which is what a dome
1271/// needs. Neither closes into a full globe — `arc-x`/`arc-y` describe a
1272/// patch, typically well under 360°; wrapping dot data onto an actual closed
1273/// globe is `dot_map`'s `projection: "orthographic"` instead (a different
1274/// problem: culling a far hemisphere, not bending a flat grid).
1275///
1276/// **Depth ordering.** Children are painted in the same declaration/`z-index`
1277/// order as a flat container — a cell that curves behind another one does
1278/// not sort behind it. For an `arc-x`/`arc-y` patch well under 180° (the
1279/// intended use: a dome of cards, not a fully enclosing sphere) every child
1280/// still faces the camera, so this is not visible; a wide enough arc or
1281/// enough `rotate-y` to turn cells past the limb can make a far cell paint
1282/// over a near one. Tracked generally by #93, not solved here.
1283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1284#[serde(tag = "kind", rename_all = "kebab-case")]
1285pub enum LayoutSurface {
1286    Cylinder {
1287        /// Cylinder radius in pixels.
1288        radius: f32,
1289        /// Total angular sweep across the container's width, in degrees.
1290        #[serde(default)]
1291        arc_x: f32,
1292        /// Shared vanishing-point distance in pixels, `None` = orthographic
1293        /// (curved, but no foreshortening).
1294        #[serde(default)]
1295        perspective: Option<f32>,
1296        #[serde(default)]
1297        rotate_x: Option<SurfaceAngle>,
1298        #[serde(default)]
1299        rotate_y: Option<SurfaceAngle>,
1300    },
1301    Sphere {
1302        /// Sphere radius in pixels.
1303        radius: f32,
1304        /// Total angular sweep across the container's width, in degrees.
1305        #[serde(default)]
1306        arc_x: f32,
1307        /// Total angular sweep across the container's height, in degrees.
1308        #[serde(default)]
1309        arc_y: f32,
1310        /// Shared vanishing-point distance in pixels, `None` = orthographic
1311        /// (curved, but no foreshortening).
1312        #[serde(default)]
1313        perspective: Option<f32>,
1314        #[serde(default)]
1315        rotate_x: Option<SurfaceAngle>,
1316        #[serde(default)]
1317        rotate_y: Option<SurfaceAngle>,
1318    },
1319}
1320
1321impl LayoutSurface {
1322    pub fn radius(&self) -> f32 {
1323        match self {
1324            LayoutSurface::Cylinder { radius, .. } | LayoutSurface::Sphere { radius, .. } => {
1325                *radius
1326            }
1327        }
1328    }
1329
1330    pub fn arc_x(&self) -> f32 {
1331        match self {
1332            LayoutSurface::Cylinder { arc_x, .. } | LayoutSurface::Sphere { arc_x, .. } => *arc_x,
1333        }
1334    }
1335
1336    pub fn arc_y(&self) -> f32 {
1337        match self {
1338            LayoutSurface::Cylinder { .. } => 0.0,
1339            LayoutSurface::Sphere { arc_y, .. } => *arc_y,
1340        }
1341    }
1342
1343    pub fn perspective(&self) -> Option<f32> {
1344        match self {
1345            LayoutSurface::Cylinder { perspective, .. }
1346            | LayoutSurface::Sphere { perspective, .. } => *perspective,
1347        }
1348    }
1349
1350    pub fn rotate_x(&self) -> Option<&SurfaceAngle> {
1351        match self {
1352            LayoutSurface::Cylinder { rotate_x, .. } | LayoutSurface::Sphere { rotate_x, .. } => {
1353                rotate_x.as_ref()
1354            }
1355        }
1356    }
1357
1358    pub fn rotate_y(&self) -> Option<&SurfaceAngle> {
1359        match self {
1360            LayoutSurface::Cylinder { rotate_y, .. } | LayoutSurface::Sphere { rotate_y, .. } => {
1361                rotate_y.as_ref()
1362            }
1363        }
1364    }
1365
1366    pub fn is_sphere(&self) -> bool {
1367        matches!(self, LayoutSurface::Sphere { .. })
1368    }
1369}
1370
1371/// A `layout-surface` `rotate-x`/`rotate-y` value: either a constant tilt in
1372/// degrees, or a linear turntable sweep from `from` to `to` starting at
1373/// scene time 0, over `duration` seconds (defaults to the scene's own
1374/// duration, so "the dome turns slowly" needs no explicit timing).
1375#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1376#[serde(untagged)]
1377pub enum SurfaceAngle {
1378    Fixed(f32),
1379    Animated {
1380        from: f32,
1381        to: f32,
1382        #[serde(default)]
1383        duration: Option<f32>,
1384    },
1385}
1386
1387impl SurfaceAngle {
1388    pub fn value_at(&self, time: f64, scene_duration: f64) -> f32 {
1389        match self {
1390            SurfaceAngle::Fixed(v) => *v,
1391            SurfaceAngle::Animated { from, to, duration } => {
1392                let dur = duration
1393                    .map(|d| d as f64)
1394                    .unwrap_or(scene_duration)
1395                    .max(0.0001);
1396                let p = (time / dur).clamp(0.0, 1.0) as f32;
1397                from + (to - from) * p
1398            }
1399        }
1400    }
1401}
1402
1403#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1404#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1405pub struct TransformOrigin {
1406    pub x: Option<LengthPercentage>,
1407    pub y: Option<LengthPercentage>,
1408    pub z: Option<Length>,
1409}
1410
1411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1412#[serde(tag = "fn", rename_all = "kebab-case")]
1413pub enum FilterFn {
1414    /// Isotropic by default (`radius`). `radius-x`/`radius-y` make it
1415    /// anisotropic — a horizontal-only smear is `radius-x` with `radius-y`
1416    /// at 0 (or omitted). When both are given they win over `radius` on
1417    /// their own axis; when neither is given the filter falls back to
1418    /// `radius` on both axes.
1419    Blur {
1420        #[serde(default)]
1421        radius: Option<Length>,
1422        #[serde(default, rename = "radius-x", alias = "radius_x")]
1423        radius_x: Option<Length>,
1424        #[serde(default, rename = "radius-y", alias = "radius_y")]
1425        radius_y: Option<Length>,
1426    },
1427    /// A single-axis blur held at `angle` degrees (0 = along +x, 90 = along
1428    /// +y) instead of being locked to the horizontal or vertical axis —
1429    /// the diagonal case `radius-x`/`radius-y` can't express.
1430    DirectionalBlur {
1431        angle: f32,
1432        radius: Length,
1433    },
1434    Brightness {
1435        value: f32,
1436    },
1437    Contrast {
1438        value: f32,
1439    },
1440    Saturate {
1441        value: f32,
1442    },
1443    HueRotate {
1444        deg: f32,
1445    },
1446    Grayscale {
1447        value: f32,
1448    },
1449    Invert {
1450        value: f32,
1451    },
1452    Sepia {
1453        value: f32,
1454    },
1455    DropShadow {
1456        offset_x: Length,
1457        offset_y: Length,
1458        #[serde(default)]
1459        blur: Option<Length>,
1460        #[serde(default)]
1461        color: Option<Color>,
1462    },
1463    Opacity {
1464        value: f32,
1465    },
1466    /// Deterministic film-grain noise. Works in both `filter` and
1467    /// `backdrop-filter` chains (frosted-glass grain).
1468    Noise {
1469        /// Grain strength in 0..1 (alpha of the noise layer). Default 0.15.
1470        #[serde(default = "default_noise_intensity")]
1471        intensity: f32,
1472        /// Perlin-noise seed — same seed ⇒ identical grain on every frame.
1473        #[serde(default = "default_noise_seed")]
1474        seed: u64,
1475    },
1476}
1477
1478fn default_noise_intensity() -> f32 {
1479    0.15
1480}
1481
1482fn default_noise_seed() -> u64 {
1483    42
1484}
1485
1486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1487#[serde(rename_all = "kebab-case")]
1488pub enum BlendMode {
1489    Normal,
1490    Multiply,
1491    Screen,
1492    Overlay,
1493    Darken,
1494    Lighten,
1495    ColorDodge,
1496    ColorBurn,
1497    HardLight,
1498    SoftLight,
1499    Difference,
1500    Exclusion,
1501    Hue,
1502    Saturation,
1503    Color,
1504    Luminosity,
1505    PlusLighter,
1506}
1507
1508/// `style.material`, as either a bare preset name or an object tuning it.
1509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1510#[serde(untagged)]
1511pub enum Material {
1512    /// `"material": "glossy"`.
1513    Preset(MaterialPreset),
1514    /// `"material": { "preset": "glossy", "intensity": 0.6 }`.
1515    Tuned {
1516        #[serde(default)]
1517        preset: MaterialPreset,
1518        /// Scales all three layers together. `1.0` is the calibrated look,
1519        /// `0.0` paints nothing. Multiplied by the scene light's own
1520        /// `intensity`.
1521        #[serde(default = "default_material_intensity")]
1522        intensity: f32,
1523        /// `inflated` only: how far inward from the silhouette the rounded
1524        /// edge reaches, in pixels. Small values give a crisp bevel, large
1525        /// ones a pillow. Default 18.
1526        #[serde(default = "default_material_bevel")]
1527        bevel: f32,
1528        /// `inflated` only: how rounded the profile is across that bevel,
1529        /// 0..1. Near 0 reads as a hard chamfer, near 1 as an inflated
1530        /// cushion. Default 0.6.
1531        #[serde(default = "default_material_softness")]
1532        softness: f32,
1533    },
1534}
1535
1536/// Which surface a `material` imitates.
1537#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1538#[serde(rename_all = "snake_case")]
1539pub enum MaterialPreset {
1540    /// A lit plastic or glass tile: bright highlight, hard specular edge,
1541    /// soft shade opposite.
1542    #[default]
1543    Glossy,
1544    /// The same light read as brushed metal: a tighter, dimmer highlight
1545    /// and a stronger edge.
1546    Metal,
1547    /// Light with no specular return — shade only, no highlight and no
1548    /// edge. Sits a flat surface next to glossy ones without looking unlit.
1549    Matte,
1550    /// A volume rather than a varnished plane: the shading is derived from
1551    /// the **silhouette itself**, so a five-branch star gets light on each
1552    /// branch instead of one band across its bounding box. Tuned by `bevel`
1553    /// and `softness`, which the other three presets ignore.
1554    Inflated,
1555}
1556
1557fn default_material_intensity() -> f32 {
1558    1.0
1559}
1560
1561fn default_material_bevel() -> f32 {
1562    18.0
1563}
1564
1565fn default_material_softness() -> f32 {
1566    0.6
1567}
1568
1569impl Material {
1570    pub fn preset(&self) -> MaterialPreset {
1571        match self {
1572            Material::Preset(p) => *p,
1573            Material::Tuned { preset, .. } => *preset,
1574        }
1575    }
1576
1577    pub fn intensity(&self) -> f32 {
1578        match self {
1579            Material::Preset(_) => 1.0,
1580            Material::Tuned { intensity, .. } => *intensity,
1581        }
1582    }
1583
1584    pub fn bevel(&self) -> f32 {
1585        match self {
1586            Material::Preset(_) => default_material_bevel(),
1587            Material::Tuned { bevel, .. } => *bevel,
1588        }
1589    }
1590
1591    pub fn softness(&self) -> f32 {
1592        match self {
1593            Material::Preset(_) => default_material_softness(),
1594            Material::Tuned { softness, .. } => *softness,
1595        }
1596    }
1597}
1598
1599fn default_clip_path_blob_lobes() -> u32 {
1600    8
1601}
1602
1603fn default_clip_path_blob_wobble() -> f32 {
1604    0.15
1605}
1606
1607fn default_clip_path_blob_seed() -> u32 {
1608    11
1609}
1610
1611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1612#[serde(tag = "kind", rename_all = "kebab-case")]
1613pub enum ClipPath {
1614    None,
1615    Inset {
1616        top: LengthPercentage,
1617        right: LengthPercentage,
1618        bottom: LengthPercentage,
1619        left: LengthPercentage,
1620        #[serde(default)]
1621        radius: Option<BorderRadius>,
1622    },
1623    Circle {
1624        radius: LengthPercentage,
1625        #[serde(default)]
1626        origin: Option<TransformOrigin>,
1627    },
1628    Ellipse {
1629        rx: LengthPercentage,
1630        ry: LengthPercentage,
1631        #[serde(default)]
1632        origin: Option<TransformOrigin>,
1633    },
1634    Polygon {
1635        points: Vec<(LengthPercentage, LengthPercentage)>,
1636    },
1637    Path {
1638        d: String,
1639    },
1640    /// A procedurally generated organic silhouette — the same wobbling-lobe
1641    /// shape a `blob` scene `transition` grows across a whole cut, scoped
1642    /// here to one element's own clip instead. `lobes` control points sit
1643    /// around a circle of `radius`, each nudged radially by a deterministic
1644    /// noise scaled by `wobble` and picked by `seed`; the same three values
1645    /// reproduce the same silhouette, byte for byte. `wobble: 0` degenerates
1646    /// to a plain circle, the same way `radius` alone does for `circle`.
1647    Blob {
1648        radius: LengthPercentage,
1649        #[serde(default)]
1650        origin: Option<TransformOrigin>,
1651        /// How many lobes the silhouette grows. Same field as
1652        /// `transition.lobes`.
1653        #[serde(default = "default_clip_path_blob_lobes")]
1654        lobes: u32,
1655        /// How far each lobe's radius wanders from a perfect circle, as a
1656        /// fraction of it. Same field as `transition.wobble`.
1657        #[serde(default = "default_clip_path_blob_wobble")]
1658        wobble: f32,
1659        /// Stable selector for the lobe noise, same selector as
1660        /// `pixel_dissolve.seed` and `transition.seed`.
1661        #[serde(default = "default_clip_path_blob_seed")]
1662        seed: u32,
1663    },
1664    /// Clip to another node's own path geometry, by id, instead of a literal
1665    /// `d` frozen at author time. The motivating shape: two copies of the
1666    /// same gem, each clipped by the same animated "crack" node, so the two
1667    /// halves separate along a coherent, shared edge — a literal
1668    /// `ClipPath::Path` on each copy could not track the crack's own
1669    /// animation without duplicating it (and letting the two drift out of
1670    /// sync the moment one copy's `d` is edited and the other isn't).
1671    ///
1672    /// This mirrors `node("id", "prop")` in `crate::expr`'s grammar, which
1673    /// reads another node's already-resolved *scalar* through
1674    /// `Scope::node_prop`; this variant instead names a node whose
1675    /// *geometry* (its own resolved path — a `shape` with
1676    /// `ShapeType::Path`, most naturally) should be read, so the `(id,
1677    /// prop)` pair that call uses does not apply — `id` alone is enough to
1678    /// say which node.
1679    ///
1680    /// Resolving `id` into an actual clip (finding the node, reading its
1681    /// current-frame path, intersecting the canvas clip with it) is the
1682    /// paint pipeline's job, not this enum's — same division as every other
1683    /// `ClipPath` variant, none of which carry their own clipping logic
1684    /// either.
1685    NodePath {
1686        id: String,
1687    },
1688    /// A shape that sweeps continuously from `from` to `to` as `progress`
1689    /// goes `0.0` → `1.0` (#384) — the chamfered-wall-style beat where the
1690    /// clip itself is the animation, not a transform riding on top of a
1691    /// frozen shape.
1692    ///
1693    /// `progress` is meant to be driven by an ordinary `keyframes` animation
1694    /// on the `clip_path_progress` property (see `KNOWN_MOTION_PROPERTIES`);
1695    /// the literal value here is only the resting position when nothing
1696    /// animates it.
1697    ///
1698    /// `from` and `to` must be the same variant — and, for `polygon`, the
1699    /// same vertex count — for the paint pass to have a correspondence to
1700    /// interpolate along. A mismatch is reported on stderr and the node is
1701    /// left unclipped for that frame; see `engine::paint_pass::morph_clip_path_to_skia`.
1702    Morph {
1703        from: Box<ClipPath>,
1704        to: Box<ClipPath>,
1705        /// Shapes the morph passes **through** on its way from `from` to
1706        /// `to`, in order. Empty means a direct two-shape sweep, which is
1707        /// what a `morph` without this field has always been.
1708        ///
1709        /// `progress` spans the whole chain, not each leg: with one `via`
1710        /// shape, `0.5` is exactly that shape.
1711        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1712        via: Vec<ClipPath>,
1713        #[serde(default)]
1714        progress: f32,
1715    },
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720    use super::*;
1721
1722    #[test]
1723    fn default_is_all_none() {
1724        let s = CssStyle::default();
1725        assert!(s.display.is_none());
1726        assert!(s.padding.is_none());
1727        assert!(s.transform.is_none());
1728    }
1729
1730    #[test]
1731    fn deserialize_basic_flex() {
1732        let json = r#"{
1733            "display": "flex",
1734            "flex-direction": "column",
1735            "gap": "16px",
1736            "align-items": "center",
1737            "padding": "24px"
1738        }"#;
1739        let s: CssStyle = serde_json::from_str(json).unwrap();
1740        assert_eq!(s.display, Some(Display::Flex));
1741        assert_eq!(s.flex_direction, Some(FlexDirection::Column));
1742        assert_eq!(s.align_items, Some(AlignItems::Center));
1743        assert!(matches!(s.padding, Some(Edges::Uniform(_))));
1744    }
1745
1746    #[test]
1747    fn deserialize_per_side_padding() {
1748        let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
1749        let s: CssStyle = serde_json::from_str(json).unwrap();
1750        assert!(matches!(s.padding, Some(Edges::Sides { .. })));
1751    }
1752
1753    #[test]
1754    fn deserialize_color_variants() {
1755        let s1: CssStyle = serde_json::from_str(r##"{ "color": "#ff0000" }"##).unwrap();
1756        let s2: CssStyle =
1757            serde_json::from_str(r##"{ "color": { "r": 255, "g": 0, "b": 0, "a": 1.0 } }"##)
1758                .unwrap();
1759        assert!(matches!(s1.color, Some(Color::String(_))));
1760        assert!(matches!(s2.color, Some(Color::Rgba { r: 255, .. })));
1761    }
1762
1763    #[test]
1764    fn deserialize_clip_path_blob_falls_back_to_the_transition_defaults() {
1765        let json = r#"{ "clip-path": { "kind": "blob", "radius": 120 } }"#;
1766        let s: CssStyle = serde_json::from_str(json).unwrap();
1767        match s.clip_path {
1768            Some(ClipPath::Blob {
1769                radius,
1770                origin,
1771                lobes,
1772                wobble,
1773                seed,
1774            }) => {
1775                assert_eq!(radius, LengthPercentage::Px(120.0));
1776                assert!(origin.is_none());
1777                assert_eq!(
1778                    lobes, 8,
1779                    "must default to the same lobes as transition: blob"
1780                );
1781                assert_eq!(
1782                    wobble, 0.15,
1783                    "must default to the same wobble as transition: blob"
1784                );
1785                assert_eq!(
1786                    seed, 11,
1787                    "must default to the same seed as transition: blob"
1788                );
1789            }
1790            other => panic!("expected ClipPath::Blob, got {other:?}"),
1791        }
1792    }
1793
1794    #[test]
1795    fn deserialize_clip_path_blob_with_explicit_lobes_wobble_seed() {
1796        let json = r#"{
1797            "clip-path": { "kind": "blob", "radius": 90, "lobes": 5, "wobble": 0.4, "seed": 3 }
1798        }"#;
1799        let s: CssStyle = serde_json::from_str(json).unwrap();
1800        match s.clip_path {
1801            Some(ClipPath::Blob {
1802                lobes,
1803                wobble,
1804                seed,
1805                ..
1806            }) => {
1807                assert_eq!(lobes, 5);
1808                assert_eq!(wobble, 0.4);
1809                assert_eq!(seed, 3);
1810            }
1811            other => panic!("expected ClipPath::Blob, got {other:?}"),
1812        }
1813    }
1814
1815    #[test]
1816    fn deserialize_transform_list() {
1817        let json = r#"{ "transform": [
1818            { "fn": "translate-x", "x": "10px" },
1819            { "fn": "scale", "x": 1.5, "y": 1.5 },
1820            { "fn": "rotate", "deg": 45.0 }
1821        ]}"#;
1822        let s: CssStyle = serde_json::from_str(json).unwrap();
1823        let t = s.transform.expect("transform set");
1824        assert_eq!(t.len(), 3);
1825    }
1826
1827    #[test]
1828    fn roundtrip_serialization() {
1829        let original = CssStyle {
1830            display: Some(Display::Flex),
1831            opacity: Some(0.5),
1832            z_index: Some(10),
1833            ..Default::default()
1834        };
1835        let json = serde_json::to_string(&original).unwrap();
1836        let parsed: CssStyle = serde_json::from_str(&json).unwrap();
1837        assert_eq!(parsed.display, Some(Display::Flex));
1838        assert_eq!(parsed.opacity, Some(0.5));
1839        assert_eq!(parsed.z_index, Some(10));
1840    }
1841
1842    #[test]
1843    fn grid_track_bare_number_is_fr() {
1844        let json = r#"{ "grid-template-columns": [1, 1, 1] }"#;
1845        let s: CssStyle = serde_json::from_str(json).unwrap();
1846        let tracks = s.grid_template_columns.expect("tracks set");
1847        assert_eq!(tracks.len(), 3);
1848        for t in &tracks {
1849            assert!(matches!(t, GridTrack::Fr(n) if (*n - 1.0).abs() < f32::EPSILON));
1850        }
1851    }
1852
1853    #[test]
1854    fn grid_track_string_fr_is_length_parsed_as_fr() {
1855        let json = r#"{ "grid-template-columns": ["1fr", "2fr"] }"#;
1856        let s: CssStyle = serde_json::from_str(json).unwrap();
1857        let tracks = s.grid_template_columns.expect("tracks set");
1858        match &tracks[0] {
1859            GridTrack::Length(lp) => {
1860                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Fr(1.0))
1861            }
1862            other => panic!("expected Length(\"1fr\"), got {other:?}"),
1863        }
1864        match &tracks[1] {
1865            GridTrack::Length(lp) => {
1866                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Fr(2.0))
1867            }
1868            other => panic!("expected Length(\"2fr\"), got {other:?}"),
1869        }
1870    }
1871
1872    #[test]
1873    fn grid_track_keyword_strings() {
1874        let json = r#"{ "grid-template-columns": ["auto", "min-content", "max-content"] }"#;
1875        let s: CssStyle = serde_json::from_str(json).unwrap();
1876        let tracks = s.grid_template_columns.expect("tracks set");
1877        assert!(matches!(
1878            tracks[0],
1879            GridTrack::Keyword(GridTrackKeyword::Auto)
1880        ));
1881        assert!(matches!(
1882            tracks[1],
1883            GridTrack::Keyword(GridTrackKeyword::MinContent)
1884        ));
1885        assert!(matches!(
1886            tracks[2],
1887            GridTrack::Keyword(GridTrackKeyword::MaxContent)
1888        ));
1889    }
1890
1891    #[test]
1892    fn grid_track_px_string_is_length() {
1893        let json = r#"{ "grid-template-columns": ["200px", "50%"] }"#;
1894        let s: CssStyle = serde_json::from_str(json).unwrap();
1895        let tracks = s.grid_template_columns.expect("tracks set");
1896        match &tracks[0] {
1897            GridTrack::Length(lp) => {
1898                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Px(200.0))
1899            }
1900            other => panic!("expected Length(200px), got {other:?}"),
1901        }
1902        match &tracks[1] {
1903            GridTrack::Length(lp) => {
1904                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Percent(50.0))
1905            }
1906            other => panic!("expected Length(50%), got {other:?}"),
1907        }
1908    }
1909
1910    fn style_with(font_size: &str, letter_spacing: &str, line_height: &str) -> CssStyle {
1911        let json = format!(
1912            r#"{{ "font-size": {font_size}, "letter-spacing": {letter_spacing}, "line-height": {line_height} }}"#
1913        );
1914        serde_json::from_str(&json).unwrap()
1915    }
1916
1917    #[test]
1918    fn font_size_px_ctx_resolves_vw() {
1919        let s = style_with(r#""15.6vw""#, "0", "1");
1920        let ctx = LengthContext {
1921            viewport_width: 1920.0,
1922            ..Default::default()
1923        };
1924        assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 15.6 / 100.0 * 1920.0);
1925        assert_eq!(s.font_size_px_or(48.0), 0.0);
1926    }
1927
1928    #[test]
1929    fn font_size_px_ctx_resolves_rem_without_cascade_dependency() {
1930        let s = style_with(r#""2rem""#, "0", "1");
1931        let ctx = LengthContext {
1932            root_font_size: 20.0,
1933            ..Default::default()
1934        };
1935        assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 40.0);
1936    }
1937
1938    #[test]
1939    fn font_size_px_ctx_falls_back_to_default_when_unset() {
1940        let s = CssStyle::default();
1941        assert_eq!(s.font_size_px_ctx(&LengthContext::default(), 48.0), 48.0);
1942    }
1943
1944    #[test]
1945    fn letter_spacing_px_ctx_resolves_own_em_not_parent_em() {
1946        let s = style_with("300", r#""-0.03em""#, "1");
1947        let own_ctx = LengthContext {
1948            font_size: 300.0,
1949            ..Default::default()
1950        };
1951        assert!((s.letter_spacing_px_ctx(&own_ctx) - (-9.0)).abs() < 1e-4);
1952        assert_eq!(s.letter_spacing_px(), 0.0);
1953    }
1954
1955    #[test]
1956    fn line_height_percent_resolves_against_own_font_size_not_parent_size() {
1957        let s = style_with("100", r#""50%""#, r#""50%""#);
1958        let ctx = LengthContext {
1959            parent_size: 1000.0,
1960            ..Default::default()
1961        };
1962        assert_eq!(s.line_height_for_ctx(100.0, &ctx), 50.0);
1963    }
1964
1965    #[test]
1966    fn line_height_number_ignores_context_like_before() {
1967        let s = style_with("100", "0", "1.5");
1968        assert_eq!(
1969            s.line_height_for_ctx(100.0, &LengthContext::default()),
1970            150.0
1971        );
1972    }
1973
1974    #[test]
1975    fn typography_px_ctx_resolves_all_three_with_correct_em_bases() {
1976        let s = style_with(r#""1.5em""#, r#""-0.03em""#, "0.85");
1977        let ctx = LengthContext {
1978            font_size: 200.0,
1979            ..Default::default()
1980        };
1981        let (font_size, letter_spacing, line_height) = s.typography_px_ctx(&ctx, 48.0);
1982        assert_eq!(font_size, 300.0);
1983        assert!(
1984            (letter_spacing - (300.0 * -0.03)).abs() < 1e-3,
1985            "letter-spacing em must resolve against the OWN 300px font-size, got {letter_spacing}"
1986        );
1987        assert_eq!(line_height, 300.0 * 0.85);
1988    }
1989
1990    #[test]
1991    fn border_radius_corners_accepts_kebab_case() {
1992        let json = r#"{ "border-radius": { "top-left": "12px", "top-right": "12px", "bottom-right": "4px", "bottom-left": "4px" } }"#;
1993        let s: CssStyle = serde_json::from_str(json).unwrap();
1994        match s.border_radius {
1995            Some(BorderRadius::Corners {
1996                top_left,
1997                top_right,
1998                bottom_right,
1999                bottom_left,
2000            }) => {
2001                assert_eq!(top_left.px(), 12.0, "top-left must be honoured, not 0");
2002                assert_eq!(top_right.px(), 12.0);
2003                assert_eq!(bottom_right.px(), 4.0);
2004                assert_eq!(bottom_left.px(), 4.0);
2005            }
2006            other => panic!("expected Corners, got {other:?}"),
2007        }
2008    }
2009
2010    #[test]
2011    fn border_radius_corners_still_accepts_legacy_snake_case() {
2012        let json = r#"{ "border-radius": { "top_left": "8px", "top_right": "8px", "bottom_right": "8px", "bottom_left": "8px" } }"#;
2013        let s: CssStyle = serde_json::from_str(json).unwrap();
2014        assert_eq!(s.border_radius_px(), Some(8.0));
2015    }
2016
2017    #[test]
2018    fn border_radius_corners_typo_is_a_named_error_not_a_silent_zero() {
2019        let json = r#"{ "border-radius": { "topleft": "12px" } }"#;
2020        let err = serde_json::from_str::<CssStyle>(json).expect_err("typo must be rejected");
2021        let msg = err.to_string();
2022        assert!(
2023            msg.contains("topleft")
2024                || msg.contains("border-radius")
2025                || msg.contains("BorderRadius"),
2026            "error must name the offending input, got: {msg}"
2027        );
2028    }
2029
2030    #[test]
2031    fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() {
2032        let json = r#"{ "padding": { "horizontal": 20 } }"#;
2033        let err = serde_json::from_str::<CssStyle>(json)
2034            .expect_err("an unrecognised padding shape must be rejected, not silently zeroed");
2035        let msg = err.to_string();
2036        assert!(
2037            msg.contains("horizontal") || msg.contains("padding") || msg.contains("Edges"),
2038            "error must name the offending input, got: {msg}"
2039        );
2040    }
2041
2042    #[test]
2043    fn edges_still_accepts_valid_per_side_object() {
2044        let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
2045        let s: CssStyle = serde_json::from_str(json).unwrap();
2046        assert_eq!(s.padding_px(), (10.0, 20.0, 10.0, 20.0));
2047    }
2048
2049    #[test]
2050    fn edges_still_accepts_uniform_scalar() {
2051        let json = r#"{ "padding": "24px" }"#;
2052        let s: CssStyle = serde_json::from_str(json).unwrap();
2053        assert_eq!(s.padding_px(), (24.0, 24.0, 24.0, 24.0));
2054    }
2055
2056    #[test]
2057    fn size_keyword_max_content_is_reachable() {
2058        for (kw, expected) in [
2059            ("max-content", SizeKeyword::MaxContent),
2060            ("min-content", SizeKeyword::MinContent),
2061            ("fit-content", SizeKeyword::FitContent),
2062        ] {
2063            let json = format!(r#"{{ "width": "{kw}" }}"#);
2064            let s: CssStyle = serde_json::from_str(&json).unwrap();
2065            assert_eq!(
2066                s.width,
2067                Some(Size::Keyword(expected)),
2068                "width: \"{kw}\" must resolve to Size::Keyword, not Size::Length(String(..))"
2069            );
2070        }
2071    }
2072
2073    #[test]
2074    fn size_length_and_auto_are_unaffected_by_the_reorder() {
2075        let s: CssStyle = serde_json::from_str(r#"{ "width": "200px" }"#).unwrap();
2076        assert!(matches!(s.width, Some(Size::Length(_))));
2077        let s: CssStyle = serde_json::from_str(r#"{ "width": "50%" }"#).unwrap();
2078        assert!(matches!(s.width, Some(Size::Length(_))));
2079        let s: CssStyle = serde_json::from_str(r#"{ "width": "auto" }"#).unwrap();
2080        assert!(matches!(s.width, Some(Size::Auto(_))));
2081        let s: CssStyle = serde_json::from_str(r#"{ "width": 200 }"#).unwrap();
2082        assert!(matches!(s.width, Some(Size::Length(_))));
2083    }
2084
2085    #[test]
2086    fn line_height_keyword_normal_is_reachable() {
2087        let s: CssStyle = serde_json::from_str(r#"{ "line-height": "normal" }"#).unwrap();
2088        assert_eq!(
2089            s.line_height,
2090            Some(LineHeight::Keyword(LineHeightKw::Normal)),
2091            "line-height: \"normal\" must resolve to Keyword, not Length(String(\"normal\"))"
2092        );
2093    }
2094
2095    #[test]
2096    fn line_height_number_and_length_are_unaffected_by_the_reorder() {
2097        let s: CssStyle = serde_json::from_str(r#"{ "line-height": 1.5 }"#).unwrap();
2098        assert!(matches!(s.line_height, Some(LineHeight::Number(_))));
2099        let s: CssStyle = serde_json::from_str(r#"{ "line-height": "24px" }"#).unwrap();
2100        assert!(matches!(s.line_height, Some(LineHeight::Length(_))));
2101    }
2102
2103    #[test]
2104    fn border_radius_corners_serializes_as_kebab_case() {
2105        let s = CssStyle {
2106            border_radius: Some(BorderRadius::Corners {
2107                top_left: LengthPercentage::Px(1.0),
2108                top_right: LengthPercentage::Px(2.0),
2109                bottom_right: LengthPercentage::Px(3.0),
2110                bottom_left: LengthPercentage::Px(4.0),
2111            }),
2112            ..Default::default()
2113        };
2114        let json = serde_json::to_value(&s).unwrap();
2115        let br = &json["border-radius"];
2116        assert_eq!(br["top-left"], serde_json::json!(1.0));
2117        assert_eq!(br["top-right"], serde_json::json!(2.0));
2118        assert_eq!(br["bottom-right"], serde_json::json!(3.0));
2119        assert_eq!(br["bottom-left"], serde_json::json!(4.0));
2120        assert!(
2121            br.get("top_left").is_none(),
2122            "must not emit the legacy snake_case key any more"
2123        );
2124    }
2125
2126    #[test]
2127    fn blur_filter_accepts_isotropic_radius_with_no_axis_fields() {
2128        let json = r#"{ "filter": [{ "fn": "blur", "radius": 24 }] }"#;
2129        let s: CssStyle = serde_json::from_str(json).unwrap();
2130        let f = &s.filter.expect("filter set")[0];
2131        assert!(matches!(
2132            f,
2133            FilterFn::Blur {
2134                radius: Some(_),
2135                radius_x: None,
2136                radius_y: None
2137            }
2138        ));
2139    }
2140
2141    #[test]
2142    fn blur_filter_accepts_snake_case_radius_x_and_radius_y_as_aliases() {
2143        let json = r#"{ "filter": [{ "fn": "blur", "radius_x": 40, "radius_y": 12 }] }"#;
2144        let s: CssStyle = serde_json::from_str(json).unwrap();
2145        let f = &s.filter.expect("filter set")[0];
2146        match f {
2147            FilterFn::Blur {
2148                radius,
2149                radius_x,
2150                radius_y,
2151            } => {
2152                assert!(radius.is_none());
2153                assert!(
2154                    matches!(radius_x, Some(Length::Px(v)) if (*v - 40.0).abs() < 1e-6),
2155                    "the snake_case spelling must be accepted exactly like radius-x, got {:?}",
2156                    radius_x
2157                );
2158                assert!(
2159                    matches!(radius_y, Some(Length::Px(v)) if (*v - 12.0).abs() < 1e-6),
2160                    "the snake_case spelling must be accepted exactly like radius-y, got {:?}",
2161                    radius_y
2162                );
2163            }
2164            other => panic!("expected Blur, got {:?}", other),
2165        }
2166    }
2167
2168    #[test]
2169    fn blur_filter_accepts_per_axis_radius_with_no_isotropic_radius() {
2170        let json = r#"{ "filter": [{ "fn": "blur", "radius-x": 40, "radius-y": 0 }] }"#;
2171        let s: CssStyle = serde_json::from_str(json).unwrap();
2172        let f = &s.filter.expect("filter set")[0];
2173        match f {
2174            FilterFn::Blur {
2175                radius,
2176                radius_x,
2177                radius_y,
2178            } => {
2179                assert!(radius.is_none());
2180                assert!(matches!(radius_x, Some(Length::Px(v)) if (*v - 40.0).abs() < 1e-6));
2181                assert!(matches!(radius_y, Some(Length::Px(v)) if v.abs() < 1e-6));
2182            }
2183            other => panic!("expected Blur, got {:?}", other),
2184        }
2185    }
2186
2187    #[test]
2188    fn directional_blur_deserializes_with_kebab_case_tag() {
2189        let json = r#"{ "filter": [{ "fn": "directional-blur", "angle": 90, "radius": 40 }] }"#;
2190        let s: CssStyle = serde_json::from_str(json).unwrap();
2191        let f = &s.filter.expect("filter set")[0];
2192        match f {
2193            FilterFn::DirectionalBlur { angle, radius } => {
2194                assert!((*angle - 90.0).abs() < 1e-6);
2195                assert!(matches!(radius, Length::Px(v) if (*v - 40.0).abs() < 1e-6));
2196            }
2197            other => panic!("expected DirectionalBlur, got {:?}", other),
2198        }
2199    }
2200}