whiskers.git / web / cat / src / stage.rs
stage.rsannotatedstage.rssource135 lines · 5.4 KB · raw

The demo's stage: a black window the cat stands in, so what is drawn round it (white speech and thought bubbles, pale z's, stars, hearts, sweat) can be seen. The colour is here, once; the page's stylesheet takes it from here (a test holds them together), and the tests read the scenes against it.

6use crate::node::Color;

The stage's floor and backdrop.

9pub const STAGE: Color = Color::hex(0x000000);

Graphical objects must stand out by at least 3 to 1 (WCAG 1.4.11).

12pub const MIN_CONTRAST: f64 = 3.0;
14fn linear(channel: u8) -> f64 {
15    let c = f64::from(channel) / 255.0;
16    if c <= 0.039_28 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
17}

WCAG relative luminance.

20pub fn luminance(Color(r, g, b): Color) -> f64 {
21    0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
22}

color laid over under at alpha, as the screen mixes it (in sRGB, as a browser does).

25pub fn over(color: Color, alpha: f32, under: Color) -> Color {
26    let mix = |c: u8, u: u8| (f64::from(c) * f64::from(alpha) + f64::from(u) * (1.0 - f64::from(alpha))).round() as u8;
27    Color(mix(color.0, under.0), mix(color.1, under.1), mix(color.2, under.2))
28}

WCAG contrast ratio, 1 to 21.

31pub fn contrast(a: Color, b: Color) -> f64 {
32    let (la, lb) = (luminance(a), luminance(b));
33    (la.max(lb) + 0.05) / (la.min(lb) + 0.05)
34}
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use crate::node::{Group, Node, Rect};
40    use crate::{Mascot, Mood, scene};

Every rectangle of a scene in paint order (a group's children after the group's turn).

43    fn rects(nodes: &[Node], out: &mut Vec<Rect>) {
44        for node in nodes {
45            match node {
46                Node::Rect(r) => out.push(*r),
47                Node::Group(Group { children, .. }) => rects(children, out),
48            }
49        }
50    }
52    fn painted(mascot: Mascot, mood: Mood) -> Vec<Rect> {
53        let mut out = Vec::new();
54        rects(&scene(mascot, mood).nodes, &mut out);
55        out
56    }
57
58    fn on_stage(r: &Rect) -> Color {
59        over(r.color, r.alpha, STAGE)
60    }
61
62    fn inside(outer: &Rect, inner: &Rect) -> bool {
63        let (cx, cy) = (inner.x + inner.w / 2.0, inner.y + inner.h / 2.0);
64        cx >= outer.x && cx <= outer.x + outer.w && cy >= outer.y && cy <= outer.y + outer.h
65    }
66
67    #[test]
68    fn the_ratio_is_the_wcag_one() {
69        assert!((contrast(Color::hex(0xFFFFFF), Color::hex(0x000000)) - 21.0).abs() < 1e-9);
70        assert!((contrast(Color::hex(0x777777), Color::hex(0x777777)) - 1.0).abs() < 1e-9);
71        assert_eq!(over(Color::hex(0xFFFFFF), 0.5, STAGE), Color(128, 128, 128));
72    }
73
74    #[test]
75    fn the_stage_is_black() {
76        assert_eq!(STAGE.css(), "#000000");
77    }
78
79    // The figure is drawn light on the stage, and so are the things round it. A dark mark (a
80    // pupil, a mouth, a cloud's dots) is fine when it lies on a light shape painted before it.
81    // The one dark mark on the bare stage is the ground shadow, which the black swallows: it
82    // is the first thing painted, it is half-transparent black, and nothing depends on seeing it.
83    #[test]
84    fn nothing_is_dark_on_the_dark_stage_in_any_mood_of_any_mascot() {
85        for &mascot in Mascot::ALL {
86            for mood in Mood::ALL {
87                let all = painted(mascot, mood);
88                for (i, r) in all.iter().enumerate() {
89                    if contrast(on_stage(r), STAGE) >= MIN_CONTRAST {
90                        continue;
91                    }
92                    let shadow = i == 0 && r.color == Color::hex(0x000000) && r.alpha < 1.0;
93                    let on_light = all[..i].iter().any(|under| contrast(on_stage(under), STAGE) >= MIN_CONTRAST && under.alpha >= 0.85 && inside(under, r));
94                    assert!(shadow || on_light, "{mascot:?} {mood:?}: {r:?} is dark and lies on the bare stage");
95                }
96            }
97        }
98    }
99
100    #[test]
101    fn every_colour_drawn_round_the_figure_stands_out_from_the_stage() {
102        // The weather is what `decor::around` and `zs` add: a colour a mood draws that the
103        // same mascot's idle scene does not.
104        let mut weather = Vec::new();
105        for &mascot in Mascot::ALL {
106            let idle: Vec<Color> = painted(mascot, Mood::Idle).iter().map(|r| r.color).collect();
107            for mood in Mood::ALL {
108                for r in painted(mascot, mood) {
109                    if !idle.contains(&r.color) {
110                        weather.push((mascot, mood, r));
111                    }
112                }
113            }
114        }
115        assert!(!weather.is_empty());
116        for (mascot, mood, r) in weather {
117            assert!(contrast(on_stage(&r), STAGE) >= MIN_CONTRAST, "{mascot:?} {mood:?}: {r:?} is {:.2} to 1", contrast(on_stage(&r), STAGE));
118        }
119    }
120
121    #[test]
122    fn each_named_effect_colour_stands_out_from_the_stage() {
123        // The effects the mood row names, by colour, each against the stage.
124        for (name, color, alpha) in [
125            ("a speech wave and a thought cloud", Color::hex(0xFFFFFF), 0.9_f32),
126            ("the z's", Color::hex(0xD6E6FF), 1.0),
127            ("the stars", Color::hex(0xFFD84A), 1.0),
128            ("a heart", Color::hex(0xFF8FA3), 1.0),
129            ("a drop of sweat", Color::hex(0x6EC1FF), 1.0),
130            ("the offline cross", Color::hex(0xFF5A5A), 1.0),
131        ] {
132            assert!(contrast(over(color, alpha, STAGE), STAGE) >= MIN_CONTRAST, "{name}");
133        }
134    }
135}