whiskers.git / web / cat / src / css.rs
css.rsannotatedcss.rssource146 lines · 6.0 KB · raw

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;
6use crate::character::Character;
7use crate::mood::Mood;
8use crate::motion::{Motion, num};
9use crate::node::{Group, Node};
10use crate::scene::scene;

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}