crates/veilvoice-video/src/palette.rs

what this file is for · veilvoice-video · 747 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! Colours: the site's own tokens, and one per speaker.
//!
//! # One source of colour, cross-checked by a test
//!
//! The hexes below are Tokyo Night, and they are the same ones
//! `website/css/themes.css` declares. They are written out here rather than
//! parsed at run time because a crate should not need the website to be on disk
//! to draw a circle, and a test reads that stylesheet and fails if the two ever
//! disagree, which is the same arrangement `veilvoice-gui` has had since the
//! themes existed.
//!
//! # Ten speaker colours, ordered by measurement rather than by eye
//!
//! Six of them are palette tokens. The other four are from the wider Tokyo
//! Night set, chosen to sit between the tokens.
//!
//! The **order** was first written down by looking at a hue wheel, and it was
//! wrong: a test comparing every pair found a further-apart pair than the one
//! put first. So the order is now computed rather than judged, by
//! [`distance`], the "redmean" approximation, which is the cheap standard
//! stand-in for perceptual difference and weights green most because the eye
//! does.
//!
//! Slot 0 and slot 1 are **the** furthest-apart pair in the set, because two
//! speakers is the common case. Every slot after that is the colour whose
//! nearest neighbour among the ones already used is furthest away, which is a maximin
//! order, so the table degrades gracefully: a recording with four people uses
//! four colours chosen to be as separable as four can be, rather than the first
//! four somebody listed.
//!
//! Ten colours cannot all be far apart. Under this metric the furthest pair
//! scores 507 and the closest pair anywhere in the set scores 63, and the
//! closest pair is only ever reached by a recording with nine or ten people in
//! it.
//!
//! One colour is deliberately not a hue at all: the near-white foreground
//! token, separated from every saturated colour by **lightness**, the axis
//! that is still free once the wheel is full.
//!
//! # Colour is never the only signal
//!
//! Somebody who cannot separate two of these needs the name, and the name is
//! always drawn beside the circle and always in the subtitles. A player that
//! showed only colours would be one about eight per cent of men could not use.
//!
//! # In plain words
//!
//! The colours, and which one each speaker gets.
//!
//! They are the same colours the website uses, taken from one place so the
//! application, the website and anything VeilVoice draws cannot drift apart. A
//! test compares them against the site's own stylesheet and fails the build if
//! they do.
//!
//! Speaker colours are handed out to be as distinct from each other as the number
//! of people allows, so that a glance at the picture tells you who is talking.


/// The page background.
pub const BG: &str = "#1a1b26";


/// A panel or inset behind the waveform.
pub const BG_INSET: &str = "#16161e";


/// Hairlines and dividers.
pub const BORDER: &str = "#2f3549";


/// Body text.
pub const FG: &str = "#c0caf5";


/// Secondary text.
pub const MUTED: &str = "#737aa2";



/// One complete colour scheme, matching one `[data-theme]` block in
/// `website/css/themes.css` and one entry in `veilvoice-gui`'s theme table.
///
/// The field names are the CSS custom properties one for one: `bg` is `--bg`,
/// `accent_2` is `--accent-2`. A test reads that stylesheet and fails if any of
/// them ever disagree, which is the same arrangement the desktop application
/// has had since the themes existed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
    /// Stable identifier, matching the website's `data-theme` value.
    pub id: &'static str,
    /// Human-readable name, as the pickers show it.
    pub name: &'static str,
    /// Whether this is a light scheme.
    pub light: bool,

    /// The page behind everything.
    pub bg: &'static str,
    /// A raised surface.
    pub bg_soft: &'static str,
    /// A panel or inset, behind the waveform.
    pub bg_inset: &'static str,
    /// Hairlines and dividers.
    pub border: &'static str,
    /// Body text.
    pub fg: &'static str,
    /// Secondary text.
    pub muted: &'static str,
    /// The project's primary colour.
    pub accent: &'static str,
    /// The "veiled" half of the mark.
    pub accent_2: &'static str,
    /// Values and figures.
    pub cyan: &'static str,
    /// Success.
    pub ok: &'static str,
    /// Warning.
    pub warn: &'static str,
    /// Error.
    pub err: &'static str,
}



