makeover 3.5.1

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
//! Tonal steps

use crate::{Rgb, mix};

// Names this module's prose links to, resolved for rustdoc.
#[allow(unused_imports)]
use crate::DISTINCT;

/// How far a tonal step sits from the token it is a step of.
///
/// The named ratios. [`tonal`] is the same operation with the number written
/// out, and this is the small set of steps the vocabulary has agreed on, so a
/// consumer asking for "the muted form of this" names it rather than picking a
/// number and disagreeing with the next consumer to pick one.
///
/// The rule these encode, stated as the three-tone convention:
///
/// | step | what it means |
/// |------|---------------|
/// | [`Full`](Self::Full) | active, emphasised, the thing itself |
/// | [`Secondary`](Self::Secondary) | inactive but usable: a control that still answers |
/// | [`Muted`](Self::Muted) | inert: disabled, or not a control at all |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Emphasis {
    /// The token unchanged.
    Full,
    /// One step back. Still legible as content, not competing with `Full`.
    Secondary,
    /// Two steps back. Present, and saying it is not the point.
    Muted,
}

impl Emphasis {
    /// The fraction of the way to the ground this step travels.
    ///
    /// Both numbers are the shipped corpus' own, not invented: across the 31
    /// bundled themes, hand-authored `content.secondary` sat at a median 0.115
    /// of the way from `content.primary` to `surface.page`, and `content.muted`
    /// at 0.424. So the derivation reproduces what theme authors converged on
    /// by eye, and the themes that move are the ones that were off the cluster.
    #[must_use]
    pub const fn ratio(self) -> f32 {
        match self {
            Self::Full => 0.0,
            Self::Secondary => 0.12,
            Self::Muted => 0.42,
        }
    }

    /// The suffix a derived token takes, or `None` for the token itself.
    ///
    /// `content` + [`Muted`](Self::Muted) is `content-muted`, which is the
    /// naming every consumer already spells by hand. Grouping a family this way
    /// is what makes `danger-muted` or `action-secondary` nameable without a
    /// second table saying what they mean.
    #[must_use]
    pub const fn suffix(self) -> Option<&'static str> {
        match self {
            Self::Full => None,
            Self::Secondary => Some("-secondary"),
            Self::Muted => Some("-muted"),
        }
    }

    /// The derived token key for `token` at this step.
    #[must_use]
    pub fn token(self, token: &str) -> String {
        match self.suffix() {
            Some(suffix) => format!("{token}{suffix}"),
            None => token.to_string(),
        }
    }
}

/// The contrast a tonal step must clear against the token it is a step of.
///
/// A ratio says how far to travel, not how far that lands, and the two are the
/// same thing only when the base has room to travel in. Across the bundled
/// themes a derived `content.secondary` sits between 1.21 and 1.44 of its ink;
/// the exceptions were the two themes whose ink is `#000000`, where OKLab L is
/// 0, 12 percent of nothing is nothing, and the sRGB transfer curve compresses
/// what is left into a 3/255 move. So the floor is the bottom of the band the
/// healthy themes already reach, and a theme inside it does not move.
///
/// Deliberately below [`DISTINCT`]: that is the 3:1 two *areas* need to read as
/// separate, and an emphasis step is one voice quieter rather than a second
/// region. Asking 3:1 of it would flatten every theme's ramp into three widely
/// spaced greys.
pub const STEP_FLOOR: f32 = 1.21;

/// A tonal step of `base`, `ratio` of the way toward the `ground` it is read
/// against.
///
/// The numerical form of [`Emphasis`], for a consumer that wants a step the
/// named set does not have. `ratio` is clamped to \[0,1\]: past 1 the step is no
/// longer a step of `base` but a colour beyond the ground, which is a different
/// operation wearing this one's name.
///
/// # Toward the ground, not toward grey
///
/// A tonal step is a *reduction in contrast against what it is read on*, so it
/// interpolates toward the surface rather than desaturating or lightening. That
/// is why it takes two colours: lightening is wrong on a light theme and
/// darkening is wrong on a dark one, and mixing toward the ground is correct on
/// both without asking which theme this is. It is also why the ground is a
/// parameter rather than assumed — text in a well is read against the well.
///
/// # It composes
///
/// Two steps toward the same ground are one step toward that ground, since
/// OKLab interpolation is linear: `tonal(tonal(c, g, a), g, b)` is
/// `tonal(c, g, a + b - a*b)`. So a family can be derived recursively — the
/// muted form of a secondary is a well-defined colour and not a compounding
/// error — and re-deriving a token that was already derived is stable rather
/// than a slow slide into the background.
#[must_use]
pub fn tonal(base: Rgb, ground: Rgb, ratio: f32) -> Rgb {
    mix(base, ground, ratio.clamp(0.0, 1.0))
}

/// A named tonal step of `base` against the `ground` it is read on.
///
/// [`tonal`] with [`Emphasis::ratio`], and the form to reach for: the two
/// spellings of "muted" a pair of consumers pick independently are the drift
/// this replaces.
#[must_use]
pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb {
    tonal(base, ground, emphasis.ratio())
}

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

    #[test]
    fn a_tonal_step_lands_between_its_base_and_its_ground() {
        let ink = Rgb::from_hex("#d8dee9").unwrap();
        let page = Rgb::from_hex("#2e3440").unwrap();
        for step in [Emphasis::Full, Emphasis::Secondary, Emphasis::Muted] {
            let out = emphasized(ink, page, step).to_oklab().l;
            assert!(
                out <= ink.to_oklab().l && out >= page.to_oklab().l,
                "{step:?} left the interval between the ink and the page"
            );
        }
        assert_eq!(emphasized(ink, page, Emphasis::Full).to_hex(), ink.to_hex());
    }

    #[test]
    fn tonal_steps_compose_rather_than_compound() {
        // Two steps toward one ground are one step toward it, which is what
        // makes deriving a family recursively well-defined. Within a rounding
        // step, since each hop lands back in 8-bit sRGB.
        let ink = Rgb::from_hex("#d8dee9").unwrap();
        let page = Rgb::from_hex("#2e3440").unwrap();
        let (a, b) = (0.12f32, 0.42f32);
        let twice = tonal(tonal(ink, page, a), page, b);
        let once = tonal(ink, page, a + b - a * b);
        let (x, y) = (twice.tuple(), once.tuple());
        for (l, r) in [(x.0, y.0), (x.1, y.1), (x.2, y.2)] {
            assert!(l.abs_diff(r) <= 1, "{twice:?} is not {once:?}");
        }
    }

    #[test]
    fn a_ratio_outside_the_interval_is_clamped_rather_than_extrapolated() {
        let ink = Rgb::from_hex("#d8dee9").unwrap();
        let page = Rgb::from_hex("#2e3440").unwrap();
        assert_eq!(tonal(ink, page, -1.0).to_hex(), ink.to_hex());
        assert_eq!(tonal(ink, page, 2.0).to_hex(), page.to_hex());
    }

    #[test]
    fn a_derived_token_key_is_the_family_plus_the_step() {
        assert_eq!(Emphasis::Muted.token("content"), "content-muted");
        assert_eq!(Emphasis::Secondary.token("content"), "content-secondary");
        assert_eq!(Emphasis::Full.token("content"), "content");
        // The point of the suffix being a property of the step: any family can
        // be grouped the same way without a second table saying what it means.
        assert_eq!(Emphasis::Muted.token("danger"), "danger-muted");
    }
}