Skip to main content

makeover/
emphasis.rs

1//! Tonal steps
2
3use crate::{Rgb, mix};
4
5// Names this module's prose links to, resolved for rustdoc.
6#[allow(unused_imports)]
7use crate::DISTINCT;
8
9/// How far a tonal step sits from the token it is a step of.
10///
11/// The named ratios. [`tonal`] is the same operation with the number written
12/// out, and this is the small set of steps the vocabulary has agreed on, so a
13/// consumer asking for "the muted form of this" names it rather than picking a
14/// number and disagreeing with the next consumer to pick one.
15///
16/// The rule these encode, stated as the three-tone convention:
17///
18/// | step | what it means |
19/// |------|---------------|
20/// | [`Full`](Self::Full) | active, emphasised, the thing itself |
21/// | [`Secondary`](Self::Secondary) | inactive but usable: a control that still answers |
22/// | [`Muted`](Self::Muted) | inert: disabled, or not a control at all |
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum Emphasis {
25    /// The token unchanged.
26    Full,
27    /// One step back. Still legible as content, not competing with `Full`.
28    Secondary,
29    /// Two steps back. Present, and saying it is not the point.
30    Muted,
31}
32
33impl Emphasis {
34    /// The fraction of the way to the ground this step travels.
35    ///
36    /// Both numbers are the shipped corpus' own, not invented: across the 31
37    /// bundled themes, hand-authored `content.secondary` sat at a median 0.115
38    /// of the way from `content.primary` to `surface.page`, and `content.muted`
39    /// at 0.424. So the derivation reproduces what theme authors converged on
40    /// by eye, and the themes that move are the ones that were off the cluster.
41    #[must_use]
42    pub const fn ratio(self) -> f32 {
43        match self {
44            Self::Full => 0.0,
45            Self::Secondary => 0.12,
46            Self::Muted => 0.42,
47        }
48    }
49
50    /// The suffix a derived token takes, or `None` for the token itself.
51    ///
52    /// `content` + [`Muted`](Self::Muted) is `content-muted`, which is the
53    /// naming every consumer already spells by hand. Grouping a family this way
54    /// is what makes `danger-muted` or `action-secondary` nameable without a
55    /// second table saying what they mean.
56    #[must_use]
57    pub const fn suffix(self) -> Option<&'static str> {
58        match self {
59            Self::Full => None,
60            Self::Secondary => Some("-secondary"),
61            Self::Muted => Some("-muted"),
62        }
63    }
64
65    /// The derived token key for `token` at this step.
66    #[must_use]
67    pub fn token(self, token: &str) -> String {
68        match self.suffix() {
69            Some(suffix) => format!("{token}{suffix}"),
70            None => token.to_string(),
71        }
72    }
73}
74
75/// The contrast a tonal step must clear against the token it is a step of.
76///
77/// A ratio says how far to travel, not how far that lands, and the two are the
78/// same thing only when the base has room to travel in. Across the bundled
79/// themes a derived `content.secondary` sits between 1.21 and 1.44 of its ink;
80/// the exceptions were the two themes whose ink is `#000000`, where OKLab L is
81/// 0, 12 percent of nothing is nothing, and the sRGB transfer curve compresses
82/// what is left into a 3/255 move. So the floor is the bottom of the band the
83/// healthy themes already reach, and a theme inside it does not move.
84///
85/// Deliberately below [`DISTINCT`]: that is the 3:1 two *areas* need to read as
86/// separate, and an emphasis step is one voice quieter rather than a second
87/// region. Asking 3:1 of it would flatten every theme's ramp into three widely
88/// spaced greys.
89pub const STEP_FLOOR: f32 = 1.21;
90
91/// A tonal step of `base`, `ratio` of the way toward the `ground` it is read
92/// against.
93///
94/// The numerical form of [`Emphasis`], for a consumer that wants a step the
95/// named set does not have. `ratio` is clamped to \[0,1\]: past 1 the step is no
96/// longer a step of `base` but a colour beyond the ground, which is a different
97/// operation wearing this one's name.
98///
99/// # Toward the ground, not toward grey
100///
101/// A tonal step is a *reduction in contrast against what it is read on*, so it
102/// interpolates toward the surface rather than desaturating or lightening. That
103/// is why it takes two colours: lightening is wrong on a light theme and
104/// darkening is wrong on a dark one, and mixing toward the ground is correct on
105/// both without asking which theme this is. It is also why the ground is a
106/// parameter rather than assumed — text in a well is read against the well.
107///
108/// # It composes
109///
110/// Two steps toward the same ground are one step toward that ground, since
111/// OKLab interpolation is linear: `tonal(tonal(c, g, a), g, b)` is
112/// `tonal(c, g, a + b - a*b)`. So a family can be derived recursively — the
113/// muted form of a secondary is a well-defined colour and not a compounding
114/// error — and re-deriving a token that was already derived is stable rather
115/// than a slow slide into the background.
116#[must_use]
117pub fn tonal(base: Rgb, ground: Rgb, ratio: f32) -> Rgb {
118    mix(base, ground, ratio.clamp(0.0, 1.0))
119}
120
121/// A named tonal step of `base` against the `ground` it is read on.
122///
123/// [`tonal`] with [`Emphasis::ratio`], and the form to reach for: the two
124/// spellings of "muted" a pair of consumers pick independently are the drift
125/// this replaces.
126#[must_use]
127pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb {
128    tonal(base, ground, emphasis.ratio())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn a_tonal_step_lands_between_its_base_and_its_ground() {
137        let ink = Rgb::from_hex("#d8dee9").unwrap();
138        let page = Rgb::from_hex("#2e3440").unwrap();
139        for step in [Emphasis::Full, Emphasis::Secondary, Emphasis::Muted] {
140            let out = emphasized(ink, page, step).to_oklab().l;
141            assert!(
142                out <= ink.to_oklab().l && out >= page.to_oklab().l,
143                "{step:?} left the interval between the ink and the page"
144            );
145        }
146        assert_eq!(emphasized(ink, page, Emphasis::Full).to_hex(), ink.to_hex());
147    }
148
149    #[test]
150    fn tonal_steps_compose_rather_than_compound() {
151        // Two steps toward one ground are one step toward it, which is what
152        // makes deriving a family recursively well-defined. Within a rounding
153        // step, since each hop lands back in 8-bit sRGB.
154        let ink = Rgb::from_hex("#d8dee9").unwrap();
155        let page = Rgb::from_hex("#2e3440").unwrap();
156        let (a, b) = (0.12f32, 0.42f32);
157        let twice = tonal(tonal(ink, page, a), page, b);
158        let once = tonal(ink, page, a + b - a * b);
159        let (x, y) = (twice.tuple(), once.tuple());
160        for (l, r) in [(x.0, y.0), (x.1, y.1), (x.2, y.2)] {
161            assert!(l.abs_diff(r) <= 1, "{twice:?} is not {once:?}");
162        }
163    }
164
165    #[test]
166    fn a_ratio_outside_the_interval_is_clamped_rather_than_extrapolated() {
167        let ink = Rgb::from_hex("#d8dee9").unwrap();
168        let page = Rgb::from_hex("#2e3440").unwrap();
169        assert_eq!(tonal(ink, page, -1.0).to_hex(), ink.to_hex());
170        assert_eq!(tonal(ink, page, 2.0).to_hex(), page.to_hex());
171    }
172
173    #[test]
174    fn a_derived_token_key_is_the_family_plus_the_step() {
175        assert_eq!(Emphasis::Muted.token("content"), "content-muted");
176        assert_eq!(Emphasis::Secondary.token("content"), "content-secondary");
177        assert_eq!(Emphasis::Full.token("content"), "content");
178        // The point of the suffix being a property of the step: any family can
179        // be grouped the same way without a second table saying what it means.
180        assert_eq!(Emphasis::Muted.token("danger"), "danger-muted");
181    }
182}