/// Every palette, in the order the pickers show them.
///
/// Index 0 is the default, and is what an unknown identifier falls back to.
pub const PALETTES: &[Palette] = &[
    Palette {
        id: "tokyo-night",
        name: "Tokyo Night",
        light: false,
        bg: "#1a1b26",
        bg_soft: "#1f2335",
        bg_inset: "#16161e",
        border: "#2f3549",
        fg: "#c0caf5",
        muted: "#737aa2",
        accent: "#7aa2f7",
        accent_2: "#bb9af7",
        cyan: "#7dcfff",
        ok: "#9ece6a",
        warn: "#e0af68",
        err: "#f7768e",
    },

    Palette {
        id: "gruvbox",
        name: "Gruvbox",
        light: false,
        bg: "#282828",
        bg_soft: "#32302f",
        bg_inset: "#1d2021",
        border: "#504945",
        fg: "#ebdbb2",
        muted: "#928374",
        accent: "#83a598",
        accent_2: "#d3869b",
        cyan: "#8ec07c",
        ok: "#b8bb26",
        warn: "#fabd2f",
        err: "#fb4934",
    },
    Palette {
        id: "dracula",
        name: "Dracula",
        light: false,
        bg: "#282a36",
        bg_soft: "#343746",
        bg_inset: "#21222c",
        border: "#44475a",
        fg: "#f8f8f2",
        muted: "#6272a4",
        accent: "#bd93f9",
        accent_2: "#ff79c6",
        cyan: "#8be9fd",
        ok: "#50fa7b",
        warn: "#f1fa8c",
        err: "#ff5555",
    },
    Palette {
        id: "nord",
        name: "Nord",
        light: false,
        bg: "#2e3440",
        bg_soft: "#3b4252",
        bg_inset: "#272c36",
        border: "#4c566a",
        fg: "#eceff4",
        muted: "#7b88a1",
        accent: "#88c0d0",
        accent_2: "#b48ead",
        cyan: "#8fbcbb",
        ok: "#a3be8c",
        warn: "#ebcb8b",
        err: "#bf616a",
    },
    Palette {
        id: "catppuccin",
        name: "Catppuccin Mocha",
        light: false,
        bg: "#1e1e2e",
        bg_soft: "#313244",
        bg_inset: "#181825",
        border: "#45475a",
        fg: "#cdd6f4",
        muted: "#7f849c",
        accent: "#89b4fa",
        accent_2: "#cba6f7",
        cyan: "#94e2d5",
        ok: "#a6e3a1",
        warn: "#f9e2af",
        err: "#f38ba8",
    },
    Palette {
        id: "everforest",
        name: "Everforest",
        light: false,
        bg: "#2d353b",
        bg_soft: "#343f44",
        bg_inset: "#272e33",
        border: "#475258",
        fg: "#d3c6aa",
        muted: "#859289",
        accent: "#a7c080",
        accent_2: "#d699b6",
        cyan: "#83c092",
        ok: "#a7c080",
        warn: "#dbbc7f",
        err: "#e67e80",
    },
    Palette {
        id: "solarized",
        name: "Solarized Dark",
        light: false,
        bg: "#002b36",
        bg_soft: "#073642",
        bg_inset: "#00212b",
        border: "#0f4b5c",
        fg: "#93a1a1",
        muted: "#657b83",
        accent: "#268bd2",
        accent_2: "#d33682",
        cyan: "#2aa198",
        ok: "#859900",
        warn: "#b58900",
        err: "#dc322f",
    },
    Palette {
        id: "rose-pine",
        name: "Rose Pine",
        light: false,
        bg: "#191724",
        bg_soft: "#1f1d2e",
        bg_inset: "#14121f",
        border: "#33304a",
        fg: "#e0def4",
        muted: "#6e6a86",
        accent: "#9ccfd8",
        accent_2: "#c4a7e7",
        cyan: "#31748f",
        ok: "#a6da95",
        warn: "#f6c177",
        err: "#eb6f92",
    },
    Palette {
        id: "paper",
        name: "Paper (light)",
        light: true,
        bg: "#faf4ed",
        bg_soft: "#f2e9e1",
        bg_inset: "#fffaf3",
        border: "#dfd8d0",
        fg: "#575279",
        muted: "#797593",
        accent: "#286983",
        accent_2: "#907aa9",
        cyan: "#56949f",
        ok: "#618774",
        warn: "#ea9d34",
        err: "#b4637a",
    },
];


/// The palette a render uses unless one is named.
pub const DEFAULT_ID: &str = "tokyo-night";



