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)]
238pub enum BackgroundPreset {
239 GradientShift(GradientShiftConfig),
240 GridDots(GridDotsConfig),
241 GridLines(GridLinesConfig),
242 ConcentricCircles(ConcentricCirclesConfig),
243 Halo(HaloConfig),
244 PixelGrid(PixelGridConfig),
245 Heropattern(HeropatternConfig),
246}
247
248impl BackgroundPreset {
249 pub fn name(&self) -> &'static str {
250 match self {
251 BackgroundPreset::GradientShift(_) => "gradient_shift",
252 BackgroundPreset::GridDots(_) => "grid_dots",
253 BackgroundPreset::GridLines(_) => "grid_lines",
254 BackgroundPreset::ConcentricCircles(_) => "concentric_circles",
255 BackgroundPreset::Halo(_) => "halo",
256 BackgroundPreset::PixelGrid(_) => "pixel_grid",
257 BackgroundPreset::Heropattern(_) => "heropattern",
258 }
259 }
260}
261
262#[derive(Debug, Clone)]
263pub struct AnimatedBackground {
264 pub preset: BackgroundPreset,
265 pub x: f32,
266 pub y: f32,
267 pub speed: f32,
268 pub direction: Option<ScrollDirection>,
269}
270
271impl Serialize for AnimatedBackground {
272 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
273 use serde::ser::SerializeMap;
274 let mut map = serializer.serialize_map(None)?;
275 map.serialize_entry("preset", self.preset.name())?;
276 match &self.preset {
277 BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?,
278 BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?,
279 BackgroundPreset::GridLines(cfg) => map.serialize_entry("grid_lines", cfg)?,
280 BackgroundPreset::ConcentricCircles(cfg) => {
281 map.serialize_entry("concentric_circles", cfg)?
282 }
283 BackgroundPreset::Halo(cfg) => map.serialize_entry("halo", cfg)?,
284 BackgroundPreset::PixelGrid(cfg) => map.serialize_entry("pixel_grid", cfg)?,
285 BackgroundPreset::Heropattern(cfg) => map.serialize_entry("heropattern", cfg)?,
286 }
287 map.serialize_entry("speed", &self.speed)?;
288 if self.x != 0.0 {
289 map.serialize_entry("x", &self.x)?;
290 }
291 if self.y != 0.0 {
292 map.serialize_entry("y", &self.y)?;
293 }
294 if let Some(ref dir) = self.direction {
295 map.serialize_entry("direction", dir)?;
296 }
297 map.end()
298 }
299}
300
301const KNOWN_BACKGROUND_PRESETS: &[&str] = &[
302 "gradient_shift",
303 "grid_dots",
304 "grid_lines",
305 "concentric_circles",
306 "halo",
307 "pixel_grid",
308 "heropattern",
309];
310
311impl<'de> Deserialize<'de> for AnimatedBackground {
312 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
313 let map: serde_json::Map<String, serde_json::Value> =
314 serde_json::Map::deserialize(deserializer)?;
315
316 let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
317 let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
318 let direction: Option<ScrollDirection> = match map.get("direction") {
319 Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| {
320 serde::de::Error::custom(format!("animated-background.direction: {e}"))
321 })?),
322 None => None,
323 };
324
325 let preset_str = map.get("preset").and_then(|v| v.as_str()).unwrap_or("");
326 if !KNOWN_BACKGROUND_PRESETS.contains(&preset_str) {
327 return Err(serde::de::Error::custom(format!(
328 "unknown animated-background preset '{preset_str}': expected one of {}",
329 KNOWN_BACKGROUND_PRESETS.join(", ")
330 )));
331 }
332
333 let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object());
334
335 let (preset, speed) = if is_new_format {
336 let sub = map.get(preset_str).unwrap().clone();
337 let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
338 let preset = deserialize_preset_config::<D::Error>(preset_str, sub)?;
339 (preset, speed)
340 } else {
341 let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32;
342 let preset = match preset_str {
343 "grid_dots" => {
344 let color = map
345 .get("colors")
346 .and_then(|v| v.as_array())
347 .and_then(|a| a.first())
348 .and_then(|v| v.as_str())
349 .unwrap_or("#FFFFFF15")
350 .to_string();
351 let element_size = map
352 .get("element_size")
353 .and_then(|v| v.as_f64())
354 .unwrap_or(4.0) as f32;
355 let spacing =
356 map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
357 BackgroundPreset::GridDots(GridDotsConfig {
358 color,
359 element_size,
360 spacing,
361 })
362 }
363 "concentric_circles" => {
364 let color = map
365 .get("colors")
366 .and_then(|v| v.as_array())
367 .and_then(|a| a.first())
368 .and_then(|v| v.as_str())
369 .unwrap_or("#FFFFFF20")
370 .to_string();
371 let element_size = map
372 .get("element_size")
373 .and_then(|v| v.as_f64())
374 .unwrap_or(4.0) as f32;
375 let spacing =
376 map.get("spacing").and_then(|v| v.as_f64()).unwrap_or(60.0) as f32;
377 let count = map.get("count").and_then(|v| v.as_u64()).map(|n| n as u32);
378 BackgroundPreset::ConcentricCircles(ConcentricCirclesConfig {
379 color,
380 element_size,
381 spacing,
382 count,
383 })
384 }
385 "halo" => {
386 let mut obj = serde_json::Map::new();
387 if let Some(z) = map.get("zones") {
388 obj.insert("zones".to_string(), z.clone());
389 }
390 let cfg: HaloConfig = serde_json::from_value(serde_json::Value::Object(obj))
391 .map_err(|e| {
392 serde::de::Error::custom(format!("animated-background.zones: {e}"))
393 })?;
394 BackgroundPreset::Halo(cfg)
395 }
396 "heropattern" => {
397 let mut obj = serde_json::Map::new();
398 for key in ["pattern", "color", "opacity", "scale"] {
399 if let Some(v) = map.get(key) {
400 obj.insert(key.to_string(), v.clone());
401 }
402 }
403 let cfg: HeropatternConfig =
404 serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
405 serde::de::Error::custom(format!(
406 "animated-background.heropattern: {e}"
407 ))
408 })?;
409 BackgroundPreset::Heropattern(cfg)
410 }
411 "gradient_shift" => {
412 let mut obj = serde_json::Map::new();
413 if let Some(c) = map.get("colors") {
414 obj.insert("colors".to_string(), c.clone());
415 }
416 if let Some(g) = map.get("gradient_type") {
417 obj.insert("gradient_type".to_string(), g.clone());
418 }
419 let cfg: GradientShiftConfig =
420 serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
421 serde::de::Error::custom(format!(
422 "animated-background.colors/gradient_type: {e}"
423 ))
424 })?;
425 BackgroundPreset::GradientShift(cfg)
426 }
427 other => {
428 return Err(serde::de::Error::custom(format!(
429 "internal error: unhandled animated-background preset '{other}'"
430 )))
431 }
432 };
433 (preset, legacy_speed)
434 };
435
436 let direction = direction.or({
437 if speed > 0.0 && !is_new_format {
438 match &preset {
439 BackgroundPreset::GradientShift(_) => Some(ScrollDirection::Cw),
440 BackgroundPreset::GridDots(_) => Some(ScrollDirection::Up),
441 _ => None,
442 }
443 } else {
444 None
445 }
446 });
447
448 Ok(AnimatedBackground {
449 preset,
450 x,
451 y,
452 speed,
453 direction,
454 })
455 }
456}
457
458fn deserialize_preset_config<E: serde::de::Error>(
459 preset_str: &str,
460 sub: serde_json::Value,
461) -> Result<BackgroundPreset, E> {
462 match preset_str {
463 "grid_dots" => Ok(BackgroundPreset::GridDots(
464 serde_json::from_value(sub).map_err(E::custom)?,
465 )),
466 "grid_lines" => Ok(BackgroundPreset::GridLines(
467 serde_json::from_value(sub).map_err(E::custom)?,
468 )),
469 "concentric_circles" => Ok(BackgroundPreset::ConcentricCircles(
470 serde_json::from_value(sub).map_err(E::custom)?,
471 )),
472 "halo" => Ok(BackgroundPreset::Halo(
473 serde_json::from_value(sub).map_err(E::custom)?,
474 )),
475 "pixel_grid" => Ok(BackgroundPreset::PixelGrid(
476 serde_json::from_value(sub).map_err(E::custom)?,
477 )),
478 "heropattern" => Ok(BackgroundPreset::Heropattern(
479 serde_json::from_value(sub).map_err(E::custom)?,
480 )),
481 "gradient_shift" => Ok(BackgroundPreset::GradientShift(
482 serde_json::from_value(sub).map_err(E::custom)?,
483 )),
484 other => Err(E::custom(format!(
485 "internal error: unhandled animated-background preset '{other}'"
486 ))),
487 }
488}
489
490impl JsonSchema for AnimatedBackground {
491 fn schema_name() -> String {
492 "AnimatedBackground".to_string()
493 }
494
495 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
496 use schemars::schema::*;
497
498 let mut props = schemars::Map::new();
499 props.insert("preset".to_string(), gen.subschema_for::<String>());
500 props.insert("x".to_string(), gen.subschema_for::<f32>());
501 props.insert("y".to_string(), gen.subschema_for::<f32>());
502 props.insert("speed".to_string(), gen.subschema_for::<f32>());
503 props.insert(
504 "direction".to_string(),
505 gen.subschema_for::<Option<ScrollDirection>>(),
506 );
507 props.insert(
508 "gradient_shift".to_string(),
509 gen.subschema_for::<Option<GradientShiftConfig>>(),
510 );
511 props.insert(
512 "grid_dots".to_string(),
513 gen.subschema_for::<Option<GridDotsConfig>>(),
514 );
515 props.insert(
516 "grid_lines".to_string(),
517 gen.subschema_for::<Option<GridLinesConfig>>(),
518 );
519 props.insert(
520 "concentric_circles".to_string(),
521 gen.subschema_for::<Option<ConcentricCirclesConfig>>(),
522 );
523 props.insert(
524 "halo".to_string(),
525 gen.subschema_for::<Option<HaloConfig>>(),
526 );
527 props.insert(
528 "heropattern".to_string(),
529 gen.subschema_for::<Option<HeropatternConfig>>(),
530 );
531
532 SchemaObject {
533 instance_type: Some(InstanceType::Object.into()),
534 object: Some(Box::new(ObjectValidation {
535 properties: props,
536 required: ["preset".to_string()].into_iter().collect(),
537 ..Default::default()
538 })),
539 ..Default::default()
540 }
541 .into()
542 }
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
547pub struct HaloZone {
548 pub color: String,
551 #[serde(default = "default_half")]
555 pub x: f32,
556 #[serde(default = "default_half")]
558 pub y: f32,
559 #[serde(default = "default_halo_radius")]
565 pub radius: f32,
566 #[serde(default)]
572 pub radius_x: Option<f32>,
573 #[serde(default)]
577 pub radius_y: Option<f32>,
578 #[serde(default)]
583 pub rotation: f32,
584 #[serde(default = "default_halo_opacity")]
592 pub opacity: f32,
593}
594
595impl HaloZone {
596 pub fn effective_radius_x(&self) -> f32 {
597 self.radius_x.unwrap_or(self.radius)
598 }
599
600 pub fn effective_radius_y(&self) -> f32 {
601 self.radius_y.unwrap_or(self.radius)
602 }
603
604 pub fn is_circular(&self) -> bool {
605 self.effective_radius_x() == self.effective_radius_y()
606 }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
611pub struct BackgroundTransition {
612 pub duration: f64,
613 #[serde(default = "default_transition_easing")]
614 pub easing: EasingType,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct BackgroundEntry {
619 #[serde(rename = "$ref", default)]
620 pub template_ref: Option<String>,
621 #[serde(default)]
622 pub transition: Option<BackgroundTransition>,
623 #[serde(flatten)]
624 pub overrides: serde_json::Map<String, serde_json::Value>,
625}
626
627impl JsonSchema for BackgroundEntry {
628 fn schema_name() -> String {
629 "BackgroundEntry".to_string()
630 }
631
632 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
633 use schemars::schema::*;
634
635 let mut props = schemars::Map::new();
636 props.insert("$ref".to_string(), gen.subschema_for::<Option<String>>());
637 props.insert(
638 "transition".to_string(),
639 gen.subschema_for::<Option<BackgroundTransition>>(),
640 );
641
642 SchemaObject {
643 instance_type: Some(InstanceType::Object.into()),
644 object: Some(Box::new(ObjectValidation {
645 properties: props,
646 additional_properties: Some(Box::new(Schema::Bool(true))),
647 ..Default::default()
648 })),
649 ..Default::default()
650 }
651 .into()
652 }
653}
654
655#[derive(Debug, Clone)]
656pub enum BackgroundValue {
657 Color(String),
658 Single(BackgroundEntry),
659 Multiple(Vec<BackgroundEntry>),
660}
661
662impl Serialize for BackgroundValue {
663 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
664 where
665 S: serde::Serializer,
666 {
667 match self {
668 BackgroundValue::Color(s) => serializer.serialize_str(s),
669 BackgroundValue::Single(entry) => entry.serialize(serializer),
670 BackgroundValue::Multiple(entries) => entries.serialize(serializer),
671 }
672 }
673}
674
675impl JsonSchema for BackgroundValue {
676 fn schema_name() -> String {
677 "BackgroundValue".to_string()
678 }
679
680 fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
681 use schemars::schema::*;
682
683 let string_schema = gen.subschema_for::<String>();
684 let entry_schema = gen.subschema_for::<BackgroundEntry>();
685 let array_schema: Schema = SchemaObject {
686 instance_type: Some(InstanceType::Array.into()),
687 array: Some(Box::new(ArrayValidation {
688 items: Some(SingleOrVec::Single(Box::new(entry_schema.clone()))),
689 ..Default::default()
690 })),
691 ..Default::default()
692 }
693 .into();
694
695 SchemaObject {
696 subschemas: Some(Box::new(SubschemaValidation {
697 one_of: Some(vec![string_schema, entry_schema, array_schema]),
698 ..Default::default()
699 })),
700 ..Default::default()
701 }
702 .into()
703 }
704}
705
706#[derive(Debug, Clone, Default, Serialize)]
707pub struct ResolvedBackground {
708 pub color: Option<String>,
709 pub animated: Vec<AnimatedBackground>,
710 pub transition: Option<BackgroundTransition>,
711}
712
713fn default_half() -> f32 {
714 0.5
715}
716
717fn default_halo_radius() -> f32 {
718 0.4
719}
720
721fn default_halo_opacity() -> f32 {
722 1.0
723}
724
725fn default_bg_element_size() -> f32 {
726 4.0
727}
728
729fn default_bg_spacing() -> f32 {
730 60.0
731}
732
733#[allow(dead_code)]
734fn default_bg_speed() -> f32 {
735 30.0
736}
737
738fn default_bg_type() -> GradientType {
739 GradientType::Linear
740}
741
742pub(crate) fn deserialize_animated_backgrounds<'de, D>(
743 deserializer: D,
744) -> Result<Vec<AnimatedBackground>, D::Error>
745where
746 D: serde::Deserializer<'de>,
747{
748 use serde::de;
749
750 struct OneOrMany;
751
752 impl<'de> de::Visitor<'de> for OneOrMany {
753 type Value = Vec<AnimatedBackground>;
754
755 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
756 f.write_str("a single animated background or an array of animated backgrounds")
757 }
758
759 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
760 where
761 A: de::SeqAccess<'de>,
762 {
763 Vec::deserialize(de::value::SeqAccessDeserializer::new(seq))
764 }
765
766 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
767 where
768 M: de::MapAccess<'de>,
769 {
770 let bg = AnimatedBackground::deserialize(de::value::MapAccessDeserializer::new(map))?;
771 Ok(vec![bg])
772 }
773 }
774
775 deserializer.deserialize_any(OneOrMany)
776}
777
778pub(crate) fn deserialize_background_value<'de, D>(
779 deserializer: D,
780) -> Result<Option<BackgroundValue>, D::Error>
781where
782 D: serde::Deserializer<'de>,
783{
784 use serde::de;
785
786 struct BgVisitor;
787
788 impl<'de> de::Visitor<'de> for BgVisitor {
789 type Value = Option<BackgroundValue>;
790
791 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
792 f.write_str("a color string, a background object, or an array of background objects")
793 }
794
795 fn visit_none<E>(self) -> Result<Self::Value, E>
796 where
797 E: de::Error,
798 {
799 Ok(None)
800 }
801
802 fn visit_unit<E>(self) -> Result<Self::Value, E>
803 where
804 E: de::Error,
805 {
806 Ok(None)
807 }
808
809 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
810 where
811 E: de::Error,
812 {
813 Ok(Some(BackgroundValue::Color(v.to_string())))
814 }
815
816 fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
817 where
818 E: de::Error,
819 {
820 Ok(Some(BackgroundValue::Color(v)))
821 }
822
823 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
824 where
825 M: de::MapAccess<'de>,
826 {
827 let entry = BackgroundEntry::deserialize(de::value::MapAccessDeserializer::new(map))?;
828 Ok(Some(BackgroundValue::Single(entry)))
829 }
830
831 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
832 where
833 A: de::SeqAccess<'de>,
834 {
835 let entries =
836 Vec::<BackgroundEntry>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
837 Ok(Some(BackgroundValue::Multiple(entries)))
838 }
839 }
840
841 deserializer.deserialize_any(BgVisitor)
842}
843
844#[cfg(test)]
845mod halo_zone_opacity_tests {
846 use super::*;
847
848 #[test]
849 fn halo_zone_opacity_defaults_to_1_when_omitted() {
850 let zone: HaloZone =
851 serde_json::from_value(serde_json::json!({ "color": "#1E3A8A55" })).unwrap();
852 assert_eq!(zone.opacity, 1.0);
853 }
854
855 #[test]
856 fn halo_zone_opacity_respects_explicit_value() {
857 let zone: HaloZone = serde_json::from_value(serde_json::json!({
858 "color": "#1E3A8A",
859 "opacity": 0.35
860 }))
861 .unwrap();
862 assert_eq!(zone.opacity, 0.35);
863 }
864
865 #[test]
866 fn halo_zone_serializes_opacity() {
867 let zone = HaloZone {
868 color: "#1E3A8A".to_string(),
869 x: 0.5,
870 y: 0.5,
871 radius: 0.4,
872 radius_x: None,
873 radius_y: None,
874 rotation: 0.0,
875 opacity: 0.6,
876 };
877 let v = serde_json::to_value(&zone).unwrap();
878 let got = v["opacity"]
879 .as_f64()
880 .expect("opacity must serialize as a number");
881 assert!((got - 0.6).abs() < 1e-6, "got {got}");
882 }
883
884 #[test]
885 fn animated_background_new_format_halo_zone_defaults_opacity() {
886 let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
887 "preset": "halo",
888 "halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] },
889 "speed": 0
890 }))
891 .unwrap();
892 match bg.preset {
893 BackgroundPreset::Halo(cfg) => {
894 assert_eq!(cfg.zones.len(), 1);
895 assert_eq!(cfg.zones[0].opacity, 1.0);
896 assert_eq!(cfg.zones[0].color, "#1E3A8A55");
897 }
898 _ => panic!("expected Halo preset"),
899 }
900 }
901
902 #[test]
903 fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() {
904 let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({
905 "preset": "halo",
906 "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }]
907 }))
908 .unwrap();
909 match bg.preset {
910 BackgroundPreset::Halo(cfg) => {
911 assert_eq!(cfg.zones[0].opacity, 1.0);
912 }
913 _ => panic!("expected Halo preset"),
914 }
915 }
916}
917
918#[cfg(test)]
919mod halo_zone_ellipse_tests {
920 use super::*;
921
922 fn zone_with_radius_only(radius: f32) -> HaloZone {
923 serde_json::from_value(serde_json::json!({ "color": "#FFFFFF", "radius": radius })).unwrap()
924 }
925
926 #[test]
927 fn radius_x_and_radius_y_default_to_none_when_omitted() {
928 let zone = zone_with_radius_only(0.4);
929 assert_eq!(zone.radius_x, None);
930 assert_eq!(zone.radius_y, None);
931 assert_eq!(zone.rotation, 0.0);
932 }
933
934 #[test]
935 fn effective_radius_falls_back_to_radius_when_axis_radii_are_absent() {
936 let zone = zone_with_radius_only(0.4);
937 assert_eq!(zone.effective_radius_x(), 0.4);
938 assert_eq!(zone.effective_radius_y(), 0.4);
939 }
940
941 #[test]
942 fn effective_radius_honours_explicit_axis_values() {
943 let zone: HaloZone = serde_json::from_value(serde_json::json!({
944 "color": "#FFFFFF",
945 "radius": 0.4,
946 "radius_x": 0.8,
947 "radius_y": 0.05
948 }))
949 .unwrap();
950 assert_eq!(zone.effective_radius_x(), 0.8);
951 assert_eq!(zone.effective_radius_y(), 0.05);
952 }
953
954 #[test]
955 fn a_radius_only_zone_is_circular() {
956 assert!(zone_with_radius_only(0.4).is_circular());
957 }
958
959 #[test]
960 fn explicit_equal_radius_x_and_radius_y_is_still_circular() {
961 let zone: HaloZone = serde_json::from_value(serde_json::json!({
962 "color": "#FFFFFF",
963 "radius": 0.4,
964 "radius_x": 0.4,
965 "radius_y": 0.4
966 }))
967 .unwrap();
968 assert!(zone.is_circular());
969 }
970
971 #[test]
972 fn differing_axis_radii_are_not_circular() {
973 let zone: HaloZone = serde_json::from_value(serde_json::json!({
974 "color": "#FFFFFF",
975 "radius": 0.4,
976 "radius_x": 0.8,
977 "radius_y": 0.1
978 }))
979 .unwrap();
980 assert!(!zone.is_circular());
981 }
982
983 #[test]
984 fn a_nonzero_rotation_does_not_affect_circularity() {
985 let zone: HaloZone = serde_json::from_value(serde_json::json!({
986 "color": "#FFFFFF",
987 "radius": 0.4,
988 "rotation": 45.0
989 }))
990 .unwrap();
991 assert!(
992 zone.is_circular(),
993 "rotation has no visible effect on a circle, so it must not change how it renders"
994 );
995 }
996
997 #[test]
998 fn rotation_defaults_to_zero_degrees() {
999 let zone: HaloZone = serde_json::from_value(serde_json::json!({
1000 "color": "#FFFFFF",
1001 "radius": 0.4,
1002 "radius_x": 0.8,
1003 "radius_y": 0.1
1004 }))
1005 .unwrap();
1006 assert_eq!(zone.rotation, 0.0);
1007 }
1008}
1009
1010#[cfg(test)]
1011mod animated_background_silent_sink_tests {
1012 use super::*;
1013 use serde_json::json;
1014
1015 #[test]
1016 fn known_preset_gradient_shift_still_works() {
1017 let bg: AnimatedBackground = serde_json::from_value(json!({
1018 "preset": "gradient_shift",
1019 "colors": ["#111111", "#222222"],
1020 "gradient_type": "radial",
1021 "speed": 10
1022 }))
1023 .unwrap();
1024 match bg.preset {
1025 BackgroundPreset::GradientShift(cfg) => {
1026 assert_eq!(cfg.colors, vec!["#111111", "#222222"]);
1027 assert!(matches!(cfg.gradient_type, GradientType::Radial));
1028 }
1029 other => panic!("expected GradientShift, got {other:?}"),
1030 }
1031 }
1032
1033 #[test]
1034 fn unknown_preset_name_is_a_named_error_not_a_silent_black_gradient() {
1035 let err = serde_json::from_value::<AnimatedBackground>(json!({
1036 "preset": "starfield",
1037 "speed": 10
1038 }))
1039 .expect_err("an unknown preset must be rejected, not silently treated as gradient_shift");
1040 let msg = err.to_string();
1041 assert!(
1042 msg.contains("starfield"),
1043 "error must name the offending preset value, got: {msg}"
1044 );
1045 }
1046
1047 #[test]
1048 fn missing_preset_key_is_a_named_error() {
1049 let err = serde_json::from_value::<AnimatedBackground>(json!({ "speed": 10 }))
1050 .expect_err("a missing `preset` must be rejected, not silently treated as gradient_shift with colors: []");
1051 assert!(
1052 err.to_string().to_lowercase().contains("preset"),
1053 "got: {err}"
1054 );
1055 }
1056
1057 #[test]
1058 fn legacy_halo_zones_still_work() {
1059 let bg: AnimatedBackground = serde_json::from_value(json!({
1060 "preset": "halo",
1061 "zones": [{ "color": "#1E3A8A", "x": 0.1, "y": 0.2, "radius": 0.3 }]
1062 }))
1063 .unwrap();
1064 match bg.preset {
1065 BackgroundPreset::Halo(cfg) => assert_eq!(cfg.zones.len(), 1),
1066 other => panic!("expected Halo, got {other:?}"),
1067 }
1068 }
1069
1070 #[test]
1071 fn legacy_halo_malformed_zones_is_a_named_error_not_a_silent_empty_zones() {
1072 let err = serde_json::from_value::<AnimatedBackground>(json!({
1073 "preset": "halo",
1074 "zones": [{ "color": "#1E3A8A", "x": "not-a-number" }]
1075 }))
1076 .expect_err("a malformed zones entry must be rejected, not silently emptied");
1077 assert!(
1078 err.to_string().contains("zones") || err.to_string().contains("x"),
1079 "error should point at the offending field, got: {err}"
1080 );
1081 }
1082
1083 #[test]
1084 fn legacy_halo_missing_zones_is_a_named_error_not_a_silent_empty_zones() {
1085 let err = serde_json::from_value::<AnimatedBackground>(json!({ "preset": "halo" }))
1086 .expect_err("missing zones must be rejected, not silently treated as an empty halo");
1087 assert!(err.to_string().contains("zones"), "got: {err}");
1088 }
1089
1090 #[test]
1091 fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() {
1092 let err = serde_json::from_value::<AnimatedBackground>(json!({
1093 "preset": "gradient_shift",
1094 "speed": 5
1095 }))
1096 .expect_err("missing colors must error, not silently produce an empty (black) gradient");
1097 assert!(err.to_string().contains("colors"), "got: {err}");
1098 }
1099
1100 #[test]
1101 fn legacy_heropattern_is_recognised_not_silently_turned_into_gradient_shift() {
1102 let bg: AnimatedBackground = serde_json::from_value(json!({
1103 "preset": "heropattern",
1104 "pattern": "plus",
1105 "color": "#ffffff",
1106 "opacity": 0.2,
1107 "scale": 1.5
1108 }))
1109 .unwrap();
1110 match bg.preset {
1111 BackgroundPreset::Heropattern(cfg) => {
1112 assert_eq!(cfg.pattern, "plus");
1113 assert_eq!(cfg.scale, 1.5);
1114 }
1115 other => panic!("expected Heropattern, got {other:?}"),
1116 }
1117 }
1118
1119 #[test]
1120 fn legacy_heropattern_missing_pattern_is_a_named_error() {
1121 let err = serde_json::from_value::<AnimatedBackground>(json!({
1122 "preset": "heropattern"
1123 }))
1124 .expect_err("heropattern with no pattern name must error");
1125 assert!(err.to_string().contains("pattern"), "got: {err}");
1126 }
1127
1128 #[test]
1129 fn direction_typo_is_a_named_error_not_a_silently_dropped_none() {
1130 let err = serde_json::from_value::<AnimatedBackground>(json!({
1131 "preset": "grid_dots",
1132 "colors": ["#fff"],
1133 "direction": "diagonal"
1134 }))
1135 .expect_err("an unrecognised direction must be rejected, not silently dropped to None");
1136 assert!(err.to_string().contains("direction"), "got: {err}");
1137 }
1138
1139 #[test]
1140 fn direction_still_works_when_valid() {
1141 let bg: AnimatedBackground = serde_json::from_value(json!({
1142 "preset": "grid_dots",
1143 "colors": ["#fff"],
1144 "direction": "up"
1145 }))
1146 .unwrap();
1147 assert!(matches!(bg.direction, Some(ScrollDirection::Up)));
1148 }
1149}