The stylesheet that plays the page's motion: one @keyframes and one class per Motion,
sampled from Motion::at, so the page moves exactly as the preview video does.
4use std::fmt::Write;
Every motion the page's scenes use, in Motion::all()'s order. The app has poses the page
never shows (juggling, typing), and their keyframes would only weigh the sheet down.
14fn used_motions() -> Vec<Motion> { 15 fn collect(nodes: &[Node], into: &mut Vec<Motion>) { 16 for node in nodes { 17 match node { 18 Node::Group(Group { motion, children, .. }) => { 19 into.push(*motion); 20 collect(children, into); 21 } 22 Node::Slot(over) => collect(over, into), 23 Node::Rect(_) => {} 24 } 25 } 26 } 27 let mut used = Vec::new(); 28 for character in Character::all() { 29 for mood in Mood::ALL { 30 collect(&scene(character, mood).nodes, &mut used); 31 } 32 } 33 Motion::all().into_iter().filter(|m| used.contains(m)).collect() 34}
36pub fn stylesheet() -> String { 37 let mut css = String::new(); 38 for motion in used_motions() { 39 keyframes(&mut css, motion); 40 } 41 // Reduced motion stops every body motion, and the groups then stand at the pose their `transform` attribute holds. That 42 // attribute already moves its own origin, so the inline `transform-origin` is cancelled or the origin is applied twice 43 // (a sleeper turned onto its side lands off to the edge). The mouth's flap and the blink are not body motion: they are 44 // what a voice looks like and what makes the face alive, so they keep playing. 45 css.push_str( 46 "@media (prefers-reduced-motion:reduce){.cat [class^=\"m-\"]:not([class^=\"m-flap\"]):not([class^=\"m-blink\"]),.cat [class*=\" m-\"]:not([class*=\" m-flap\"]):not([class*=\" m-blink\"]){animation:none;transform-origin:0 0!important}}\n", 47 ); 48 css 49} 50 51fn keyframes(css: &mut String, motion: Motion) { 52 let track = motion.played(); 53 let (period, frames) = (track.period, &track.frames); 54 let moves = frames.iter().any(|(_, xf)| xf.css() != Motion::Still.at(0.0).css()); 55 let fades = frames.iter().any(|(_, xf)| xf.opacity != 1.0); 56 let name = motion.name(); 57 let _ = write!(css, "@keyframes k-{name}{{"); 58 for (percent, xf) in frames { 59 let _ = write!(css, "{}%{{", num_pct(*percent)); 60 if moves { 61 let _ = write!(css, "transform:{};", xf.css()); 62 } 63 if fades { 64 let _ = write!(css, "opacity:{};", num(xf.opacity)); 65 } 66 css.push('}'); 67 } 68 let timing = if track.steps { "step-end" } else { "linear" }; 69 let _ = writeln!(css, "}}\n.cat .m-{name}{{animation:k-{name} {}s {timing} infinite}}", num(period)); 70} 71 72fn num_pct(percent: f32) -> String { 73 let s = format!("{percent:.3}"); 74 s.trim_end_matches('0').trim_end_matches('.').to_owned() 75} 76 77#[cfg(test)] 78mod tests { 79 use super::*; 80 use crate::mood::Mood; 81 use crate::node::{Group, Node}; 82 83 fn motions(nodes: &[Node], into: &mut Vec<Motion>) { 84 for node in nodes { 85 match node { 86 Node::Group(Group { motion, children, .. }) => { 87 into.push(*motion); 88 motions(children, into); 89 } 90 Node::Slot(over) => motions(over, into), 91 Node::Rect(_) => {} 92 } 93 } 94 } 95 96 #[test] 97 fn every_motion_a_scene_uses_has_a_class_and_keyframes() { 98 let css = stylesheet(); 99 let mut used = Vec::new(); 100 for character in Character::all() { 101 for mood in Mood::ALL { 102 motions(&scene(character, mood).nodes, &mut used); 103 } 104 } 105 for motion in used.into_iter().filter(|m| *m != Motion::Still) { 106 let name = motion.name(); 107 assert!(css.contains(&format!(".cat .m-{name}{{")), "{name}"); 108 assert!(css.contains(&format!("@keyframes k-{name}{{")), "{name}"); 109 } 110 } 111 112 #[test] 113 fn the_sheet_is_small_and_honours_reduced_motion() { 114 let css = stylesheet(); 115 assert!(css.len() < 50_000, "{} bytes", css.len()); 116 assert!(css.contains("prefers-reduced-motion:reduce")); 117 } 118 119 #[test] 120 fn reduced_motion_cancels_the_origin_the_attribute_already_carries_and_keeps_the_mouth_and_blink_playing() { 121 let css = stylesheet(); 122 let rule = css.split("@media (prefers-reduced-motion:reduce){").nth(1).unwrap().lines().next().unwrap(); 123 assert!(rule.contains("animation:none;transform-origin:0 0!important"), "{rule}"); 124 assert!(rule.contains(":not([class^=\"m-flap\"])") && rule.contains(":not([class^=\"m-blink\"])"), "{rule}"); 125 } 126 127 #[test] 128 fn every_character_moves_with_the_same_body_so_the_sheet_has_one_keyframe_set_for_it() { 129 let css = stylesheet(); 130 for name in ["body-resting", "body-listening", "body-thinking", "body-dizzy", "body-asleep", "body-asleep-flat", "speak-beat", "flap-wide", "flap-closed"] { 131 assert_eq!(css.matches(&format!("@keyframes k-{name}{{")).count(), 1, "{name}"); 132 } 133 // The spin goes the whole way round and the flap shows a shape in steps. 134 assert!(css.contains("rotate(360deg)") || css.contains("rotate(359."), "a spin"); 135 assert!(css.contains(".cat .m-flap-wide{animation:k-flap-wide 0.242s step-end infinite}"), "{}", css.split("m-flap-wide{").nth(1).unwrap_or("").lines().next().unwrap_or("")); 136 } 137 138 #[test] 139 fn a_blink_is_a_step_that_holds_and_the_rising_z_jumps_back_in_an_instant() { 140 let css = stylesheet(); 141 assert!(css.contains(".cat .m-blink{animation:k-blink 4s step-end infinite}")); 142 let rise = css.split("@keyframes k-rise-0{").nth(1).unwrap().lines().next().unwrap(); 143 // The second to last keyframe is the end of the cycle, a thousandth of a second short. 144 assert!(rise.contains("99.965%"), "{rise}"); 145 } 146}