/// The palette with this identifier.
///
/// `None` rather than a fallback: a caller who typed a theme name meant it, and
/// silently drawing in a different one answers a question they did not ask. The
/// command line turns this into an error that lists what it could have been.
pub fn by_id(id: &str) -> Option<&'static Palette> {
    PALETTES.iter().find(|palette| palette.id == id)
}



/// The default palette. Tokyo Night, and the same hexes the constants above
/// carry.
pub fn default_palette() -> &'static Palette {
    &PALETTES[0]
}



/// Every identifier, for an error message or a picker.
pub fn ids() -> Vec<&'static str> {
    PALETTES.iter().map(|palette| palette.id).collect()
}



/// The ten speaker colours, in the order slots are handed out.
///
/// See the module note for why the order is what it is, and why the tenth is
/// pale rather than another hue.
///
/// # One set, for every palette
///
/// These do **not** change with the chosen palette, and that is a decision
/// rather than an omission. A palette here has six chromatic tokens; ten
/// mutually separable colours cannot be got out of six without inventing four,
/// and four invented colours are four colours whose separation nobody has
/// measured. This set was measured: the closest pair anywhere in it scores 63
/// under [`distance`], and that pair is only ever reached by a recording with
/// nine or ten people in it.
///
/// What the palette *does* decide is everything around them -- the page, the
/// panel, the hairlines, the text -- so a render in Gruvbox is a Gruvbox
/// picture with these ten circles in it. And the ink drawn on each circle is
/// computed by [`ink_on`] rather than assumed, so the names stay readable on a
/// light palette as well as a dark one.
pub const SPEAKERS: [&str; 10] = [

    "#73daca", // 0  teal      -- furthest-apart pair with slot 1
    "#ff007c", // 1  magenta
    "#ff9e64", // 2  orange
    "#bb9af7", // 3  purple    -- the palette's accent-2
    "#9ece6a", // 4  green     -- the palette's ok
    "#7aa2f7", // 5  blue      -- the palette's accent
    "#f7768e", // 6  red-pink  -- the palette's err
    "#c0caf5", // 7  pale      -- separated by lightness, not hue
    "#7dcfff", // 8  cyan      -- the palette's cyan
    "#e0af68", // 9  yellow    -- the palette's warn
];


/// The colour for a speaker slot.
///
/// Wraps past ten, exactly as the voice table does and for the same reason: the
/// function is total so a caller cannot panic, but two speakers sharing a
/// colour is a real collision and the conversation crate refuses an eleventh
/// speaker long before this is reached.
pub fn speaker(slot: usize) -> &'static str {
    SPEAKERS[slot % SPEAKERS.len()]
}



/// How far apart two colours look, by the "redmean" approximation.
///
/// A cheap, widely used stand-in for a perceptual colour distance: it weights
/// green most, because the eye takes most of its luminance from green, and
/// shifts the red and blue weights by where the pair sits on the red axis.
///
/// Used to *order* the speaker colours rather than to make any claim about
/// what somebody can see. Nothing here decides that colour is sufficient --
/// see the note at the top of this file about the name always being drawn.
pub fn distance(a: &str, b: &str) -> f32 {
    let (Some((r1, g1, b1)), Some((r2, g2, b2))) = (rgb(a), rgb(b)) else {
        return 0.0;
    };
    let mean = (r1 as f32 + r2 as f32) / 2.0;
    let dr = r1 as f32 - r2 as f32;
    let dg = g1 as f32 - g2 as f32;
    let db = b1 as f32 - b2 as f32;
    ((2.0 + mean / 256.0) * dr * dr + 4.0 * dg * dg + (2.0 + (255.0 - mean) / 256.0) * db * db)
        .sqrt()
}



/// Parse `#rrggbb` into its three channels.
///
/// Returns `None` for anything that is not exactly that, rather than guessing:
/// a colour that half-parsed would be drawn in some arbitrary shade and look
/// like a design decision.
pub fn rgb(hex: &str) -> Option<(u8, u8, u8)> {
    let hex = hex.strip_prefix('#')?;
    if hex.len() != 6 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        return None;
    }
    Some((
        u8::from_str_radix(&hex[0..2], 16).ok()?,
        u8::from_str_radix(&hex[2..4], 16).ok()?,
        u8::from_str_radix(&hex[4..6], 16).ok()?,
    ))
}



