1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use super::units::{Length, LengthContext, LengthPercentage, ParsedLength};
11use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow};
15
16pub const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012;
36
37pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0;
60
61#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
64#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
65pub struct CssStyle {
66 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 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 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 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 pub text_autofit: Option<bool>,
173
174 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 pub gradient_border: Option<GradientBorder>,
186
187 pub backdrop_blur: Option<f32>,
190 pub inner_shadow: Option<InnerShadow>,
192
193 pub filter: Option<Vec<FilterFn>>,
195 pub backdrop_filter: Option<Vec<FilterFn>>,
196
197 pub transform: Option<Vec<TransformFn>>,
199 pub transform_origin: Option<TransformOrigin>,
200 pub perspective: Option<Length>,
201 pub perspective_origin: Option<TransformOrigin>,
202
203 pub depth: Option<f32>,
210
211 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 #[serde(default, deserialize_with = "deserialize_animation_effects")]
220 pub animation: Vec<AnimationEffect>,
221 pub transition: Option<StyleTransition>,
232
233 #[serde(default)]
235 pub audio_reactive: Option<AudioReactive>,
236}
237
238#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
259#[serde(deny_unknown_fields)]
260pub struct AudioReactive {
261 #[serde(default)]
263 pub track: Option<String>,
264 pub source: AudioSource,
266 pub property: AudioReactiveProperty,
268 pub min: f64,
270 pub max: f64,
272 #[serde(default)]
274 pub smoothing_frames: u32,
275}
276
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
279#[serde(untagged)]
280pub enum AudioSource {
281 Amplitude(AudioSourceTag),
283 Band { band: u8 },
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
289#[serde(rename_all = "snake_case")]
290pub enum AudioSourceTag {
291 Amplitude,
292}
293
294#[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
320impl CssStyle {
327 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 pub fn font_size_px(&self) -> Option<f32> {
334 self.font_size.as_ref().map(|l| l.px())
335 }
336
337 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 pub fn color_str_or<'a>(&'a self, default: &'a str) -> &'a str {
347 self.color_str().unwrap_or(default)
348 }
349
350 pub fn font_family_str(&self) -> Option<&str> {
352 self.font_family.as_deref()
353 }
354
355 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 pub fn letter_spacing_px(&self) -> f32 {
362 self.letter_spacing.as_ref().map(|l| l.px()).unwrap_or(0.0)
363 }
364
365 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 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 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 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 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 pub fn opacity_or(&self, default: f32) -> f32 {
494 self.opacity.unwrap_or(default)
495 }
496
497 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 pub fn border_radius_px_or(&self, default: f32) -> f32 {
508 self.border_radius_px().unwrap_or(default)
509 }
510
511 pub fn padding_px(&self) -> (f32, f32, f32, f32) {
513 edges_px(self.padding.as_ref())
514 }
515
516 pub fn margin_px(&self) -> (f32, f32, f32, f32) {
518 edges_px(self.margin.as_ref())
519 }
520
521 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#[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#[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#[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#[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 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#[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 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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
856#[serde(untagged)]
857pub enum GridTrack {
858 Fr(f32),
861 Keyword(GridTrackKeyword),
863 Length(LengthPercentage),
867 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#[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#[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#[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 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#[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 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#[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 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#[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#[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 Noise {
1357 #[serde(default = "default_noise_intensity")]
1359 intensity: f32,
1360 #[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#[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#[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#[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 #[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 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 assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 15.6 / 100.0 * 1920.0);
1599 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 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 let s = style_with("300", r#""-0.03em""#, "1");
1629 let own_ctx = LengthContext {
1630 font_size: 300.0, ..Default::default()
1632 };
1633 assert!((s.letter_spacing_px_ctx(&own_ctx) - (-9.0)).abs() < 1e-4);
1634 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 let s = style_with("100", r#""50%""#, r#""50%""#);
1646 let ctx = LengthContext {
1647 parent_size: 1000.0, ..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 let s = style_with(r#""1.5em""#, r#""-0.03em""#, "0.85");
1676 let ctx = LengthContext {
1677 font_size: 200.0, ..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 #[test]
1692 fn border_radius_corners_accepts_kebab_case() {
1693 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 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 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 #[test]
1747 fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() {
1748 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 #[test]
1781 fn size_keyword_max_content_is_reachable() {
1782 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 #[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 #[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}