Skip to main content

max / makeover

7.5 KB · 183 lines History Blame Raw
1 //! Tonal steps
2
3 use crate::{Rgb, mix};
4
5 // Names this module's prose links to, resolved for rustdoc.
6 #[allow(unused_imports)]
7 use 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)]
24 pub 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
33 impl 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.
89 pub 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]
117 pub 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]
127 pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb {
128 tonal(base, ground, emphasis.ratio())
129 }
130
131 #[cfg(test)]
132 mod 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 }
183