/// Relative luminance, as WCAG defines it, from 0.0 to 1.0.
///
/// Used to pick readable text over a speaker's colour. The project already
/// computes contrast rather than trusting it. Writing that check for the
/// custom palettes found the default theme's `--muted` failing at 2.76:1, and
/// this is the same arithmetic in the same spirit.
pub fn luminance(hex: &str) -> f32 {
    let Some((r, g, b)) = rgb(hex) else {
        return 0.0;
    };
    let channel = |value: u8| {
        let v = value as f32 / 255.0;
        if v <= 0.039_28 {
            v / 12.92
        } else {
            ((v + 0.055) / 1.055).powf(2.4)
        }
    };
    0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}



/// The contrast ratio between two colours, from 1.0 to 21.0.
pub fn contrast(a: &str, b: &str) -> f32 {
    let (first, second) = (luminance(a), luminance(b));
    let (lighter, darker) = if first > second {
        (first, second)
    } else {
        (second, first)
    };
    (lighter + 0.05) / (darker + 0.05)
}



/// Black or white, whichever is readable on `background`.
pub fn ink_on(background: &str) -> &'static str {
    if contrast(background, "#000000") >= contrast(background, "#ffffff") {
        "#000000"
    } else {
        "#ffffff"
    }
}


#[cfg(test)]
mod tests {
    use super::*;

    /// The hexes here must be the ones the website declares. If a colour is
    /// changed in one place, this is what says so.
    #[test]
    fn every_token_matches_the_website_stylesheet() {
        let css = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/css/themes.css"),
        )
        .expect("themes.css should be readable from the crate directory");
        let start = css.find(":root,").expect("the default block");
        let block = &css[start..css[start..].find('}').unwrap() + start];

