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#[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 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 pub material: Option<Material>,
145 pub gradient_border: Option<GradientBorder>,
149
150 pub backdrop_blur: Option<f32>,
152 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 pub depth: Option<f32>,
169
170 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 pub transition: Option<StyleTransition>,
199
200 #[serde(default)]
201 pub audio_reactive: Option<AudioReactive>,
202
203 #[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
353#[serde(deny_unknown_fields)]
354pub struct AudioReactive {
355 #[serde(default)]
357 pub track: Option<String>,
358 pub source: AudioSource,
360 pub property: AudioReactiveProperty,
362 pub min: f64,
364 pub max: f64,
366 #[serde(default)]
368 pub smoothing_frames: u32,
369}
370
371#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
373#[serde(untagged)]
374pub enum AudioSource {
375 Amplitude(AudioSourceTag),
377 Band { band: u8 },
379}
380
381#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
383#[serde(rename_all = "snake_case")]
384pub enum AudioSourceTag {
385 Amplitude,
386}
387
388#[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#[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#[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#[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 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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
831#[serde(untagged)]
832pub enum GridTrack {
833 Fr(f32),
836 Keyword(GridTrackKeyword),
838 Length(LengthPercentage),
842 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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1284#[serde(tag = "kind", rename_all = "kebab-case")]
1285pub enum LayoutSurface {
1286 Cylinder {
1287 radius: f32,
1289 #[serde(default)]
1291 arc_x: f32,
1292 #[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 radius: f32,
1304 #[serde(default)]
1306 arc_x: f32,
1307 #[serde(default)]
1309 arc_y: f32,
1310 #[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#[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 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 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 Noise {
1469 #[serde(default = "default_noise_intensity")]
1471 intensity: f32,
1472 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1510#[serde(untagged)]
1511pub enum Material {
1512 Preset(MaterialPreset),
1514 Tuned {
1516 #[serde(default)]
1517 preset: MaterialPreset,
1518 #[serde(default = "default_material_intensity")]
1522 intensity: f32,
1523 #[serde(default = "default_material_bevel")]
1527 bevel: f32,
1528 #[serde(default = "default_material_softness")]
1532 softness: f32,
1533 },
1534}
1535
1536#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1538#[serde(rename_all = "snake_case")]
1539pub enum MaterialPreset {
1540 #[default]
1543 Glossy,
1544 Metal,
1547 Matte,
1550 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 Blob {
1648 radius: LengthPercentage,
1649 #[serde(default)]
1650 origin: Option<TransformOrigin>,
1651 #[serde(default = "default_clip_path_blob_lobes")]
1654 lobes: u32,
1655 #[serde(default = "default_clip_path_blob_wobble")]
1658 wobble: f32,
1659 #[serde(default = "default_clip_path_blob_seed")]
1662 seed: u32,
1663 },
1664 NodePath {
1686 id: String,
1687 },
1688 Morph {
1703 from: Box<ClipPath>,
1704 to: Box<ClipPath>,
1705 #[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}