1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::animation::EasingType;
5use super::scenario::default_transition_easing;
6use super::video::GradientType;
7
8#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(rename_all = "snake_case")]
11pub enum ScrollDirection {
12 Up,
13 Down,
14 Left,
15 Right,
16 UpLeft,
17 UpRight,
18 DownLeft,
19 DownRight,
20 Cw,
22 Ccw,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct GradientShiftConfig {
29 pub colors: Vec<String>,
30 #[serde(default = "default_bg_type")]
31 pub gradient_type: GradientType,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct GridDotsConfig {
37 #[serde(default = "default_grid_dots_color")]
38 pub color: String,
39 #[serde(default = "default_bg_element_size")]
40 pub element_size: f32,
41 #[serde(default = "default_bg_spacing")]
42 pub spacing: f32,
43}
44
45fn default_grid_dots_color() -> String {
46 "#FFFFFF15".to_string()
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct GridLinesConfig {
57 #[serde(default = "default_grid_lines_color")]
59 pub color: String,
60 #[serde(default = "default_grid_lines_cell")]
62 pub cell: f32,
63 #[serde(default = "default_grid_lines_weight")]
65 pub weight: f32,
66 #[serde(default)]
70 pub major_every: u32,
71 #[serde(default = "default_grid_lines_major_weight")]
73 pub major_weight: f32,
74}
75
76fn default_grid_lines_color() -> String {
77 "#FFFFFF14".to_string()
78}
79
80fn default_grid_lines_cell() -> f32 {
81 72.0
82}
83
84fn default_grid_lines_weight() -> f32 {
85 1.0
86}
87
88fn default_grid_lines_major_weight() -> f32 {
89 2.0
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
94pub struct ConcentricCirclesConfig {
95 #[serde(default = "default_concentric_color")]
96 pub color: String,
97 #[serde(default = "default_bg_element_size")]
98 pub element_size: f32,
99 #[serde(default = "default_bg_spacing")]
100 pub spacing: f32,
101 #[serde(default)]
102 pub count: Option<u32>,
103}
104
105fn default_concentric_color() -> String {
106 "#FFFFFF20".to_string()
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct HaloConfig {
112 pub zones: Vec<HaloZone>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
122pub struct PixelGridConfig {
123 #[serde(default = "default_pixel_colors")]
126 pub colors: Vec<String>,
127 #[serde(default = "default_pixel_size")]
129 pub size: f32,
130 #[serde(default = "default_pixel_spacing")]
133 pub spacing: f32,
134 #[serde(default = "default_pixel_density")]
138 pub density: f32,
139 #[serde(default)]
143 pub density_ramp: PixelDensityRamp,
144 #[serde(default)]
146 pub radius: f32,
147 #[serde(default = "default_pixel_seed")]
150 pub seed: u32,
151 #[serde(default)]
153 pub motion: PixelGridMotion,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159pub enum PixelDensityRamp {
160 #[default]
162 None,
163 Left,
164 Right,
165 Top,
166 Bottom,
167 Radial,
169 Edges,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum PixelGridMotion {
183 #[default]
185 None,
186 Twinkle,
188 Sweep,
190}
191
192fn default_pixel_colors() -> Vec<String> {
193 vec!["#FFFFFF22".to_string()]
194}
195
196fn default_pixel_size() -> f32 {
197 10.0
198}
199
200fn default_pixel_spacing() -> f32 {
201 24.0
202}
203
204fn default_pixel_density() -> f32 {
205 0.6
206}
207
208fn default_pixel_seed() -> u32 {
209 7
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
214pub struct HeropatternConfig {
215 pub pattern: String,
217 #[serde(default = "default_hero_color")]
218 pub color: String,
219 #[serde(default = "default_hero_opacity")]
220 pub opacity: f32,
221 #[serde(default = "default_hero_scale")]
222 pub scale: f32,
223}
224
225fn default_hero_color() -> String {
226 "#FFFFFF".to_string()
227}
228
229fn default_hero_opacity() -> f32 {
230 0.1
231}
232
233fn default_hero_scale() -> f32 {
234 1.0
235}
236
237#[derive(Debug, Clone)]
239pub enum BackgroundPreset {
240 GradientShift(GradientShiftConfig),
241 GridDots(GridDotsConfig),
242 GridLines(GridLinesConfig),
243 ConcentricCircles(ConcentricCirclesConfig),
244 Halo(HaloConfig),
245 PixelGrid(PixelGridConfig),
246 Heropattern(HeropatternConfig),
247}
248
249impl BackgroundPreset {
250 pub fn name(&self) -> &'static str {
251 match self {
252 BackgroundPreset::GradientShift(_) => "gradient_shift",
253 BackgroundPreset::GridDots(_) => "grid_dots",
254 BackgroundPreset::GridLines(_) => "grid_lines",
255 BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
256 BackgroundPreset::Halo(_) => "halo",
257 BackgroundPreset::PixelGrid(_) => "pixel_grid",
258 BackgroundPreset::Heropattern(_) => "heropattern",
259 }
260 }
261}
262
263#[derive(Debug, Clone)]
265pub struct AnimatedBackground {
266 pub preset: BackgroundPreset,
267 pub x: f32,
269 pub y: f32,
271 pub speed: f32,
273 pub direction: Option<ScrollDirection>,
275}
276
277impl Serialize for AnimatedBackground {
278 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
279 use serde::ser::SerializeMap;
280 let mut map = serializer.serialize_map(None)?;
281 map.serialize_entry("preset", self.preset.name())?;
282 match &self.preset {
284 BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?,
285 BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?,
286 BackgroundPreset::GridLines(cfg) => map.serialize_entry("grid_lines", cfg)?,
287 BackgroundPreset::ConcentricCircles(cfg) => {
288 map.serialize_entry("concentric_circles", cfg)?
289 }
290 BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
291 BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
292 BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
293 }
294 map.serialize_entry("speed", &self.speed)?;
295 if self.x != 0.0 {
296 map.serialize_entry("x", &self.x)?;
297 }
298 if self.y != 0.0 {
299 map.serialize_entry("y", &self.y)?;
300 }
301 if let Some(ref dir) = self.direction {
302 map.serialize_entry("direction", dir)?;
303 }
304 map.end()
305 }
306}
307
308const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
313 "gradient_shift",
314 "grid_dots",
315 "grid_lines",
316 "concentric_circles",
317 "halo",
318 "pixel_grid",
319 "heropattern",
320];
321
322impl<'de> Deserialize<'de> for AnimatedBackground {
323 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
324 let map: serde_json::Map<String, serde_json::Value> =
325 serde_json::Map::deserialize(deserializer)?;
326
327 let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
329 let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
330 let direction: Option<ScrollDirection> = match map.get("direction") {
334 Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| {
335 serde::de::Error::custom(format!("animated-background.direction: {e}"))
336 })?),
337 None => None,
338 };
339
340 let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or("");
341 if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) {
342 return Err(serde::de::Error::custom(format!(
343 "unknown animated-background preset '{preset_str}': expected one of {}",
344 KNOWN_BACKGROUND_PRESETS.join(", ")
345 )));
346 }
347
348 let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object());
350
351 let (preset, speed) = if is_new_format {
352 let sub = map.get(preset_str).unwrap().clone();
354 let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
355 let preset = deserialize_preset_config::<D::Error>(preset_str, sub)?;
356 (preset, speed)
357 } else {
358 let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32;
360 let preset = match preset_str {
361 "grid_dots" => {
362 let color = map
363 .get("colors")
364 .and_then(|v| v.as_array())
365 .and_then(|a| a.first())
366 .and_then(|v| v.as_str())
367 .unwrap_or("#FFFFFF15")
368 .to_string();
369 let element_size = map
370 .get("element_size")
371 .and_then(|v| v.as_f64())
372 .unwrap_or(4.0) as f32;
373 let spacing =
374 map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
375 BackgroundPreset::GridDots(GridDotsConfig {
376 color,
377 element_size,
378 spacing,
379 })
380 }
381 "concentric_circles" => {
382 let color = map
383 .get("colors")
384 .and_then(|v| v.as_array())
385 .and_then(|a| a.first())
386 .and_then(|v| v.as_str())
387 .unwrap_or("#FFFFFF20")
388 .to_string();
389 let element_size = map
390 .get("element_size")
391 .and_then(|v| v.as_f64())
392 .unwrap_or(4.0) as f32;
393 let spacing =
394 map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
395 let count = map.get("count").and_then(|v| v.as_u64()).map(|n| n as u32);
396 BackgroundPreset::ConcentricCircles(ConcentricCirclesConfig {
397 color,
398 element_size,
399 spacing,
400 count,
401 })
402 }
403 "halo" => {
404 let mut obj = serde_json::Map::new();
413 if let Some(z) = map.get("zones") {
414 obj.insert("zones".to_string(), z.clone());
415 }
416 let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj))
417 .map_err(|e| {
418 serde::de::Error::custom(format!("animated-background.zones: {e}"))
419 })?;
420 BackgroundPreset::Halo(cfg)
421 }
422 "heropattern" => {
423 let mut obj = serde_json::Map::new();
430 for key in ["pattern", "color", "opacity", "scale"] {
431 if let Some(v) = map.get(key) {
432 obj.insert(key.to_string(), v.clone());
433 }
434 }
435 let cfg: HeropatternConfig =
436 serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
437 serde::de::Error::custom(format!(
438 "animated-background.heropattern: {e}"
439 ))
440 })?;
441 BackgroundPreset::Heropattern(cfg)
442 }
443 "gradient_shift" => {
444 let mut obj = serde_json::Map::new();
452 if let Some(c) = map.get("colors") {
453 obj.insert("colors".to_string(), c.clone());
454 }
455 if let Some(g) = map.get("gradient_type") {
456 obj.insert("gradient_type".to_string(), g.clone());
457 }
458 let cfg: GradientShiftConfig =
459 serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
460 serde::de::Error::custom(format!(
461 "animated-background.colors/gradient_type: {e}"
462 ))
463 })?;
464 BackgroundPreset::GradientShift(cfg)
465 }
466 other => {
469 return Err(serde::de::Error::custom(format!(
470 "internal error: unhandled animated-background preset '{other}'"
471 )))
472 }
473 };
474 (preset, legacy_speed)
475 };
476
477 let direction = direction.or({
479 if speed > 0.0 && !is_new_format {
480 match &preset {
481 BackgroundPreset::GradientShift(_) => Some(ScrollDirection::Cw),
482 BackgroundPreset::GridDots(_) => Some(ScrollDirection::Up),
483 _ => None,
484 }
485 } else {
486 None
487 }
488 });
489
490 Ok(AnimatedBackground {
491 preset,
492 x,
493 y,
494 speed,
495 direction,
496 })
497 }
498}
499
500fn deserialize_preset_config<E: serde::de::Error>(
505 preset_str: &str,
506 sub: serde_json::Value,
507) -> Result<BackgroundPreset, E> {
508 match preset_str {
509 "grid_dots" => Ok(BackgroundPreset::GridDots(
510 serde_json::from_value(sub).map_err(E::custom)?,
511 )),
512 "grid_lines" => Ok(BackgroundPreset::GridLines(
513 serde_json::from_value(sub).map_err(E::custom)?,
514 )),
515 "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles(
516 serde_json::from_value(sub).map_err(E::custom)?,
517 )),
518 "halo" => Ok(BackgroundPreset::Halo(
519 serde_json::from_value(sub).map_err(E::custom)?,
520 )),
521 "pixel_grid" => Ok(BackgroundPreset::PixelGrid(
522 serde_json::from_value(sub).map_err(E::custom)?,
523 )),
524 "heropattern" => Ok(BackgroundPreset::Heropattern(
525 serde_json::from_value(sub).map_err(E::custom)?,
526 )),
527 "gradient_shift" => Ok(BackgroundPreset::GradientShift(
528 serde_json::from_value(sub).map_err(E::custom)?,
529 )),
530 other => Err(E::custom(format!(
531 "internal error: unhandled animated-background preset '{other}'"
532 ))),
533 }
534}
535
536impl JsonSchema for AnimatedBackground {
537 fn schema_name() -> String {
538 "AnimatedBackground".to_string()
539 }
540
541 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
542 use schemars::schema::*;
543
544 let mut props = schemars::Map::new();
545 props.insert("preset".to_string(), gen.subschema_for::<String>());
546 props.insert("x".to_string(), gen.subschema_for::<f32>());
547 props.insert("y".to_string(), gen.subschema_for::<f32>());
548 props.insert("speed".to_string(), gen.subschema_for::<f32>());
549 props.insert(
550 "direction".to_string(),
551 gen.subschema_for::<Option<ScrollDirection>>(),
552 );
553 props.insert(
554 "gradient_shift".to_string(),
555 gen.subschema_for::<Option<GradientShiftConfig>>(),
556 );
557 props.insert(
558 "grid_dots".to_string(),
559 gen.subschema_for::<Option<GridDotsConfig>>(),
560 );
561 props.insert(
562 "grid_lines".to_string(),
563 gen.subschema_for::<Option<GridLinesConfig>>(),
564 );
565 props.insert(
566 "concentric_circles".to_string(),
567 gen.subschema_for::<Option<ConcentricCirclesConfig>>(),
568 );
569 props.insert(
570 "halo".to_string(),
571 gen.subschema_for::<Option<HaloConfig>>(),
572 );
573 props.insert(
574 "heropattern".to_string(),
575 gen.subschema_for::<Option<HeropatternConfig>>(),
576 );
577
578 SchemaObject {
579 instance_type: Some(InstanceType::Object.into()),
580 object: Some(Box::new(ObjectValidation {
581 properties: props,
582 required: ["preset".to_string()].into_iter().collect(),
583 ..Default::default()
584 })),
585 ..Default::default()
586 }
587 .into()
588 }
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
593pub struct HaloZone {
594 pub color: String,
597 #[serde(default = "default_half")]
601 pub x: f32,
602 #[serde(default = "default_half")]
604 pub y: f32,
605 #[serde(default = "default_halo_radius")]
608 pub radius: f32,
609 #[serde(default = "default_halo_opacity")]
617 pub opacity: f32,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
622pub struct BackgroundTransition {
623 pub duration: f64,
624 #[serde(default = "default_transition_easing")]
625 pub easing: EasingType,
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize)]
630pub struct BackgroundEntry {
631 #[serde(rename = "$ref", default)]
632 pub template_ref: Option<String>,
633 #[serde(default)]
634 pub transition: Option<BackgroundTransition>,
635 #[serde(flatten)]
636 pub overrides: serde_json::Map<String, serde_json::Value>,
637}
638
639impl JsonSchema for BackgroundEntry {
652 fn schema_name() -> String {
653 "BackgroundEntry".to_string()
654 }
655
656 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
657 use schemars::schema::*;
658
659 let mut props = schemars::Map::new();
660 props.insert("$ref".to_string(), gen.subschema_for::<Option<String>>());
661 props.insert(
662 "transition".to_string(),
663 gen.subschema_for::<Option<BackgroundTransition>>(),
664 );
665
666 SchemaObject {
667 instance_type: Some(InstanceType::Object.into()),
668 object: Some(Box::new(ObjectValidation {
669 properties: props,
670 additional_properties: Some(Box::new(Schema::Bool(true))),
674 ..Default::default()
675 })),
676 ..Default::default()
677 }
678 .into()
679 }
680}
681
682#[derive(Debug, Clone)]
684pub enum BackgroundValue {
685 Color(String),
686 Single(BackgroundEntry),
687 Multiple(Vec<BackgroundEntry>),
688}
689
690impl Serialize for BackgroundValue {
691 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
692 where
693 S: serde::Serializer,
694 {
695 match self {
696 BackgroundValue::Color(s) => serializer.serialize_str(s),
697 BackgroundValue::Single(entry) => entry.serialize(serializer),
698 BackgroundValue::Multiple(entries) => entries.serialize(serializer),
699 }
700 }
701}
702
703impl JsonSchema for BackgroundValue {
707 fn schema_name() -> String {
708 "BackgroundValue".to_string()
709 }
710
711 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
712 use schemars::schema::*;
713
714 let string_schema = gen.subschema_for::<String>();
715 let entry_schema = gen.subschema_for::<BackgroundEntry>();
716 let array_schema: Schema = SchemaObject {
717 instance_type: Some(InstanceType::Array.into()),
718 array: Some(Box::new(ArrayValidation {
719 items: Some(SingleOrVec::Single(Box::new(entry_schema.clone()))),
720 ..Default::default()
721 })),
722 ..Default::default()
723 }
724 .into();
725
726 SchemaObject {
727 subschemas: Some(Box::new(SubschemaValidation {
728 one_of: Some(vec![string_schema, entry_schema, array_schema]),
729 ..Default::default()
730 })),
731 ..Default::default()
732 }
733 .into()
734 }
735}
736
737#[derive(Debug, Clone, Default, Serialize)]
739pub struct ResolvedBackground {
740 pub color: Option<String>,
741 pub animated: Vec<AnimatedBackground>,
742 pub transition: Option<BackgroundTransition>,
744}
745
746fn default_half() -> f32 {
747 0.5
748}
749
750fn default_halo_radius() -> f32 {
751 0.4
752}
753
754fn default_halo_opacity() -> f32 {
755 1.0
756}
757
758fn default_bg_element_size() -> f32 {
759 4.0
760}
761
762fn default_bg_spacing() -> f32 {
763 60.0
764}
765
766#[allow(dead_code)]
767fn default_bg_speed() -> f32 {
768 30.0
769}
770
771fn default_bg_type() -> GradientType {
772 GradientType::Linear
773}
774
775pub(crate) fn deserialize_animated_backgrounds<'de, D>(
777 deserializer: D,
778) -> Result<Vec<AnimatedBackground>, D::Error>
779where
780 D: serde::Deserializer<'de>,
781{
782 use serde::de;
783
784 struct OneOrMany;
785
786 impl<'de> de::Visitor<'de> for OneOrMany {
787 type Value = Vec<AnimatedBackground>;
788
789 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
790 f.write_str("a single animated background or an array of animated backgrounds")
791 }
792
793 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
794 where
795 A: de::SeqAccess<'de>,
796 {
797 Vec::deserialize(de::value::SeqAccessDeserializer::new(seq))
798 }
799
800 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
801 where
802 M: de::MapAccess<'de>,
803 {
804 let bg = AnimatedBackground::deserialize(de::value::MapAccessDeserializer::new(map))?;
805 Ok(vec![bg])
806 }
807 }
808
809 deserializer.deserialize_any(OneOrMany)
810}
811
812pub(crate) fn deserialize_background_value<'de, D>(
814 deserializer: D,
815) -> Result<Option<BackgroundValue>, D::Error>
816where
817 D: serde::Deserializer<'de>,
818{
819 use serde::de;
820
821 struct BgVisitor;
822
823 impl<'de> de::Visitor<'de> for BgVisitor {
824 type Value = Option<BackgroundValue>;
825
826 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
827 f.write_str("a color string, a background object, or an array of background objects")
828 }
829
830 fn visit_none<E>(self) -> Result<Self::Value, E>
831 where
832 E: de::Error,
833 {
834 Ok(None)
835 }
836
837 fn visit_unit<E>(self) -> Result<Self::Value, E>
838 where
839 E: de::Error,
840 {
841 Ok(None)
842 }
843
844 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
845 where
846 E: de::Error,
847 {
848 Ok(Some(BackgroundValue::Color(v.to_string())))
849 }
850
851 fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
852 where
853 E: de::Error,
854 {
855 Ok(Some(BackgroundValue::Color(v)))
856 }
857
858 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
859 where
860 M: de::MapAccess<'de>,
861 {
862 let entry = BackgroundEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
863 Ok(Some(BackgroundValue::Single(entry)))
864 }
865
866 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
867 where
868 A: de::SeqAccess<'de>,
869 {
870 let entries =
871 Vec::<BackgroundEntry>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
872 Ok(Some(BackgroundValue::Multiple(entries)))
873 }
874 }
875
876 deserializer.deserialize_any(BgVisitor)
877}
878
879#[cfg(test)]
880mod halo_zone_opacity_tests {
881 use super::*;
882
883 #[test]
884 fn halo_zone_opacity_defaults_to_1_when_omitted() {
885 let zone: HaloZone =
886 serde_json::from_value(serde_json::json!({ "color": "#1E3A8A55" })).unwrap();
887 assert_eq!(zone.opacity, 1.0);
888 }
889
890 #[test]
891 fn halo_zone_opacity_respects_explicit_value() {
892 let zone: HaloZone = serde_json::from_value(serde_json::json!({
893 "color": "#1E3A8A",
894 "opacity": 0.35
895 }))
896 .unwrap();
897 assert_eq!(zone.opacity, 0.35);
898 }
899
900 #[test]
901 fn halo_zone_serializes_opacity() {
902 let zone = HaloZone {
903 color: "#1E3A8A".to_string(),
904 x: 0.5,
905 y: 0.5,
906 radius: 0.4,
907 opacity: 0.6,
908 };
909 let v = serde_json::to_value(&zone).unwrap();
910 let got = v["opacity"]
914 .as_f64()
915 .expect("opacity must serialize as a number");
916 assert!((got - 0.6).abs() < 1e-6, "got {got}");
917 }
918
919 #[test]
920 fn animated_background_new_format_halo_zone_defaults_opacity() {
921 let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
923 "preset": "halo",
924 "halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] },
925 "speed": 0
926 }))
927 .unwrap();
928 match bg.preset {
929 BackgroundPreset::Halo(cfg) => {
930 assert_eq!(cfg.zones.len(), 1);
931 assert_eq!(cfg.zones[0].opacity, 1.0);
932 assert_eq!(cfg.zones[0].color, "#1E3A8A55");
934 }
935 _ => panic!("expected Halo preset"),
936 }
937 }
938
939 #[test]
940 fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() {
941 let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
943 "preset": "halo",
944 "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }]
945 }))
946 .unwrap();
947 match bg.preset {
948 BackgroundPreset::Halo(cfg) => {
949 assert_eq!(cfg.zones[0].opacity, 1.0);
950 }
951 _ => panic!("expected Halo preset"),
952 }
953 }
954}
955
956#[cfg(test)]
971mod animated_background_silent_sink_tests {
972 use super::*;
973 use serde_json::json;
974
975 #[test]
976 fn known_preset_gradient_shift_still_works() {
977 let bg: AnimatedBackground = serde_json::from_value(json!({
978 "preset": "gradient_shift",
979 "colors": ["#111111", "#222222"],
980 "gradient_type": "radial",
981 "speed": 10
982 }))
983 .unwrap();
984 match bg.preset {
985 BackgroundPreset::GradientShift(cfg) => {
986 assert_eq!(cfg.colors, vec!["#111111", "#222222"]);
987 assert!(matches!(cfg.gradient_type, GradientType::Radial));
988 }
989 other => panic!("expected GradientShift, got {other:?}"),
990 }
991 }
992
993 #[test]
994 fn unknown_preset_name_is_a_named_error_not_a_silent_black_gradient() {
995 let err = serde_json::from_value::<AnimatedBackground>(json!({
996 "preset": "starfield",
997 "speed": 10
998 }))
999 .expect_err("an unknown preset must be rejected, not silently treated as gradient_shift");
1000 let msg = err.to_string();
1001 assert!(
1002 msg.contains("starfield"),
1003 "error must name the offending preset value, got: {msg}"
1004 );
1005 }
1006
1007 #[test]
1008 fn missing_preset_key_is_a_named_error() {
1009 let err = serde_json::from_value::<AnimatedBackground>(json!({ "speed": 10 }))
1010 .expect_err("a missing `preset` must be rejected, not silently treated as gradient_shift with colors: []");
1011 assert!(
1012 err.to_string().to_lowercase().contains("preset"),
1013 "got: {err}"
1014 );
1015 }
1016
1017 #[test]
1018 fn legacy_halo_zones_still_work() {
1019 let bg: AnimatedBackground = serde_json::from_value(json!({
1020 "preset": "halo",
1021 "zones": [{ "color": "#1E3A8A", "x": 0.1, "y": 0.2, "radius": 0.3 }]
1022 }))
1023 .unwrap();
1024 match bg.preset {
1025 BackgroundPreset::Halo(cfg) => assert_eq!(cfg.zones.len(), 1),
1026 other => panic!("expected Halo, got {other:?}"),
1027 }
1028 }
1029
1030 #[test]
1031 fn legacy_halo_malformed_zones_is_a_named_error_not_a_silent_empty_zones() {
1032 let err = serde_json::from_value::<AnimatedBackground>(json!({
1033 "preset": "halo",
1034 "zones": [{ "color": "#1E3A8A", "x": "not-a-number" }]
1035 }))
1036 .expect_err("a malformed zones entry must be rejected, not silently emptied");
1037 assert!(
1038 err.to_string().contains("zones") || err.to_string().contains("x"),
1039 "error should point at the offending field, got: {err}"
1040 );
1041 }
1042
1043 #[test]
1044 fn legacy_halo_missing_zones_is_a_named_error_not_a_silent_empty_zones() {
1045 let err = serde_json::from_value::<AnimatedBackground>(json!({ "preset": "halo" }))
1046 .expect_err("missing zones must be rejected, not silently treated as an empty halo");
1047 assert!(err.to_string().contains("zones"), "got: {err}");
1048 }
1049
1050 #[test]
1051 fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() {
1052 let err = serde_json::from_value::<AnimatedBackground>(json!({
1057 "preset": "gradient_shift",
1058 "speed": 5
1059 }))
1060 .expect_err("missing colors must error, not silently produce an empty (black) gradient");
1061 assert!(err.to_string().contains("colors"), "got: {err}");
1062 }
1063
1064 #[test]
1065 fn legacy_heropattern_is_recognised_not_silently_turned_into_gradient_shift() {
1066 let bg: AnimatedBackground = serde_json::from_value(json!({
1067 "preset": "heropattern",
1068 "pattern": "plus",
1069 "color": "#ffffff",
1070 "opacity": 0.2,
1071 "scale": 1.5
1072 }))
1073 .unwrap();
1074 match bg.preset {
1075 BackgroundPreset::Heropattern(cfg) => {
1076 assert_eq!(cfg.pattern, "plus");
1077 assert_eq!(cfg.scale, 1.5);
1078 }
1079 other => panic!("expected Heropattern, got {other:?}"),
1080 }
1081 }
1082
1083 #[test]
1084 fn legacy_heropattern_missing_pattern_is_a_named_error() {
1085 let err = serde_json::from_value::<AnimatedBackground>(json!({
1086 "preset": "heropattern"
1087 }))
1088 .expect_err("heropattern with no pattern name must error");
1089 assert!(err.to_string().contains("pattern"), "got: {err}");
1090 }
1091
1092 #[test]
1093 fn direction_typo_is_a_named_error_not_a_silently_dropped_none() {
1094 let err = serde_json::from_value::<AnimatedBackground>(json!({
1095 "preset": "grid_dots",
1096 "colors": ["#fff"],
1097 "direction": "diagonal"
1098 }))
1099 .expect_err("an unrecognised direction must be rejected, not silently dropped to None");
1100 assert!(err.to_string().contains("direction"), "got: {err}");
1101 }
1102
1103 #[test]
1104 fn direction_still_works_when_valid() {
1105 let bg: AnimatedBackground = serde_json::from_value(json!({
1106 "preset": "grid_dots",
1107 "colors": ["#fff"],
1108 "direction": "up"
1109 }))
1110 .unwrap();
1111 assert!(matches!(bg.direction, Some(ScrollDirection::Up)));
1112 }
1113}