        for (token, expected) in [
            ("--bg:", BG),
            ("--bg-inset:", BG_INSET),
            ("--border:", BORDER),
            ("--fg:", FG),
            ("--muted:", MUTED),
            ("--accent:", SPEAKERS[5]),
            ("--ok:", SPEAKERS[4]),
            ("--err:", SPEAKERS[6]),
            ("--cyan:", SPEAKERS[8]),
            ("--warn:", SPEAKERS[9]),
            ("--accent-2:", SPEAKERS[3]),
        ] {
            let at = block
                .find(token)
                .unwrap_or_else(|| panic!("{token} is not in themes.css"));
            let rest = &block[at + token.len()..];
            let found = rest.trim_start();
            assert!(
                found.to_ascii_lowercase().starts_with(expected),
                "{token} is {} in themes.css and {expected} here",
                &found[..7.min(found.len())]
            );
        }
    }

    #[test]
    fn every_speaker_colour_is_a_valid_hex_and_they_are_all_different() {
        let mut seen = Vec::new();
        for (slot, colour) in SPEAKERS.iter().enumerate() {
            assert!(rgb(colour).is_some(), "slot {slot} is not a colour");
            assert!(!seen.contains(colour), "slot {slot} repeats {colour}");
            seen.push(colour);
        }
        assert_eq!(SPEAKERS.len(), 10);
    }

    /// Two speakers is the common case, so slots 0 and 1 must be **the**
    /// furthest-apart pair in the set. The first version of this table failed
    /// exactly here, which is why the order is computed rather than judged.
    /// Every palette here is a palette the website has, with the same hexes.
    ///
    /// The same arrangement `veilvoice-gui` has had since the themes existed:
    /// the values are written out so a crate needs no website on disk to draw
    /// a circle, and this reads the stylesheet and fails if the two ever part
    /// company. Without it a theme could be changed on the site and a rendered
    /// video would quietly keep the old colours.
    #[test]
    fn every_palette_matches_the_website_stylesheet() {
        let css = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/css/themes.css"),
        )
        .expect("themes.css should be readable from the crate directory");

        for palette in PALETTES {
            // The block for this theme: from its selector to the closing brace.
            let marker = format!("[data-theme=\"{}\"]", palette.id);
            let start = css
                .find(&marker)
                .unwrap_or_else(|| panic!("{} is not in themes.css", palette.id));
            let block = &css[start..];
            let end = block.find('}').expect("a theme block must close");
            let block = &block[..end];

            for (token, value) in [
                ("--bg", palette.bg),
                ("--bg-soft", palette.bg_soft),
                ("--bg-inset", palette.bg_inset),
                ("--border", palette.border),
                ("--fg", palette.fg),
                ("--muted", palette.muted),
                ("--accent", palette.accent),
                ("--accent-2", palette.accent_2),
                ("--cyan", palette.cyan),
                ("--ok", palette.ok),
                ("--warn", palette.warn),
                ("--err", palette.err),
            ] {
                // `--accent` would otherwise match `--accent-2`.
                let wanted = format!("{token}:");
                let at = block
                    .match_indices(&wanted)
                    .find(|(index, _)| {
                        block[..*index].ends_with(char::is_whitespace) || *index == 0
                    })
                    .unwrap_or_else(|| panic!("{} has no {token}", palette.id))
                    .0;
                let rest = &block[at + wanted.len()..];
                let declared = rest.trim_start();
                assert!(
                    declared.to_lowercase().starts_with(&value.to_lowercase()),
                    "{} {token} is {} here and {} in themes.css",
                    palette.id,
                    value,
                    &declared[..7.min(declared.len())]
                );
            }
        }
    }

    /// The stylesheet must not hold a theme this crate has never heard of. A
    /// picker offering nine and a renderer knowing eight is a picker with one
    /// entry that silently draws in the wrong colours.
    #[test]
    fn the_stylesheet_has_no_theme_this_crate_is_missing() {
        let css = std::fs::read_to_string(
            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/css/themes.css"),
        )
        .expect("themes.css should be readable from the crate directory");

        for (index, _) in css.match_indices("[data-theme=\"") {
            let rest = &css[index + "[data-theme=\"".len()..];
            let id: String = rest.chars().take_while(|c| *c != '"').collect();
            assert!(
                by_id(&id).is_some(),
                "themes.css declares {id:?} and this crate has no palette for it"
            );
        }
    }

    #[test]
    fn the_default_is_tokyo_night_and_matches_the_constants() {
        let default = default_palette();
        assert_eq!(default.id, DEFAULT_ID);
        assert_eq!(default.bg, BG);
        assert_eq!(default.bg_inset, BG_INSET);
        assert_eq!(default.border, BORDER);
        assert_eq!(default.fg, FG);
        assert_eq!(default.muted, MUTED);
    }

    /// An unknown name is refused rather than falling back. A caller who typed
    /// a theme meant it, and drawing in a different one answers a question
    /// they did not ask.
    #[test]
    fn an_unknown_identifier_is_refused_rather_than_defaulted() {
        assert!(by_id("tokyo-night").is_some());
        assert!(by_id("gruvbox").is_some());
        assert!(by_id("solarised").is_none(), "not a spelling this has");
        assert!(by_id("").is_none());
        assert!(by_id("TOKYO-NIGHT").is_none(), "identifiers are exact");
    }

    #[test]
    fn every_identifier_is_unique_and_every_colour_parses() {
        let mut ids = ids();
        let count = ids.len();
        ids.sort_unstable();
        ids.dedup();
        assert_eq!(ids.len(), count, "two palettes share an identifier");
        assert!(count >= 9, "the website offers nine; this has {count}");

        for palette in PALETTES {
            for (what, colour) in [
                ("bg", palette.bg),
                ("bg-soft", palette.bg_soft),
                ("bg-inset", palette.bg_inset),
                ("border", palette.border),
                ("fg", palette.fg),
                ("muted", palette.muted),
                ("accent", palette.accent),
                ("accent-2", palette.accent_2),
                ("cyan", palette.cyan),
                ("ok", palette.ok),
                ("warn", palette.warn),
                ("err", palette.err),
            ] {
                assert!(
                    rgb(colour).is_some(),
                    "{} {what} is {colour:?}, which is not #rrggbb",
                    palette.id
                );
            }
        }
    }

    /// Text has to be readable on the page in every palette, light or dark.
    /// WCAG's floor for body text is 4.5:1; this is the arithmetic, not a
    /// judgement.
    #[test]
    fn body_text_is_readable_on_the_page_in_every_palette() {
        for palette in PALETTES {
            let ratio = contrast(palette.fg, palette.bg);
            assert!(
                ratio >= 4.5,
                "{}: fg on bg is {ratio:.2}:1, under 4.5",
                palette.id
            );
        }
    }

    /// The speaker circles are one measured set shared by every palette, so
    /// the thing that has to hold per palette is that a name drawn on a circle
    /// is readable. `ink_on` computes that rather than assuming it.
    #[test]
    fn a_name_on_a_speaker_circle_is_readable_whatever_the_palette() {
        for slot in 0..SPEAKERS.len() {
            let circle = speaker(slot);
            let ratio = contrast(ink_on(circle), circle);
            assert!(
                ratio >= 4.5,
                "slot {slot} ({circle}) gives {ratio:.2}:1 for its label"
            );
        }
    }

    #[test]
    fn the_first_two_slots_are_the_furthest_apart_pair_in_the_set() {
        let first_pair = distance(SPEAKERS[0], SPEAKERS[1]);
        for (i, a) in SPEAKERS.iter().enumerate() {
            for b in SPEAKERS.iter().skip(i + 1) {
                assert!(
                    distance(a, b) <= first_pair + 0.5,
                    "{a} and {b} are further apart than slots 0 and 1"
                );
            }
        }
    }

    /// Every slot after the first two must be the colour whose nearest
    /// neighbour among those already used is furthest away. That is what makes
    /// a four-speaker recording use four well-separated colours rather than
    /// the first four somebody happened to list.
    #[test]
    fn the_order_is_maximin_so_it_degrades_gracefully() {
        for slot in 2..SPEAKERS.len() {
            let used = &SPEAKERS[..slot];
            let nearest = |colour: &str| {
                used.iter()
                    .map(|other| distance(colour, other))
                    .fold(f32::INFINITY, f32::min)
            };
            let chosen = nearest(SPEAKERS[slot]);
            for later in &SPEAKERS[slot + 1..] {
                assert!(
                    nearest(later) <= chosen + 0.5,
                    "slot {slot} should have been {later}: it is further from the \
                     colours already in use"
                );
            }
        }
    }

    /// The figures quoted in the module documentation, checked.
    #[test]
    fn the_spread_is_what_the_documentation_says() {
        let mut furthest: f32 = 0.0;
        let mut closest = f32::INFINITY;
        for (i, a) in SPEAKERS.iter().enumerate() {
            for b in SPEAKERS.iter().skip(i + 1) {
                furthest = furthest.max(distance(a, b));
                closest = closest.min(distance(a, b));
            }
        }
        assert!(
            (furthest - 507.0).abs() < 2.0,
            "furthest pair is {furthest}"
        );
        assert!((closest - 63.0).abs() < 2.0, "closest pair is {closest}");
    }

    #[test]
    fn a_distance_between_two_non_colours_is_zero_rather_than_a_panic() {
        assert_eq!(distance("nonsense", "#7aa2f7"), 0.0);
        assert_eq!(distance("#7aa2f7", "#7aa2f7"), 0.0);
    }

    /// Every speaker colour has to be visible against the page it is drawn on.
    /// Computed, not assumed -- the same rule the custom palettes follow.
    #[test]
    fn every_speaker_colour_is_visible_on_the_background() {
        for (slot, colour) in SPEAKERS.iter().enumerate() {
            let ratio = contrast(colour, BG);
            assert!(
                ratio >= 3.0,
                "slot {slot} ({colour}) is {ratio:.2}:1 against the background, and a \
                 shape needs 3:1"
            );
        }
    }

    /// A name drawn on a speaker's colour must be readable on it.
    #[test]
    fn the_ink_chosen_for_each_colour_is_readable_on_it() {
        for colour in SPEAKERS {
            let ink = ink_on(colour);
            assert!(
                contrast(colour, ink) >= 4.5,
                "{ink} on {colour} is only {:.2}:1",
                contrast(colour, ink)
            );
        }
    }

    #[test]
    fn a_colour_that_is_not_a_colour_is_refused_rather_than_guessed_at() {
        for bad in ["", "#", "#fff", "#gggggg", "7aa2f7", "#7aa2f7f7", "blue"] {
            assert!(rgb(bad).is_none(), "{bad:?} parsed as a colour");
        }
        assert_eq!(luminance("nonsense"), 0.0);
    }

    #[test]
    fn luminance_and_contrast_are_the_documented_ranges() {
        assert!(luminance("#000000") < 1e-6);
        assert!((luminance("#ffffff") - 1.0).abs() < 1e-4);
        assert!((contrast("#000000", "#ffffff") - 21.0).abs() < 0.01);
        assert!((contrast("#7aa2f7", "#7aa2f7") - 1.0).abs() < 1e-4);
    }

    #[test]
    fn asking_past_the_table_wraps() {
        assert_eq!(speaker(0), SPEAKERS[0]);
        assert_eq!(speaker(10), SPEAKERS[0]);
        let _ = speaker(usize::MAX);
    }
}