//! 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"); } }