Skip to main content

rustmotion_core/css/
style.rs

1//! `CssStyle` — typed mirror of the CSS properties supported by the engine.
2//!
3//! Scope: Remotion-equivalent (Flex, Grid, Block, transforms 2D/3D, filters,
4//! gradients, position absolute/relative, box-shadow, border-radius, opacity,
5//! clip-path). Excludes: inline boxes, floats, tables, position sticky/fixed.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use super::units::{Length, LengthContext, LengthPercentage, ParsedLength};
11// `GradientBorder` / `InnerShadow` are reused from the schema layer rather
12// than mirrored: same crate, same serde/JsonSchema derives, identical JSON
13// shape either way — a css-local mirror would only duplicate the struct.
14use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow};
15
16// ─── Legibility floor (relocated from `rustmotion/src/cli/commands/
17// geometry.rs`'s `check_legibility`, issue #110/#102 — moved here, not
18// duplicated, so `text-autofit` below can shrink down to the exact same
19// calibrated threshold instead of inventing a second one; `rustmotion`
20// depends on `rustmotion-core`, never the other way around, so the shared
21// value has to live on this side of that boundary) ─────────────────────────
22//
23// Threshold justification (rendered evidence, not a guess): a 1920×1080
24// scenario was rendered with the same sample line at 8/10/11/12/13/14/16/18/
25// 20/22/24/28px, then the frame was scaled down 50% (a realistic "not
26// full-native" viewing size) to inspect. 8–13px degraded to an illegible
27// grey smear at that scale; 14px was the first size that stayed readable.
28// 0.012 (1.2% of output height) sits between those two bands — it equals
29// ~13px on a 1080p frame — and clears every built-in component default
30// already shipped (table/terminal/codeblock/pill_nav = 14px, badge `md` =
31// 14px, kbd = 14px, tooltip = 13px), so it does not fire on scenarios that
32// already validate clean today. Expressing it as a fraction of output
33// height (rather than an absolute px count) makes the same *visual* size
34// get flagged on a 4K or vertical-format canvas too.
35pub const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012;
36
37/// [`MIN_LEGIBLE_FONT_RATIO`] evaluated at a fixed 1920×1080 reference
38/// canvas (≈12.96px) — `text-autofit`'s shrink floor.
39///
40/// This is deliberately **not** `MIN_LEGIBLE_FONT_RATIO * scenario.video.
41/// height`, unlike `check_legibility`'s own per-scenario check. Reason:
42/// `text-autofit` must resolve to the *identical* px value wherever it's
43/// computed (`TextIntrinsic::measure`, which runs pre-layout inside
44/// `box_builder.rs`, and `Text`/`GradientText`'s painters, which run
45/// post-layout with a real `PaintCtx`) — see the measure/paint parity
46/// argument on `CssStyle::text_autofit`. `box_builder.rs` does not thread
47/// the real `VideoConfig` down to where `TextIntrinsic` is constructed (out
48/// of this workstream's file scope), so the painter side cannot be allowed
49/// to use the real, more accurate `ctx.video_height` either — doing so would
50/// silently reintroduce exactly the measure-vs-paint divergence this
51/// workstream exists to prevent, just relocated from "the box" to "the
52/// floor". Pinning both sides to the same fixed reference trades per-canvas
53/// precision (a vertical 1080×2256 scenario's *true* 1.2%-of-height floor is
54/// larger than this) for the non-negotiable guarantee that they agree. This
55/// does not weaken `check_legibility` itself: that check still runs
56/// independently, against the real canvas, on whatever `font-size` was
57/// authored — it has no visibility into `text-autofit`'s runtime output
58/// either way (see the workstream report's "non traité" list).
59pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0;
60
61/// Top-level CSS style block. All fields are optional; `None` means "not set"
62/// and lets the cascade fill in inherited / initial values.
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
64#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
65pub struct CssStyle {
66    // ---- Layout / box ----
67    pub display: Option<Display>,
68    pub position: Option<Position>,
69    pub top: Option<LengthPercentage>,
70    pub right: Option<LengthPercentage>,
71    pub bottom: Option<LengthPercentage>,
72    pub left: Option<LengthPercentage>,
73
74    pub width: Option<Size>,
75    pub height: Option<Size>,
76    pub min_width: Option<Size>,
77    pub min_height: Option<Size>,
78    pub max_width: Option<Size>,
79    pub max_height: Option<Size>,
80
81    pub margin: Option<Edges>,
82    pub padding: Option<Edges>,
83    pub border: Option<BorderEdges>,
84    pub box_sizing: Option<BoxSizing>,
85    pub aspect_ratio: Option<f32>,
86
87    // ---- Flex ----
88    pub flex_direction: Option<FlexDirection>,
89    pub flex_wrap: Option<FlexWrap>,
90    pub justify_content: Option<JustifyContent>,
91    pub align_items: Option<AlignItems>,
92    pub align_self: Option<AlignSelf>,
93    pub align_content: Option<AlignContent>,
94    pub gap: Option<Gap>,
95    pub flex_grow: Option<f32>,
96    pub flex_shrink: Option<f32>,
97    pub flex_basis: Option<Size>,
98    pub order: Option<i32>,
99
100    // ---- Grid ----
101    pub grid_template_columns: Option<Vec<GridTrack>>,
102    pub grid_template_rows: Option<Vec<GridTrack>>,
103    pub grid_column: Option<GridLine>,
104    pub grid_row: Option<GridLine>,
105    pub grid_auto_flow: Option<GridAutoFlow>,
106    pub justify_items: Option<JustifyItems>,
107    pub justify_self: Option<JustifySelf>,
108
109    // ---- Typography (most are inherited) ----
110    pub font_family: Option<String>,
111    pub font_size: Option<Length>,
112    pub font_weight: Option<FontWeight>,
113    pub font_style: Option<FontStyle>,
114    pub line_height: Option<LineHeight>,
115    pub letter_spacing: Option<Length>,
116    pub text_align: Option<TextAlign>,
117    pub color: Option<Color>,
118    pub white_space: Option<WhiteSpace>,
119    pub overflow_wrap: Option<OverflowWrap>,
120    pub text_overflow: Option<TextOverflow>,
121    pub text_decoration: Option<TextDecoration>,
122    /// When `true` on `text`/`gradient_text`, the effective `font-size` is
123    /// shrunk (never grown) until the content fits the box it was assigned,
124    /// instead of overflowing it. This is what lets an author declare "this
125    /// text must fit here" and closes `ContentOverflowsBox` as a possible
126    /// validator failure for that node — see `apply_fixes`
127    /// (`rustmotion/src/cli/commands/validate.rs`)'s comment on why it
128    /// deliberately refuses to auto-fix that violation today: growing the
129    /// box, shrinking the font, and shortening the copy are all legitimate
130    /// fixes, and picking one was never this tool's call to make silently.
131    /// `text-autofit` removes that ambiguity by having the *author* pick
132    /// "shrink the font" up front.
133    ///
134    /// **Which box.** Two independent axes, each opt-in on its own:
135    /// - *Width*: the box's resolved width — its own `width`/`max-width` if
136    ///   set, else whatever it inherited from its parent (exactly the value
137    ///   `text`/`gradient_text` already wrap against — see
138    ///   `TextIntrinsic`/`Text::paint`). Always present once the node is
139    ///   laid out, so the width axis is always a candidate for shrinking.
140    /// - *Height*: only when the node's own box resolves to a **definite**
141    ///   height (an explicit `height`, or a parent that hands it one, e.g. a
142    ///   fixed `flex-basis`) — never an implicit/inherited one. A box that
143    ///   grows to fit its content has, by construction, nothing to overflow
144    ///   on the height axis, so there is nothing to shrink for. See
145    ///   `TextIntrinsic::measure` / `Text::paint`'s `content_height` for
146    ///   exactly how this is read (the same taffy-resolved, padding/border-
147    ///   already-subtracted content-box value on both the pre-layout measure
148    ///   path and the post-layout paint path — this is what guarantees the
149    ///   two agree on the target, not just the algorithm).
150    ///
151    /// **`white-space: nowrap`.** Does not change: nowrap still means "never
152    /// break this into multiple lines". `text-autofit` composes with it
153    /// rather than overriding it — a `nowrap` line combined with
154    /// `text-autofit: true` shrinks the *one* line until it fits the box's
155    /// width (this is `text`/`gradient_text`'s answer to Remotion's
156    /// `fitText()`), it does not start wrapping.
157    ///
158    /// **`auto_scroll`** (`codeblock`/`terminal`). Unrelated: `text-autofit`
159    /// is only read by `text`/`gradient_text`'s own painter/intrinsic —
160    /// `codeblock`/`terminal` never look at this field, so there is no
161    /// precedence to resolve between the two; `auto_scroll` keeps scrolling
162    /// (never shrinking) exactly as documented in `CLAUDE.md`.
163    ///
164    /// **The floor.** Never shrinks below [`TEXT_AUTOFIT_MIN_FONT_PX`] — the
165    /// same calibrated legibility ratio `check_legibility`
166    /// (`rustmotion/src/cli/commands/geometry.rs`) already enforces, not a
167    /// new threshold. If the content still doesn't fit at the floor, the
168    /// floor size is used anyway (illegible-but-smallest beats an even
169    /// larger overflow) and the geometry validator's `ContentOverflowsBox`
170    /// still fires — `text-autofit` narrows that failure class, it does not
171    /// silence it.
172    pub text_autofit: Option<bool>,
173
174    // ---- Visual ----
175    pub background: Option<Background>,
176    pub border_radius: Option<BorderRadius>,
177    pub box_shadow: Option<Vec<BoxShadow>>,
178    pub text_shadow: Option<Vec<TextShadow>>,
179    pub opacity: Option<f32>,
180    pub mix_blend_mode: Option<BlendMode>,
181    pub clip_path: Option<ClipPath>,
182    /// Gradient-colored border painted instead of `border` when present.
183    /// `{ "colors": [...], "width": 2, "angle": 0 }` — angle follows the same
184    /// convention as `background` linear gradients.
185    pub gradient_border: Option<GradientBorder>,
186
187    // ---- Legacy compat (accepted, never rendered — validator warns) ----
188    /// Deprecated: use `backdrop-filter: [{ "fn": "blur", "radius": N }]`.
189    pub backdrop_blur: Option<f32>,
190    /// Deprecated: use `box-shadow` with `"inset": true`.
191    pub inner_shadow: Option<InnerShadow>,
192
193    // ---- Filters / effects ----
194    pub filter: Option<Vec<FilterFn>>,
195    pub backdrop_filter: Option<Vec<FilterFn>>,
196
197    // ---- Transform ----
198    pub transform: Option<Vec<TransformFn>>,
199    pub transform_origin: Option<TransformOrigin>,
200    pub perspective: Option<Length>,
201    pub perspective_origin: Option<TransformOrigin>,
202
203    // ---- Scene-camera parallax ----
204    /// Parallax plane depth for the scene camera (issue #90). 0 = locked
205    /// plane (the camera does not affect it), 1 = normal plane (default),
206    /// above 1 = amplified foreground. v1: effective on direct children of
207    /// the scene root only (each top-level child is one plane whose depth
208    /// governs its whole subtree). Not inherited via cascade.
209    pub depth: Option<f32>,
210
211    // ---- Overflow / stacking ----
212    pub overflow: Option<Overflow>,
213    pub overflow_x: Option<Overflow>,
214    pub overflow_y: Option<Overflow>,
215    pub z_index: Option<i32>,
216    pub visibility: Option<Visibility>,
217
218    // ---- Animation ----
219    #[serde(default, deserialize_with = "deserialize_animation_effects")]
220    pub animation: Vec<AnimationEffect>,
221    /// Smoothing for `timeline` style-state changes. Supported properties:
222    /// `opacity`; `color` on text/counter; `background` (solid colour only)
223    /// and `border-radius` (single uniform, absolute-px value only) — see
224    /// `box_builder.rs`'s `transition_keyframes`/
225    /// `resolve_transition_css_overrides`. Everything else — including a
226    /// `background`/`border-radius` value outside the shape those two
227    /// support (gradients, per-corner radii, `%`/`em`/`rem`/`vw`/`vh`) —
228    /// still snaps at the step's `at`; `rustmotion validate`'s
229    /// `check_transition_smoothing` (`validate_schema.rs`) reports exactly
230    /// which property and why whenever it would otherwise snap silently.
231    pub transition: Option<StyleTransition>,
232
233    // ---- Audio reactive binding ----
234    #[serde(default)]
235    pub audio_reactive: Option<AudioReactive>,
236}
237
238/// `transition` config: bare number = duration in seconds with the default
239/// easing, or a `{ duration, easing }` object.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
241#[serde(untagged)]
242pub enum StyleTransition {
243    Duration(f64),
244    Config {
245        duration: f64,
246        #[serde(default = "default_transition_easing")]
247        easing: crate::schema::EasingType,
248    },
249}
250
251fn default_transition_easing() -> crate::schema::EasingType {
252    crate::schema::EasingType::EaseInOut
253}
254
255// ---- Audio reactive binding ----
256
257/// Bind a CSS property to audio analysis data.
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
259#[serde(deny_unknown_fields)]
260pub struct AudioReactive {
261    /// Audio track src key. If None, uses the first entry in the cache.
262    #[serde(default)]
263    pub track: Option<String>,
264    /// Which audio data source to use.
265    pub source: AudioSource,
266    /// Which CSS property to modulate.
267    pub property: AudioReactiveProperty,
268    /// Value when audio is at 0.
269    pub min: f64,
270    /// Value when audio is at 1.
271    pub max: f64,
272    /// Number of previous frames to average (0 = no smoothing).
273    #[serde(default)]
274    pub smoothing_frames: u32,
275}
276
277/// The audio data source for an AudioReactive binding.
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
279#[serde(untagged)]
280pub enum AudioSource {
281    /// Overall amplitude (RMS). JSON: `"amplitude"`.
282    Amplitude(AudioSourceTag),
283    /// A specific frequency band (0..15). JSON: `{"band": 3}`.
284    Band { band: u8 },
285}
286
287/// Tag-only variant for the amplitude source.
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
289#[serde(rename_all = "snake_case")]
290pub enum AudioSourceTag {
291    Amplitude,
292}
293
294/// Which CSS property to modulate with audio.
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
296#[serde(rename_all = "snake_case")]
297pub enum AudioReactiveProperty {
298    Opacity,
299    Scale,
300    TranslateY,
301    Rotation,
302}
303
304impl StyleTransition {
305    pub fn duration(&self) -> f64 {
306        match self {
307            StyleTransition::Duration(d) => *d,
308            StyleTransition::Config { duration, .. } => *duration,
309        }
310    }
311
312    pub fn easing(&self) -> crate::schema::EasingType {
313        match self {
314            StyleTransition::Duration(_) => default_transition_easing(),
315            StyleTransition::Config { easing, .. } => easing.clone(),
316        }
317    }
318}
319
320// ---- Painter convenience accessors ----
321//
322// These resolve raw CssStyle values to the simple primitives that component
323// painters deal with: f32 px, &str hex colors, etc. They drop unsupported
324// units (em/rem/% with no parent context). Painters that need full
325// resolution should use `Length::resolve(&LengthContext)` directly.
326impl CssStyle {
327    /// `font-size` in px, falling back to `default` when unset.
328    pub fn font_size_px_or(&self, default: f32) -> f32 {
329        self.font_size.as_ref().map(|l| l.px()).unwrap_or(default)
330    }
331
332    /// `font-size` in px if set as a length.
333    pub fn font_size_px(&self) -> Option<f32> {
334        self.font_size.as_ref().map(|l| l.px())
335    }
336
337    /// `color` as a hex string (only `Color::String` returns Some).
338    pub fn color_str(&self) -> Option<&str> {
339        match &self.color {
340            Some(Color::String(s)) => Some(s.as_str()),
341            _ => None,
342        }
343    }
344
345    /// `color` as a hex string with default fallback.
346    pub fn color_str_or<'a>(&'a self, default: &'a str) -> &'a str {
347        self.color_str().unwrap_or(default)
348    }
349
350    /// `font-family` string.
351    pub fn font_family_str(&self) -> Option<&str> {
352        self.font_family.as_deref()
353    }
354
355    /// `font-family` string with default fallback.
356    pub fn font_family_or<'a>(&'a self, default: &'a str) -> &'a str {
357        self.font_family.as_deref().unwrap_or(default)
358    }
359
360    /// `letter-spacing` in px, defaulting to 0.
361    pub fn letter_spacing_px(&self) -> f32 {
362        self.letter_spacing.as_ref().map(|l| l.px()).unwrap_or(0.0)
363    }
364
365    /// `line-height` resolution. For `Number` (unitless) returns
366    /// `n * font_size`; for `Length(px)` returns the px value; otherwise
367    /// returns `1.3 * font_size`.
368    pub fn line_height_for(&self, font_size: f32) -> f32 {
369        match &self.line_height {
370            Some(LineHeight::Number(n)) => n * font_size,
371            Some(LineHeight::Length(l)) => l.px(),
372            _ => font_size * 1.3,
373        }
374    }
375
376    // ---- Context-aware typography resolution (issue #125 §2) ----
377    //
378    // `font_size_px_or`/`letter_spacing_px`/`line_height_for` above are the
379    // context-free accessors ~50+ call sites across the engine use; they go
380    // through `Length::px()`, which cannot resolve `%`/`em`/`rem`/`vw`/`vh`
381    // (no `LengthContext` reaches them) and — as of this fix — warns loudly
382    // instead of silently dropping to `0px` when the value actually is one
383    // of those units (see `units::px_or_warn`). That is the "fail loudly"
384    // half of issue #125 §2.
385    //
386    // The methods below are the "or work" half: given a `LengthContext`,
387    // they resolve `%`/`em`/`rem`/`vw`/`vh` correctly for these three
388    // properties specifically, honouring the CSS rule that `em` means two
389    // different things depending on which property it's on:
390    //   - on `font-size` itself, `em` is relative to the *parent's* computed
391    //     font-size — i.e. `ctx.font_size` going in.
392    //   - on `letter-spacing` / `line-height`, `em` is relative to the
393    //     element's *own* (just-computed) font-size, not the parent's.
394    // `typography_px_ctx` below resolves all three together and gets this
395    // right by re-deriving the context between steps; the three individual
396    // methods are the building blocks for callers that need only one value,
397    // or that already have the right `ctx.font_size` for what they're
398    // resolving.
399    //
400    // What is NOT fixed by this, and is explicitly out of scope for this
401    // workstream (file allowlist: renderer/text.rs, css/units.rs,
402    // css/style.rs — not css/cascade.rs): `cascade::inherit_from` copies an
403    // inherited `font-size` down the tree as the raw, unresolved `Length` —
404    // not a resolved px value. So today nothing walks the tree computing
405    // "the actual parent font-size in px" to feed as `ctx.font_size` when
406    // resolving a child's `em` font-size; a caller that plugs in some other
407    // value (a default, the root font-size, whatever's convenient) gets a
408    // *technically* resolved but *semantically wrong* base for that one
409    // case. `rem` (always relative to a single scenario-wide root, not a
410    // per-ancestor chain) and `vw`/`vh` (relative to the real viewport) do
411    // NOT have this problem — they are fully correct via `ctx.root_font_size`
412    // / `ctx.viewport_*` regardless of cascade. `%` on `line-height` is
413    // special-cased below against the *own* font-size per CSS, not
414    // `ctx.parent_size`, so it isn't affected either. In short: `em`/`%` on
415    // `font-size` need a cascade.rs fix to be fully correct end-to-end;
416    // everything else these methods resolve is correct today.
417    //
418    // These are additive — nothing above changes signature, and nothing
419    // currently in the engine calls these yet, since every existing call
420    // site (`rustmotion-components/**`) is outside this workstream's file
421    // scope. Wiring a real `LengthContext` (viewport dims from `PaintCtx`,
422    // parent font-size from a resolved-cascade) into those call sites is the
423    // integration step a sibling workstream (or a follow-up PR) needs to do
424    // for relative units on type to actually reach rendered output.
425
426    /// `font-size` resolved against `ctx`, correctly handling
427    /// `%`/`em`/`rem`/`vw`/`vh` — unlike [`Self::font_size_px_or`]. `em`/`%`
428    /// resolve against `ctx.font_size`, which the caller should set to the
429    /// parent's *actual computed* font-size in px for correctness (see the
430    /// module note above on why nothing does that yet).
431    pub fn font_size_px_ctx(&self, ctx: &LengthContext, default: f32) -> f32 {
432        self.font_size
433            .as_ref()
434            .and_then(|l| l.parse().resolve(ctx))
435            .unwrap_or(default)
436    }
437
438    /// `letter-spacing` resolved against `ctx`, correctly handling
439    /// `%`/`em`/`rem`/`vw`/`vh` — unlike [`Self::letter_spacing_px`]. Per
440    /// CSS, `em` here means the element's *own* font-size, so pass a `ctx`
441    /// whose `font_size` is the already-resolved own font-size (e.g. via
442    /// [`Self::font_size_px_ctx`]), not the parent's — see
443    /// [`Self::typography_px_ctx`] for a helper that gets this right
444    /// automatically.
445    pub fn letter_spacing_px_ctx(&self, ctx: &LengthContext) -> f32 {
446        self.letter_spacing
447            .as_ref()
448            .and_then(|l| l.parse().resolve(ctx))
449            .unwrap_or(0.0)
450    }
451
452    /// `line-height` resolved against `ctx`, correctly handling
453    /// `%`/`em`/`rem`/`vw`/`vh` — unlike [`Self::line_height_for`].
454    /// `LineHeight::Number` (unitless, e.g. `1.5`) is unaffected — it always
455    /// means `n * font_size` regardless of any context. For
456    /// `LineHeight::Length`, `%` is special-cased to CSS's actual rule for
457    /// this property (relative to the element's *own* font-size, not
458    /// `ctx.parent_size` like `%` normally means): a generic
459    /// `ParsedLength::resolve` would silently resolve it against the wrong
460    /// base otherwise. Same own-vs-parent `em` caveat as
461    /// [`Self::letter_spacing_px_ctx`] applies.
462    pub fn line_height_for_ctx(&self, font_size: f32, ctx: &LengthContext) -> f32 {
463        match &self.line_height {
464            Some(LineHeight::Number(n)) => n * font_size,
465            Some(LineHeight::Length(lp)) => match lp.parse() {
466                ParsedLength::Percent(p) => p / 100.0 * font_size,
467                other => other.resolve(ctx).unwrap_or(font_size * 1.3),
468            },
469            _ => font_size * 1.3,
470        }
471    }
472
473    /// Resolve `font-size`, `letter-spacing`, and `line-height` together in
474    /// one call, honouring CSS's two different `em` bases (see the module
475    /// note above `font_size_px_ctx`): `font-size`'s own `em` resolves
476    /// against `ctx.font_size` (conventionally the parent's font-size),
477    /// while `letter-spacing`'s and `line-height`'s `em` resolve against the
478    /// just-computed *own* font-size, not `ctx.font_size` again. Returns
479    /// `(font_size_px, letter_spacing_px, line_height_px)`.
480    pub fn typography_px_ctx(
481        &self,
482        ctx: &LengthContext,
483        default_font_size: f32,
484    ) -> (f32, f32, f32) {
485        let font_size = self.font_size_px_ctx(ctx, default_font_size);
486        let own_ctx = LengthContext { font_size, ..*ctx };
487        let letter_spacing = self.letter_spacing_px_ctx(&own_ctx);
488        let line_height = self.line_height_for_ctx(font_size, &own_ctx);
489        (font_size, letter_spacing, line_height)
490    }
491
492    /// `opacity` with default 1.0.
493    pub fn opacity_or(&self, default: f32) -> f32 {
494        self.opacity.unwrap_or(default)
495    }
496
497    /// `border-radius` resolved as a single uniform px value (drops per-corner).
498    pub fn border_radius_px(&self) -> Option<f32> {
499        match &self.border_radius {
500            Some(BorderRadius::Uniform(lp)) => Some(lp.px()),
501            Some(BorderRadius::Corners { top_left, .. }) => Some(top_left.px()),
502            None => None,
503        }
504    }
505
506    /// `border-radius` as px, defaulting to `default`.
507    pub fn border_radius_px_or(&self, default: f32) -> f32 {
508        self.border_radius_px().unwrap_or(default)
509    }
510
511    /// Resolved padding tuple `(top, right, bottom, left)` in px.
512    pub fn padding_px(&self) -> (f32, f32, f32, f32) {
513        edges_px(self.padding.as_ref())
514    }
515
516    /// Resolved margin tuple `(top, right, bottom, left)` in px.
517    pub fn margin_px(&self) -> (f32, f32, f32, f32) {
518        edges_px(self.margin.as_ref())
519    }
520
521    /// `background` as a hex/keyword string when set as a plain color.
522    pub fn background_color_str(&self) -> Option<&str> {
523        match &self.background {
524            Some(Background::Color(Color::String(s))) => Some(s.as_str()),
525            _ => None,
526        }
527    }
528}
529
530fn edges_px(e: Option<&Edges>) -> (f32, f32, f32, f32) {
531    match e {
532        Some(Edges::Uniform(v)) => {
533            let p = v.px();
534            (p, p, p, p)
535        }
536        Some(Edges::Sides {
537            top,
538            right,
539            bottom,
540            left,
541        }) => (top.px(), right.px(), bottom.px(), left.px()),
542        None => (0.0, 0.0, 0.0, 0.0),
543    }
544}
545
546// ---- Layout enums ----
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
549#[serde(rename_all = "kebab-case")]
550pub enum Display {
551    Block,
552    Flex,
553    Grid,
554    InlineBlock,
555    None,
556    Contents,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
560#[serde(rename_all = "kebab-case")]
561pub enum Position {
562    Static,
563    Relative,
564    Absolute,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568#[serde(rename_all = "kebab-case")]
569pub enum BoxSizing {
570    ContentBox,
571    BorderBox,
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
575#[serde(rename_all = "kebab-case")]
576pub enum Overflow {
577    Visible,
578    Hidden,
579    Auto,
580    Scroll,
581    Clip,
582}
583
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
585#[serde(rename_all = "kebab-case")]
586pub enum Visibility {
587    Visible,
588    Hidden,
589}
590
591/// `width: <length>` / `width: auto` / `width: 50%` / `width: max-content` / etc.
592///
593/// Constat #6: `#[serde(untagged)]` tries variants in declaration order and
594/// keeps the first that succeeds. `Length(LengthPercentage)` has its own
595/// `String` catch-all variant that accepts *any* string — so with `Keyword`
596/// declared after `Length` (as this used to be), `"max-content"` matched
597/// `Length(String("max-content"))` before `Keyword` was ever tried:
598/// `max-content`/`min-content`/`fit-content` were unreachable, dead schema.
599/// `Keyword` must come before the `Length` catch-all; `Auto` before either
600/// is fine since it needs an exact `"auto"` match nothing else claims first.
601#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
602#[serde(untagged)]
603pub enum Size {
604    Auto(AutoKw),
605    Keyword(SizeKeyword),
606    Length(LengthPercentage),
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
610#[serde(rename_all = "kebab-case")]
611pub enum AutoKw {
612    Auto,
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
616#[serde(rename_all = "kebab-case")]
617pub enum SizeKeyword {
618    MaxContent,
619    MinContent,
620    FitContent,
621}
622
623/// Edge values for `margin` / `padding`. Either uniform or per-side.
624///
625/// Constat #2: `CssStyle` itself has `deny_unknown_fields`, which gives the
626/// impression that any bad key under `style` is rejected — but one level
627/// down, `Sides`'s four fields are all `#[serde(default)]` with no
628/// `deny_unknown_fields` of its own. Since this is an untagged enum, a
629/// well-meaning but unsupported shape like `{"horizontal": 20}` (the exact
630/// form the LAYOUT `margin-left` rule teaches LLMs to reach for) fails to
631/// match `Uniform` (not a scalar) and then matches `Sides` anyway — every
632/// side defaults to 0, no error. `deny_unknown_fields` here closes that: an
633/// object that isn't a recognised `{top,right,bottom,left}` shape now fails
634/// to match either variant, and the untagged enum reports it.
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
636#[serde(untagged, deny_unknown_fields)]
637pub enum Edges {
638    Uniform(LengthPercentage),
639    Sides {
640        #[serde(default)]
641        top: LengthPercentage,
642        #[serde(default)]
643        right: LengthPercentage,
644        #[serde(default)]
645        bottom: LengthPercentage,
646        #[serde(default)]
647        left: LengthPercentage,
648    },
649}
650
651impl Edges {
652    pub fn resolve(
653        &self,
654    ) -> (
655        LengthPercentage,
656        LengthPercentage,
657        LengthPercentage,
658        LengthPercentage,
659    ) {
660        match self {
661            Edges::Uniform(v) => (v.clone(), v.clone(), v.clone(), v.clone()),
662            Edges::Sides {
663                top,
664                right,
665                bottom,
666                left,
667            } => (top.clone(), right.clone(), bottom.clone(), left.clone()),
668        }
669    }
670}
671
672/// `border: 1px solid red` modeled per side.
673#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
674#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
675pub struct BorderEdges {
676    pub width: Option<Edges>,
677    pub style: Option<BorderStyle>,
678    pub color: Option<Color>,
679    /// Per-side overrides.
680    pub top: Option<BorderSide>,
681    pub right: Option<BorderSide>,
682    pub bottom: Option<BorderSide>,
683    pub left: Option<BorderSide>,
684}
685
686#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
687#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
688pub struct BorderSide {
689    pub width: Option<Length>,
690    pub style: Option<BorderStyle>,
691    pub color: Option<Color>,
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
695#[serde(rename_all = "kebab-case")]
696pub enum BorderStyle {
697    None,
698    Solid,
699    Dashed,
700    Dotted,
701    Double,
702}
703
704/// Border-radius: uniform or per-corner.
705///
706/// Constat #1: every other composite in this file is kebab-case on the wire
707/// (`box-shadow` -> `offset-x`/`offset-y`, `transform-origin` -> `x`/`y`,
708/// etc. — see `rules/component-field-placement.md`). `Corners` used to be
709/// the sole snake_case outlier (`top_left`/...), with no `deny_unknown_fields`
710/// and every field defaulted — so the kebab form a CSS-literate author (or
711/// LLM) naturally writes matched *zero* declared fields, and being an
712/// untagged enum, serde didn't complain: it just produced `Corners` with
713/// every corner at 0px, silently. `rename_all = "kebab-case"` makes kebab
714/// the canonical wire form (matching every neighbour); `alias` keeps the
715/// original snake_case working for any scenario already written that way;
716/// `deny_unknown_fields` turns any other spelling (a genuine typo) into a
717/// named parse error instead of a third silent zero.
718#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
719#[serde(untagged, deny_unknown_fields)]
720pub enum BorderRadius {
721    Uniform(LengthPercentage),
722    Corners {
723        #[serde(default, alias = "top_left")]
724        #[serde(rename = "top-left")]
725        top_left: LengthPercentage,
726        #[serde(default, alias = "top_right")]
727        #[serde(rename = "top-right")]
728        top_right: LengthPercentage,
729        #[serde(default, alias = "bottom_right")]
730        #[serde(rename = "bottom-right")]
731        bottom_right: LengthPercentage,
732        #[serde(default, alias = "bottom_left")]
733        #[serde(rename = "bottom-left")]
734        bottom_left: LengthPercentage,
735    },
736}
737
738impl BorderRadius {
739    /// The single uniform radius as an absolute pixel value, or `None` when
740    /// this isn't a shape a context-free (pre-layout) resolver can safely
741    /// interpolate: per-corner radii (which corner "wins" a 2-point
742    /// interpolation is undefined), or a unit that needs a
743    /// [`crate::css::units::LengthContext`] the caller doesn't have yet
744    /// (`%`/`em`/`rem`/`vw`/`vh` — see the "unités mixtes" decision in
745    /// `box_builder.rs`'s `resolve_transition_overrides`: resolved only
746    /// where both endpoints are unambiguous, refused otherwise rather than
747    /// guessed). Used by `box_builder.rs` (`style.transition` smoothing) and
748    /// `validate_schema.rs` (the matching diagnostic) — both must agree on
749    /// exactly which shapes are interpolable, which is why this lives here
750    /// once instead of being reimplemented on each side.
751    pub fn absolute_px(&self) -> Option<f32> {
752        match self {
753            BorderRadius::Uniform(lp) => match lp.try_parse() {
754                Some(crate::css::units::ParsedLength::Px(v)) => Some(v),
755                _ => None,
756            },
757            BorderRadius::Corners { .. } => None,
758        }
759    }
760}
761
762// ---- Flex ----
763
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
765#[serde(rename_all = "kebab-case")]
766pub enum FlexDirection {
767    Row,
768    RowReverse,
769    Column,
770    ColumnReverse,
771}
772
773#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
774#[serde(rename_all = "kebab-case")]
775pub enum FlexWrap {
776    Nowrap,
777    Wrap,
778    WrapReverse,
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
782#[serde(rename_all = "kebab-case")]
783pub enum JustifyContent {
784    FlexStart,
785    FlexEnd,
786    Center,
787    SpaceBetween,
788    SpaceAround,
789    SpaceEvenly,
790    Start,
791    End,
792}
793
794#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
795#[serde(rename_all = "kebab-case")]
796pub enum AlignItems {
797    Stretch,
798    FlexStart,
799    FlexEnd,
800    Center,
801    Baseline,
802    Start,
803    End,
804}
805
806#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
807#[serde(rename_all = "kebab-case")]
808pub enum AlignSelf {
809    Auto,
810    Stretch,
811    FlexStart,
812    FlexEnd,
813    Center,
814    Baseline,
815    Start,
816    End,
817}
818
819#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
820#[serde(rename_all = "kebab-case")]
821pub enum AlignContent {
822    Stretch,
823    FlexStart,
824    FlexEnd,
825    Center,
826    SpaceBetween,
827    SpaceAround,
828    SpaceEvenly,
829    Start,
830    End,
831}
832
833/// `gap: 8px` (uniform) or `gap: 8px 16px` (row-gap, column-gap).
834#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
835#[serde(untagged)]
836pub enum Gap {
837    Uniform(LengthPercentage),
838    RowColumn {
839        row: LengthPercentage,
840        column: LengthPercentage,
841    },
842}
843
844// ---- Grid ----
845
846/// A single grid track (column or row) sizing function.
847///
848/// Variant order matters here: this is `#[serde(untagged)]`, and serde tries
849/// each variant in declaration order, keeping the first that deserializes
850/// successfully. `Length(LengthPercentage)` accepts *any* JSON number or
851/// string (its own `String` fallback variant is a catch-all), so it must be
852/// tried last — otherwise it silently swallows bare numbers (meant to be
853/// `Fr`, matching the `flex-grow` convention) and keyword strings like
854/// `"auto"` (meant to be `Keyword`).
855#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
856#[serde(untagged)]
857pub enum GridTrack {
858    /// Bare JSON number, e.g. `1` — a flex fraction, same convention as
859    /// `flex-grow`. Equivalent to the string form `"1fr"`.
860    Fr(f32),
861    /// `"auto"` / `"min-content"` / `"max-content"`.
862    Keyword(GridTrackKeyword),
863    /// Any other length/percentage, including the explicit string form of a
864    /// flex fraction (`"1fr"`), which `LengthPercentage::parse()` resolves
865    /// to the same `ParsedLength::Fr` as the bare-number form above.
866    Length(LengthPercentage),
867    /// `minmax(min, max)`
868    Minmax {
869        min: Box<GridTrack>,
870        max: Box<GridTrack>,
871    },
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
875#[serde(rename_all = "kebab-case")]
876pub enum GridTrackKeyword {
877    Auto,
878    MinContent,
879    MaxContent,
880}
881
882#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
883#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
884#[derive(Default)]
885pub struct GridLine {
886    pub start: Option<GridLineEnd>,
887    pub end: Option<GridLineEnd>,
888    pub span: Option<u16>,
889}
890
891#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
892#[serde(untagged)]
893pub enum GridLineEnd {
894    Index(i32),
895}
896
897#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
898#[serde(rename_all = "kebab-case")]
899pub enum GridAutoFlow {
900    Row,
901    Column,
902    RowDense,
903    ColumnDense,
904}
905
906#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
907#[serde(rename_all = "kebab-case")]
908pub enum JustifyItems {
909    Stretch,
910    Start,
911    End,
912    Center,
913    Legacy,
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
917#[serde(rename_all = "kebab-case")]
918pub enum JustifySelf {
919    Auto,
920    Stretch,
921    Start,
922    End,
923    Center,
924}
925
926// ---- Typography ----
927
928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
929#[serde(untagged)]
930pub enum FontWeight {
931    Keyword(FontWeightKw),
932    Number(u16),
933}
934
935#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
936#[serde(rename_all = "kebab-case")]
937pub enum FontWeightKw {
938    Normal,
939    Bold,
940    Bolder,
941    Lighter,
942}
943
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
945#[serde(rename_all = "kebab-case")]
946pub enum FontStyle {
947    Normal,
948    Italic,
949    Oblique,
950}
951
952/// `line-height: 1.5` (number) or `line-height: 24px` (length).
953///
954/// Same class of bug as constat #6 on [`Size`], found while auditing this
955/// file for other untagged enums with a catch-all before a specific variant:
956/// `Length(LengthPercentage)`'s `String` fallback accepts any string, so
957/// with `Keyword` declared after it, `"normal"` matched
958/// `Length(String("normal"))` — which then resolves through
959/// `Length::px()`/`.parse()` as an unparseable length, falling back to 0 —
960/// instead of `Keyword(LineHeightKw::Normal)`. `Keyword` now comes first.
961#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
962#[serde(untagged)]
963pub enum LineHeight {
964    Number(f32),
965    Keyword(LineHeightKw),
966    Length(LengthPercentage),
967}
968
969#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
970#[serde(rename_all = "kebab-case")]
971pub enum LineHeightKw {
972    Normal,
973}
974
975#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
976#[serde(rename_all = "kebab-case")]
977pub enum TextAlign {
978    Left,
979    Right,
980    Center,
981    Justify,
982    Start,
983    End,
984}
985
986#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
987#[serde(rename_all = "kebab-case")]
988pub enum WhiteSpace {
989    Normal,
990    Nowrap,
991    Pre,
992    PreLine,
993    PreWrap,
994    BreakSpaces,
995}
996
997#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
998#[serde(rename_all = "kebab-case")]
999pub enum OverflowWrap {
1000    Normal,
1001    BreakWord,
1002    Anywhere,
1003}
1004
1005#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1006#[serde(rename_all = "kebab-case")]
1007pub enum TextOverflow {
1008    Clip,
1009    Ellipsis,
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1013#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1014pub struct TextDecoration {
1015    pub line: Option<TextDecorationLine>,
1016    pub style: Option<TextDecorationStyle>,
1017    pub color: Option<Color>,
1018    pub thickness: Option<Length>,
1019}
1020
1021#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1022#[serde(rename_all = "kebab-case")]
1023pub enum TextDecorationLine {
1024    None,
1025    Underline,
1026    Overline,
1027    LineThrough,
1028}
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1031#[serde(rename_all = "kebab-case")]
1032pub enum TextDecorationStyle {
1033    Solid,
1034    Double,
1035    Dotted,
1036    Dashed,
1037    Wavy,
1038}
1039
1040// ---- Color ----
1041
1042/// Typed color. Strings are parsed lazily ("#rgb", "rgba(..)", named colors).
1043#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1044#[serde(untagged)]
1045pub enum Color {
1046    String(String),
1047    Rgba {
1048        r: u8,
1049        g: u8,
1050        b: u8,
1051        #[serde(default = "one_f32")]
1052        a: f32,
1053    },
1054}
1055
1056fn one_f32() -> f32 {
1057    1.0
1058}
1059
1060impl Color {
1061    /// CSS-string form: pass strings through, format rgba as `#rrggbb[aa]`.
1062    pub fn to_css_string(&self) -> String {
1063        match self {
1064            Color::String(s) => s.clone(),
1065            Color::Rgba { r, g, b, a } => {
1066                if *a >= 1.0 {
1067                    format!("#{r:02x}{g:02x}{b:02x}")
1068                } else {
1069                    let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
1070                    format!("#{r:02x}{g:02x}{b:02x}{alpha:02x}")
1071                }
1072            }
1073        }
1074    }
1075}
1076
1077// ---- Background ----
1078
1079#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1080#[serde(untagged)]
1081pub enum Background {
1082    Color(Color),
1083    Layers(Vec<BackgroundLayer>),
1084    Single(BackgroundLayer),
1085}
1086
1087impl Background {
1088    /// The background's hex/rgba string when it's a plain solid colour, or
1089    /// `None` for anything else (gradients, image layers, multi-layer
1090    /// stacks) — those need real paint-time compositing to interpolate
1091    /// correctly, which is out of reach for a pre-layout `CssStyle` value.
1092    /// Same shared-predicate rationale as [`BorderRadius::absolute_px`]:
1093    /// `box_builder.rs`'s smoothing and `validate_schema.rs`'s diagnostic
1094    /// both call this so they can never disagree about what's interpolable.
1095    pub fn solid_hex(&self) -> Option<String> {
1096        match self {
1097            Background::Color(c) => Some(c.to_css_string()),
1098            Background::Layers(_) | Background::Single(_) => None,
1099        }
1100    }
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1104#[serde(tag = "kind", rename_all = "kebab-case")]
1105pub enum BackgroundLayer {
1106    Color {
1107        color: Color,
1108    },
1109    LinearGradient {
1110        #[serde(default)]
1111        angle: Option<f32>,
1112        stops: Vec<GradientStop>,
1113    },
1114    RadialGradient {
1115        #[serde(default)]
1116        shape: Option<RadialShape>,
1117        #[serde(default)]
1118        position: Option<TransformOrigin>,
1119        stops: Vec<GradientStop>,
1120    },
1121    ConicGradient {
1122        #[serde(default)]
1123        from: Option<f32>,
1124        #[serde(default)]
1125        position: Option<TransformOrigin>,
1126        stops: Vec<GradientStop>,
1127    },
1128    Image {
1129        url: String,
1130        #[serde(default)]
1131        size: Option<BackgroundSize>,
1132        #[serde(default)]
1133        position: Option<TransformOrigin>,
1134        #[serde(default)]
1135        repeat: Option<BackgroundRepeat>,
1136    },
1137}
1138
1139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1140#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1141pub struct GradientStop {
1142    pub color: Color,
1143    pub offset: Option<f32>,
1144}
1145
1146impl Default for GradientStop {
1147    fn default() -> Self {
1148        Self {
1149            color: Color::String("#000000".into()),
1150            offset: None,
1151        }
1152    }
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1156#[serde(rename_all = "kebab-case")]
1157pub enum RadialShape {
1158    Circle,
1159    Ellipse,
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1163#[serde(rename_all = "kebab-case")]
1164pub enum BackgroundSize {
1165    Cover,
1166    Contain,
1167    Auto,
1168    Length {
1169        width: LengthPercentage,
1170        height: LengthPercentage,
1171    },
1172}
1173
1174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1175#[serde(rename_all = "kebab-case")]
1176pub enum BackgroundRepeat {
1177    Repeat,
1178    NoRepeat,
1179    RepeatX,
1180    RepeatY,
1181    Round,
1182    Space,
1183}
1184
1185// ---- Shadows ----
1186
1187#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1188#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1189pub struct BoxShadow {
1190    pub offset_x: Length,
1191    pub offset_y: Length,
1192    pub blur: Option<Length>,
1193    pub spread: Option<Length>,
1194    pub color: Option<Color>,
1195    pub inset: Option<bool>,
1196}
1197
1198#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1199#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1200pub struct TextShadow {
1201    pub offset_x: Length,
1202    pub offset_y: Length,
1203    pub blur: Option<Length>,
1204    pub color: Option<Color>,
1205}
1206
1207impl TextShadow {
1208    /// Resolve into the legacy schema shadow consumed by the text painters.
1209    pub fn to_schema(&self, ctx: &crate::css::units::LengthContext) -> crate::schema::TextShadow {
1210        crate::schema::TextShadow {
1211            color: self
1212                .color
1213                .as_ref()
1214                .map(Color::to_css_string)
1215                .unwrap_or_else(|| "#000000".to_string()),
1216            offset_x: self.offset_x.resolve(ctx),
1217            offset_y: self.offset_y.resolve(ctx),
1218            blur: self.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0),
1219        }
1220    }
1221}
1222
1223// ---- Transform ----
1224
1225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1226#[serde(tag = "fn", rename_all = "kebab-case")]
1227pub enum TransformFn {
1228    Translate {
1229        x: LengthPercentage,
1230        #[serde(default)]
1231        y: LengthPercentage,
1232    },
1233    TranslateX {
1234        x: LengthPercentage,
1235    },
1236    TranslateY {
1237        y: LengthPercentage,
1238    },
1239    TranslateZ {
1240        z: Length,
1241    },
1242    Translate3d {
1243        x: LengthPercentage,
1244        y: LengthPercentage,
1245        z: Length,
1246    },
1247    Scale {
1248        x: f32,
1249        #[serde(default = "one_f32")]
1250        y: f32,
1251    },
1252    ScaleX {
1253        x: f32,
1254    },
1255    ScaleY {
1256        y: f32,
1257    },
1258    ScaleZ {
1259        z: f32,
1260    },
1261    Scale3d {
1262        x: f32,
1263        y: f32,
1264        z: f32,
1265    },
1266    Rotate {
1267        deg: f32,
1268    },
1269    RotateX {
1270        deg: f32,
1271    },
1272    RotateY {
1273        deg: f32,
1274    },
1275    RotateZ {
1276        deg: f32,
1277    },
1278    Rotate3d {
1279        x: f32,
1280        y: f32,
1281        z: f32,
1282        deg: f32,
1283    },
1284    Skew {
1285        x: f32,
1286        #[serde(default)]
1287        y: f32,
1288    },
1289    SkewX {
1290        x: f32,
1291    },
1292    SkewY {
1293        y: f32,
1294    },
1295    Perspective {
1296        length: Length,
1297    },
1298    Matrix {
1299        values: [f32; 6],
1300    },
1301    Matrix3d {
1302        values: [f32; 16],
1303    },
1304}
1305
1306#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
1307#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
1308pub struct TransformOrigin {
1309    pub x: Option<LengthPercentage>,
1310    pub y: Option<LengthPercentage>,
1311    pub z: Option<Length>,
1312}
1313
1314// ---- Filters ----
1315
1316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1317#[serde(tag = "fn", rename_all = "kebab-case")]
1318pub enum FilterFn {
1319    Blur {
1320        radius: Length,
1321    },
1322    Brightness {
1323        value: f32,
1324    },
1325    Contrast {
1326        value: f32,
1327    },
1328    Saturate {
1329        value: f32,
1330    },
1331    HueRotate {
1332        deg: f32,
1333    },
1334    Grayscale {
1335        value: f32,
1336    },
1337    Invert {
1338        value: f32,
1339    },
1340    Sepia {
1341        value: f32,
1342    },
1343    DropShadow {
1344        offset_x: Length,
1345        offset_y: Length,
1346        #[serde(default)]
1347        blur: Option<Length>,
1348        #[serde(default)]
1349        color: Option<Color>,
1350    },
1351    Opacity {
1352        value: f32,
1353    },
1354    /// Deterministic film-grain noise. Works in both `filter` and
1355    /// `backdrop-filter` chains (frosted-glass grain).
1356    Noise {
1357        /// Grain strength in 0..1 (alpha of the noise layer). Default 0.15.
1358        #[serde(default = "default_noise_intensity")]
1359        intensity: f32,
1360        /// Perlin-noise seed — same seed ⇒ identical grain on every frame.
1361        #[serde(default = "default_noise_seed")]
1362        seed: u64,
1363    },
1364}
1365
1366fn default_noise_intensity() -> f32 {
1367    0.15
1368}
1369
1370fn default_noise_seed() -> u64 {
1371    42
1372}
1373
1374// ---- Blend ----
1375
1376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1377#[serde(rename_all = "kebab-case")]
1378pub enum BlendMode {
1379    Normal,
1380    Multiply,
1381    Screen,
1382    Overlay,
1383    Darken,
1384    Lighten,
1385    ColorDodge,
1386    ColorBurn,
1387    HardLight,
1388    SoftLight,
1389    Difference,
1390    Exclusion,
1391    Hue,
1392    Saturation,
1393    Color,
1394    Luminosity,
1395    PlusLighter,
1396}
1397
1398// ---- Clip-path ----
1399
1400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1401#[serde(tag = "kind", rename_all = "kebab-case")]
1402pub enum ClipPath {
1403    None,
1404    Inset {
1405        top: LengthPercentage,
1406        right: LengthPercentage,
1407        bottom: LengthPercentage,
1408        left: LengthPercentage,
1409        #[serde(default)]
1410        radius: Option<BorderRadius>,
1411    },
1412    Circle {
1413        radius: LengthPercentage,
1414        #[serde(default)]
1415        origin: Option<TransformOrigin>,
1416    },
1417    Ellipse {
1418        rx: LengthPercentage,
1419        ry: LengthPercentage,
1420        #[serde(default)]
1421        origin: Option<TransformOrigin>,
1422    },
1423    Polygon {
1424        points: Vec<(LengthPercentage, LengthPercentage)>,
1425    },
1426    Path {
1427        d: String,
1428    },
1429}
1430
1431// ---- Tests ----
1432
1433#[cfg(test)]
1434mod tests {
1435    use super::*;
1436
1437    #[test]
1438    fn default_is_all_none() {
1439        let s = CssStyle::default();
1440        assert!(s.display.is_none());
1441        assert!(s.padding.is_none());
1442        assert!(s.transform.is_none());
1443    }
1444
1445    #[test]
1446    fn deserialize_basic_flex() {
1447        let json = r#"{
1448            "display": "flex",
1449            "flex-direction": "column",
1450            "gap": "16px",
1451            "align-items": "center",
1452            "padding": "24px"
1453        }"#;
1454        let s: CssStyle = serde_json::from_str(json).unwrap();
1455        assert_eq!(s.display, Some(Display::Flex));
1456        assert_eq!(s.flex_direction, Some(FlexDirection::Column));
1457        assert_eq!(s.align_items, Some(AlignItems::Center));
1458        assert!(matches!(s.padding, Some(Edges::Uniform(_))));
1459    }
1460
1461    #[test]
1462    fn deserialize_per_side_padding() {
1463        let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
1464        let s: CssStyle = serde_json::from_str(json).unwrap();
1465        assert!(matches!(s.padding, Some(Edges::Sides { .. })));
1466    }
1467
1468    #[test]
1469    fn deserialize_color_variants() {
1470        let s1: CssStyle = serde_json::from_str(r##"{ "color": "#ff0000" }"##).unwrap();
1471        let s2: CssStyle =
1472            serde_json::from_str(r##"{ "color": { "r": 255, "g": 0, "b": 0, "a": 1.0 } }"##)
1473                .unwrap();
1474        assert!(matches!(s1.color, Some(Color::String(_))));
1475        assert!(matches!(s2.color, Some(Color::Rgba { r: 255, .. })));
1476    }
1477
1478    #[test]
1479    fn deserialize_transform_list() {
1480        let json = r#"{ "transform": [
1481            { "fn": "translate-x", "x": "10px" },
1482            { "fn": "scale", "x": 1.5, "y": 1.5 },
1483            { "fn": "rotate", "deg": 45.0 }
1484        ]}"#;
1485        let s: CssStyle = serde_json::from_str(json).unwrap();
1486        let t = s.transform.expect("transform set");
1487        assert_eq!(t.len(), 3);
1488    }
1489
1490    #[test]
1491    fn roundtrip_serialization() {
1492        let original = CssStyle {
1493            display: Some(Display::Flex),
1494            opacity: Some(0.5),
1495            z_index: Some(10),
1496            ..Default::default()
1497        };
1498        let json = serde_json::to_string(&original).unwrap();
1499        let parsed: CssStyle = serde_json::from_str(&json).unwrap();
1500        assert_eq!(parsed.display, Some(Display::Flex));
1501        assert_eq!(parsed.opacity, Some(0.5));
1502        assert_eq!(parsed.z_index, Some(10));
1503    }
1504
1505    // ---- Grid track deserialization (issue #105) ----
1506    //
1507    // `GridTrack` is `#[serde(untagged)]`; these lock in which variant a
1508    // given JSON shape resolves to, since that resolution previously
1509    // silently swallowed both `Fr` and `Keyword` into `Length`.
1510
1511    #[test]
1512    fn grid_track_bare_number_is_fr() {
1513        let json = r#"{ "grid-template-columns": [1, 1, 1] }"#;
1514        let s: CssStyle = serde_json::from_str(json).unwrap();
1515        let tracks = s.grid_template_columns.expect("tracks set");
1516        assert_eq!(tracks.len(), 3);
1517        for t in &tracks {
1518            assert!(matches!(t, GridTrack::Fr(n) if (*n - 1.0).abs() < f32::EPSILON));
1519        }
1520    }
1521
1522    #[test]
1523    fn grid_track_string_fr_is_length_parsed_as_fr() {
1524        let json = r#"{ "grid-template-columns": ["1fr", "2fr"] }"#;
1525        let s: CssStyle = serde_json::from_str(json).unwrap();
1526        let tracks = s.grid_template_columns.expect("tracks set");
1527        match &tracks[0] {
1528            GridTrack::Length(lp) => {
1529                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Fr(1.0))
1530            }
1531            other => panic!("expected Length(\"1fr\"), got {other:?}"),
1532        }
1533        match &tracks[1] {
1534            GridTrack::Length(lp) => {
1535                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Fr(2.0))
1536            }
1537            other => panic!("expected Length(\"2fr\"), got {other:?}"),
1538        }
1539    }
1540
1541    #[test]
1542    fn grid_track_keyword_strings() {
1543        let json = r#"{ "grid-template-columns": ["auto", "min-content", "max-content"] }"#;
1544        let s: CssStyle = serde_json::from_str(json).unwrap();
1545        let tracks = s.grid_template_columns.expect("tracks set");
1546        assert!(matches!(
1547            tracks[0],
1548            GridTrack::Keyword(GridTrackKeyword::Auto)
1549        ));
1550        assert!(matches!(
1551            tracks[1],
1552            GridTrack::Keyword(GridTrackKeyword::MinContent)
1553        ));
1554        assert!(matches!(
1555            tracks[2],
1556            GridTrack::Keyword(GridTrackKeyword::MaxContent)
1557        ));
1558    }
1559
1560    #[test]
1561    fn grid_track_px_string_is_length() {
1562        let json = r#"{ "grid-template-columns": ["200px", "50%"] }"#;
1563        let s: CssStyle = serde_json::from_str(json).unwrap();
1564        let tracks = s.grid_template_columns.expect("tracks set");
1565        match &tracks[0] {
1566            GridTrack::Length(lp) => {
1567                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Px(200.0))
1568            }
1569            other => panic!("expected Length(200px), got {other:?}"),
1570        }
1571        match &tracks[1] {
1572            GridTrack::Length(lp) => {
1573                assert_eq!(lp.parse(), crate::css::units::ParsedLength::Percent(50.0))
1574            }
1575            other => panic!("expected Length(50%), got {other:?}"),
1576        }
1577    }
1578
1579    // ---- issue #125 §2: context-aware typography resolution ----
1580
1581    fn style_with(font_size: &str, letter_spacing: &str, line_height: &str) -> CssStyle {
1582        let json = format!(
1583            r#"{{ "font-size": {font_size}, "letter-spacing": {letter_spacing}, "line-height": {line_height} }}"#
1584        );
1585        serde_json::from_str(&json).unwrap()
1586    }
1587
1588    #[test]
1589    fn font_size_px_ctx_resolves_vw() {
1590        let s = style_with(r#""15.6vw""#, "0", "1");
1591        let ctx = LengthContext {
1592            viewport_width: 1920.0,
1593            ..Default::default()
1594        };
1595        // The exact regression from issue #125 §2: `font-size: "15.6vw"`
1596        // used to resolve to 0 via `.px()` (rendering nothing / a black
1597        // frame). Through a LengthContext it resolves correctly.
1598        assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 15.6 / 100.0 * 1920.0);
1599        // The context-free accessor still can't do this — proving the two
1600        // are genuinely different code paths, not the same thing renamed.
1601        assert_eq!(s.font_size_px_or(48.0), 0.0);
1602    }
1603
1604    #[test]
1605    fn font_size_px_ctx_resolves_rem_without_cascade_dependency() {
1606        let s = style_with(r#""2rem""#, "0", "1");
1607        let ctx = LengthContext {
1608            root_font_size: 20.0,
1609            ..Default::default()
1610        };
1611        // rem is relative to a single scenario-wide root font-size, not a
1612        // per-ancestor chain — no cascade.rs involvement needed for this to
1613        // be correct.
1614        assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 40.0);
1615    }
1616
1617    #[test]
1618    fn font_size_px_ctx_falls_back_to_default_when_unset() {
1619        let s = CssStyle::default();
1620        assert_eq!(s.font_size_px_ctx(&LengthContext::default(), 48.0), 48.0);
1621    }
1622
1623    #[test]
1624    fn letter_spacing_px_ctx_resolves_own_em_not_parent_em() {
1625        // letter-spacing's `em` is relative to the *element's own*
1626        // font-size, not whatever `ctx.font_size` happened to be for
1627        // resolving font-size itself.
1628        let s = style_with("300", r#""-0.03em""#, "1");
1629        let own_ctx = LengthContext {
1630            font_size: 300.0, // the element's own resolved font-size
1631            ..Default::default()
1632        };
1633        assert!((s.letter_spacing_px_ctx(&own_ctx) - (-9.0)).abs() < 1e-4);
1634        // Context-free path can't resolve this at all (issue #125 §2): it
1635        // silently (now loudly, but still numerically) drops to 0, which is
1636        // byte-identical to a deliberate zero tracking.
1637        assert_eq!(s.letter_spacing_px(), 0.0);
1638    }
1639
1640    #[test]
1641    fn line_height_percent_resolves_against_own_font_size_not_parent_size() {
1642        // CSS special case: `line-height: 50%` means 50% of the element's
1643        // own font-size, NOT 50% of `ctx.parent_size` like `%` means for
1644        // most other properties (width, padding, etc).
1645        let s = style_with("100", r#""50%""#, r#""50%""#);
1646        let ctx = LengthContext {
1647            parent_size: 1000.0, // deliberately different from font_size,
1648            // to prove `%` here does NOT fall through to the generic
1649            // percent-of-parent resolution.
1650            ..Default::default()
1651        };
1652        assert_eq!(s.line_height_for_ctx(100.0, &ctx), 50.0);
1653    }
1654
1655    #[test]
1656    fn line_height_number_ignores_context_like_before() {
1657        let s = style_with("100", "0", "1.5");
1658        assert_eq!(
1659            s.line_height_for_ctx(100.0, &LengthContext::default()),
1660            150.0
1661        );
1662    }
1663
1664    #[test]
1665    fn typography_px_ctx_resolves_all_three_with_correct_em_bases() {
1666        // font-size: 1.5em against a 200px parent font-size -> 300px own
1667        // font-size. letter-spacing/line-height's em must then use that
1668        // 300px *own* size, not the 200px parent size passed in via ctx.
1669        // line-height as a bare JSON number (unitless, `LineHeight::Number`)
1670        // — a quoted `"0.85"` would instead deserialize as a `Length`
1671        // string, which parses a bare numeric string as *pixels*
1672        // (`ParsedLength::Px`), not as the unitless multiplier CSS means;
1673        // that's an existing quirk of `LineHeight`'s untagged variants,
1674        // unrelated to this fix.
1675        let s = style_with(r#""1.5em""#, r#""-0.03em""#, "0.85");
1676        let ctx = LengthContext {
1677            font_size: 200.0, // parent's font-size, for font-size's own em
1678            ..Default::default()
1679        };
1680        let (font_size, letter_spacing, line_height) = s.typography_px_ctx(&ctx, 48.0);
1681        assert_eq!(font_size, 300.0);
1682        assert!(
1683            (letter_spacing - (300.0 * -0.03)).abs() < 1e-3,
1684            "letter-spacing em must resolve against the OWN 300px font-size, got {letter_spacing}"
1685        );
1686        assert_eq!(line_height, 300.0 * 0.85);
1687    }
1688
1689    // ---- constat #1: border-radius per-corner kebab-case (RED first) ----
1690
1691    #[test]
1692    fn border_radius_corners_accepts_kebab_case() {
1693        // This is the shape every sibling composite in this file uses
1694        // (box-shadow -> offset-x/offset-y, transform-origin -> x/y, etc.)
1695        // and the shape `rules/component-field-placement.md` teaches. Before
1696        // the fix, `BorderRadius::Corners`'s fields are literally
1697        // `top_left`/`top_right`/... with no kebab alias, so this kebab
1698        // object fails to match `Corners` (unknown fields) and, being all
1699        // `#[serde(default)]`, matches it anyway with every corner at 0 —
1700        // the untagged enum never reports an error, it just silently
1701        // produces radius 0.
1702        let json = r#"{ "border-radius": { "top-left": "12px", "top-right": "12px", "bottom-right": "4px", "bottom-left": "4px" } }"#;
1703        let s: CssStyle = serde_json::from_str(json).unwrap();
1704        match s.border_radius {
1705            Some(BorderRadius::Corners {
1706                top_left,
1707                top_right,
1708                bottom_right,
1709                bottom_left,
1710            }) => {
1711                assert_eq!(top_left.px(), 12.0, "top-left must be honoured, not 0");
1712                assert_eq!(top_right.px(), 12.0);
1713                assert_eq!(bottom_right.px(), 4.0);
1714                assert_eq!(bottom_left.px(), 4.0);
1715            }
1716            other => panic!("expected Corners, got {other:?}"),
1717        }
1718    }
1719
1720    #[test]
1721    fn border_radius_corners_still_accepts_legacy_snake_case() {
1722        // Back-compat: any scenario already written with the old
1723        // snake_case field names must keep working identically.
1724        let json = r#"{ "border-radius": { "top_left": "8px", "top_right": "8px", "bottom_right": "8px", "bottom_left": "8px" } }"#;
1725        let s: CssStyle = serde_json::from_str(json).unwrap();
1726        assert_eq!(s.border_radius_px(), Some(8.0));
1727    }
1728
1729    #[test]
1730    fn border_radius_corners_typo_is_a_named_error_not_a_silent_zero() {
1731        // A misspelled key must not silently resolve to Corners{0,0,0,0} —
1732        // it must be reported.
1733        let json = r#"{ "border-radius": { "topleft": "12px" } }"#;
1734        let err = serde_json::from_str::<CssStyle>(json).expect_err("typo must be rejected");
1735        let msg = err.to_string();
1736        assert!(
1737            msg.contains("topleft")
1738                || msg.contains("border-radius")
1739                || msg.contains("BorderRadius"),
1740            "error must name the offending input, got: {msg}"
1741        );
1742    }
1743
1744    // ---- constat #2: `Edges` (padding/margin) rejects unknown shapes (RED first) ----
1745
1746    #[test]
1747    fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() {
1748        // `rules/margin-left-hack.md`-adjacent trap: an LLM reasoning in CSS
1749        // terms writes `{"horizontal": 20}` instead of the supported
1750        // `{"top":.., "right":.., "bottom":.., "left":..}` shape. Before the
1751        // fix, `Edges::Sides`'s four fields are all `#[serde(default)]` with
1752        // no `deny_unknown_fields`, so this object matches `Sides` anyway
1753        // with every side at 0 — silent, wrong padding instead of an error.
1754        let json = r#"{ "padding": { "horizontal": 20 } }"#;
1755        let err = serde_json::from_str::<CssStyle>(json)
1756            .expect_err("an unrecognised padding shape must be rejected, not silently zeroed");
1757        let msg = err.to_string();
1758        assert!(
1759            msg.contains("horizontal") || msg.contains("padding") || msg.contains("Edges"),
1760            "error must name the offending input, got: {msg}"
1761        );
1762    }
1763
1764    #[test]
1765    fn edges_still_accepts_valid_per_side_object() {
1766        let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
1767        let s: CssStyle = serde_json::from_str(json).unwrap();
1768        assert_eq!(s.padding_px(), (10.0, 20.0, 10.0, 20.0));
1769    }
1770
1771    #[test]
1772    fn edges_still_accepts_uniform_scalar() {
1773        let json = r#"{ "padding": "24px" }"#;
1774        let s: CssStyle = serde_json::from_str(json).unwrap();
1775        assert_eq!(s.padding_px(), (24.0, 24.0, 24.0, 24.0));
1776    }
1777
1778    // ---- constat #6: `Size` untagged variant order (RED first) ----
1779
1780    #[test]
1781    fn size_keyword_max_content_is_reachable() {
1782        // `Size` is `#[serde(untagged)]`: Auto, Length, Keyword in that
1783        // declared order (before the fix). `Length(LengthPercentage)`'s
1784        // `String` fallback variant accepts *any* string, so it is tried
1785        // (and succeeds) before `Keyword` is ever reached — `max-content` /
1786        // `min-content` / `fit-content` are dead schema. After the fix,
1787        // `Keyword` must be tried before the `Length` catch-all.
1788        for (kw, expected) in [
1789            ("max-content", SizeKeyword::MaxContent),
1790            ("min-content", SizeKeyword::MinContent),
1791            ("fit-content", SizeKeyword::FitContent),
1792        ] {
1793            let json = format!(r#"{{ "width": "{kw}" }}"#);
1794            let s: CssStyle = serde_json::from_str(&json).unwrap();
1795            assert_eq!(
1796                s.width,
1797                Some(Size::Keyword(expected)),
1798                "width: \"{kw}\" must resolve to Size::Keyword, not Size::Length(String(..))"
1799            );
1800        }
1801    }
1802
1803    #[test]
1804    fn size_length_and_auto_are_unaffected_by_the_reorder() {
1805        let s: CssStyle = serde_json::from_str(r#"{ "width": "200px" }"#).unwrap();
1806        assert!(matches!(s.width, Some(Size::Length(_))));
1807        let s: CssStyle = serde_json::from_str(r#"{ "width": "50%" }"#).unwrap();
1808        assert!(matches!(s.width, Some(Size::Length(_))));
1809        let s: CssStyle = serde_json::from_str(r#"{ "width": "auto" }"#).unwrap();
1810        assert!(matches!(s.width, Some(Size::Auto(_))));
1811        let s: CssStyle = serde_json::from_str(r#"{ "width": 200 }"#).unwrap();
1812        assert!(matches!(s.width, Some(Size::Length(_))));
1813    }
1814
1815    // ---- extra: `LineHeight` has the same catch-all-before-specific shape
1816    // as constat #6's `Size`, found while auditing this file for the same
1817    // bug class. Fixed alongside it (see the doc comment on `LineHeight`).
1818
1819    #[test]
1820    fn line_height_keyword_normal_is_reachable() {
1821        let s: CssStyle = serde_json::from_str(r#"{ "line-height": "normal" }"#).unwrap();
1822        assert_eq!(
1823            s.line_height,
1824            Some(LineHeight::Keyword(LineHeightKw::Normal)),
1825            "line-height: \"normal\" must resolve to Keyword, not Length(String(\"normal\"))"
1826        );
1827    }
1828
1829    #[test]
1830    fn line_height_number_and_length_are_unaffected_by_the_reorder() {
1831        let s: CssStyle = serde_json::from_str(r#"{ "line-height": 1.5 }"#).unwrap();
1832        assert!(matches!(s.line_height, Some(LineHeight::Number(_))));
1833        let s: CssStyle = serde_json::from_str(r#"{ "line-height": "24px" }"#).unwrap();
1834        assert!(matches!(s.line_height, Some(LineHeight::Length(_))));
1835    }
1836
1837    // ---- border-radius: kebab-case is the canonical wire form on output ----
1838
1839    #[test]
1840    fn border_radius_corners_serializes_as_kebab_case() {
1841        let s = CssStyle {
1842            border_radius: Some(BorderRadius::Corners {
1843                top_left: LengthPercentage::Px(1.0),
1844                top_right: LengthPercentage::Px(2.0),
1845                bottom_right: LengthPercentage::Px(3.0),
1846                bottom_left: LengthPercentage::Px(4.0),
1847            }),
1848            ..Default::default()
1849        };
1850        let json = serde_json::to_value(&s).unwrap();
1851        let br = &json["border-radius"];
1852        assert_eq!(br["top-left"], serde_json::json!(1.0));
1853        assert_eq!(br["top-right"], serde_json::json!(2.0));
1854        assert_eq!(br["bottom-right"], serde_json::json!(3.0));
1855        assert_eq!(br["bottom-left"], serde_json::json!(4.0));
1856        assert!(
1857            br.get("top_left").is_none(),
1858            "must not emit the legacy snake_case key any more"
1859        );
1860    }
1861}