Skip to main content

max / makeover

Derive the ink's emphasis steps rather than authoring them content-secondary and content-muted are not independent colours: they are the ink, one step and two steps back. Authored separately they drift, and they had -- nord and solarized-dark shipped a secondary lighter than their own primary and dracula an identical one, so the emphasis ramp every renderer reads was inverted in three of the 31 bundled themes and nothing said so. Both are now tonal steps of content.primary toward surface.page, 0.12 and 0.42 of the way, filled in by derive_tonal_steps at load. The ratios are the corpus' own: the hand-authored values sat at a median 0.115 and 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. Derived at load rather than in resolve() so there is one answer. ansi_intent reads authored dotted keys, makeover-tui's Theme reads them, and resolve() reads them; a derivation visible from only one of those is how a terminal and a webview come to disagree about what muted means. The general form comes with it, since a tonal step of a token is not a fact about the ink specifically: Emphasis (Full/Secondary/Muted) with ratio, suffix and token, over tonal() and emphasized(). Any family can be stepped the same way, so danger-muted is nameable without a second table saying what it means, and steps compose exactly -- OKLab interpolation is linear -- so deriving recursively does not compound.
Wiki
three-tone-convention
Author: Max Johnson <me@maxj.phd> · 2026-08-16 19:00 UTC
Signed with PGP, not checked
Commit: da2b2d121fa43ed3f8928ca432c826a20f39df4a
Parent: 1170872
34 files changed, +269 insertions, -72 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.5.2"
3 + version = "2.6.0"
4 4 edition = "2024"
5 5 description = "Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast."
6 6 license = "MIT"
M src/lib.rs +259 -6
@@ -17,8 +17,9 @@
17 17 //! [surface] # container backgrounds by role/elevation
18 18 //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252"
19 19 //!
20 - //! [content] # text/ink by emphasis
21 - //! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88"
20 + //! [content] # the ink. Its emphasis steps are derived, not authored:
21 + //! primary = "#d8dee9" # `content-secondary` and `content-muted` are tonal
22 + //! # steps of this toward `surface.page`. See `Emphasis`.
22 23 //!
23 24 //! [action] # interactive / brand color
24 25 //! primary = "#81a1c1"
@@ -229,6 +230,116 @@
229 230 })
230 231 }
231 232
233 + // ============================================================================
234 + // Tonal steps
235 + // ============================================================================
236 +
237 + /// How far a tonal step sits from the token it is a step of.
238 + ///
239 + /// The named ratios. [`tonal`] is the same operation with the number written
240 + /// out, and this is the small set of steps the vocabulary has agreed on, so a
241 + /// consumer asking for "the muted form of this" names it rather than picking a
242 + /// number and disagreeing with the next consumer to pick one.
243 + ///
244 + /// The rule these encode, stated as the three-tone convention:
245 + ///
246 + /// | step | what it means |
247 + /// |------|---------------|
248 + /// | [`Full`](Self::Full) | active, emphasised, the thing itself |
249 + /// | [`Secondary`](Self::Secondary) | inactive but usable: a control that still answers |
250 + /// | [`Muted`](Self::Muted) | inert: disabled, or not a control at all |
251 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
252 + pub enum Emphasis {
253 + /// The token unchanged.
254 + Full,
255 + /// One step back. Still legible as content, not competing with `Full`.
256 + Secondary,
257 + /// Two steps back. Present, and saying it is not the point.
258 + Muted,
259 + }
260 +
261 + impl Emphasis {
262 + /// The fraction of the way to the ground this step travels.
263 + ///
264 + /// Both numbers are the shipped corpus' own, not invented: across the 31
265 + /// bundled themes, hand-authored `content.secondary` sat at a median 0.115
266 + /// of the way from `content.primary` to `surface.page`, and `content.muted`
267 + /// at 0.424. So the derivation reproduces what theme authors converged on
268 + /// by eye, and the themes that move are the ones that were off the cluster.
269 + #[must_use]
270 + pub const fn ratio(self) -> f32 {
271 + match self {
272 + Self::Full => 0.0,
273 + Self::Secondary => 0.12,
274 + Self::Muted => 0.42,
275 + }
276 + }
277 +
278 + /// The suffix a derived token takes, or `None` for the token itself.
279 + ///
280 + /// `content` + [`Muted`](Self::Muted) is `content-muted`, which is the
281 + /// naming every consumer already spells by hand. Grouping a family this way
282 + /// is what makes `danger-muted` or `action-secondary` nameable without a
283 + /// second table saying what they mean.
284 + #[must_use]
285 + pub const fn suffix(self) -> Option<&'static str> {
286 + match self {
287 + Self::Full => None,
288 + Self::Secondary => Some("-secondary"),
289 + Self::Muted => Some("-muted"),
290 + }
291 + }
292 +
293 + /// The derived token key for `token` at this step.
294 + #[must_use]
295 + pub fn token(self, token: &str) -> String {
296 + match self.suffix() {
297 + Some(suffix) => format!("{token}{suffix}"),
298 + None => token.to_string(),
299 + }
300 + }
301 + }
302 +
303 + /// A tonal step of `base`, `ratio` of the way toward the `ground` it is read
304 + /// against.
305 + ///
306 + /// The numerical form of [`Emphasis`], for a consumer that wants a step the
307 + /// named set does not have. `ratio` is clamped to [0,1]: past 1 the step is no
308 + /// longer a step of `base` but a colour beyond the ground, which is a different
309 + /// operation wearing this one's name.
310 + ///
311 + /// # Toward the ground, not toward grey
312 + ///
313 + /// A tonal step is a *reduction in contrast against what it is read on*, so it
314 + /// interpolates toward the surface rather than desaturating or lightening. That
315 + /// is why it takes two colours: lightening is wrong on a light theme and
316 + /// darkening is wrong on a dark one, and mixing toward the ground is correct on
317 + /// both without asking which theme this is. It is also why the ground is a
318 + /// parameter rather than assumed — text in a well is read against the well.
319 + ///
320 + /// # It composes
321 + ///
322 + /// Two steps toward the same ground are one step toward that ground, since
323 + /// OKLab interpolation is linear: `tonal(tonal(c, g, a), g, b)` is
324 + /// `tonal(c, g, a + b - a*b)`. So a family can be derived recursively — the
325 + /// muted form of a secondary is a well-defined colour and not a compounding
326 + /// error — and re-deriving a token that was already derived is stable rather
327 + /// than a slow slide into the background.
328 + #[must_use]
329 + pub fn tonal(base: Rgb, ground: Rgb, ratio: f32) -> Rgb {
330 + mix(base, ground, ratio.clamp(0.0, 1.0))
331 + }
332 +
333 + /// A named tonal step of `base` against the `ground` it is read on.
334 + ///
335 + /// [`tonal`] with [`Emphasis::ratio`], and the form to reach for: the two
336 + /// spellings of "muted" a pair of consumers pick independently are the drift
337 + /// this replaces.
338 + #[must_use]
339 + pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb {
340 + tonal(base, ground, emphasis.ratio())
341 + }
342 +
232 343 // ============================================================================
233 344 // Low-color terminals
234 345 // ============================================================================
@@ -558,9 +669,13 @@
558 669 // Intent resolution
559 670 // ============================================================================
560 671
561 - /// Authored base intents: (TOML dotted source key, canonical token key).
562 - /// These are read straight from the theme; the token key is the CSS-var stem
563 - /// (`--{token}`) and the `rgb()` lookup key.
672 + /// Base intents: (TOML dotted source key, canonical token key). The token key
673 + /// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key.
674 + ///
675 + /// Read straight from the loaded theme, which is not quite the same as read
676 + /// from the file: `content.secondary` and `content.muted` are tonal steps of
677 + /// `content.primary` and are filled in at load by [`derive_tonal_steps`], so
678 + /// they arrive here already computed and take this path like any other.
564 679 pub const BASE_INTENTS: &[(&str, &str)] = &[
565 680 ("surface.page", "surface-page"),
566 681 ("surface.raised", "surface-raised"),
@@ -1142,6 +1257,10 @@
1142 1257
1143 1258 /// Extract the intent color sections into a flat `HashMap` with dotted keys
1144 1259 /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
1260 + ///
1261 + /// The tonal steps of `content.primary` are filled in here rather than read, by
1262 + /// [`derive_tonal_steps`]. Anything a theme authored under those keys is
1263 + /// replaced.
1145 1264 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
1146 1265 let mut colors = HashMap::new();
1147 1266 for section in COLOR_SECTIONS {
@@ -1153,9 +1272,48 @@
1153 1272 }
1154 1273 }
1155 1274 }
1275 + derive_tonal_steps(&mut colors);
1156 1276 colors
1157 1277 }
1158 1278
1279 + /// Fill in the tonal steps of `content.primary`, overwriting whatever the theme
1280 + /// authored under those keys.
1281 + ///
1282 + /// # Why they are not authored
1283 + ///
1284 + /// `content.secondary` and `content.muted` are not independent colours. They are
1285 + /// the ink, one step and two steps back, and a theme that names them separately
1286 + /// is stating three times something it stated once — which is how three of the
1287 + /// bundled themes came to author a `secondary` *lighter* than their own
1288 + /// `primary` (nord, solarized-dark) or identical to it (dracula), inverting the
1289 + /// emphasis ramp the whole vocabulary rests on. Deriving them makes
1290 + /// `content` > `content-secondary` > `content-muted` true by construction in
1291 + /// every theme, including one a user writes.
1292 + ///
1293 + /// Applied at load rather than in [`resolve`] so that there is one answer: the
1294 + /// resolved token layer, the ANSI table ([`ansi_intent`] reads authored keys),
1295 + /// and every consumer holding a [`ThemeColors`] all see the same value. A
1296 + /// derivation visible from only one of those is how a terminal and a webview
1297 + /// come to disagree about what muted means.
1298 + ///
1299 + /// Both keys need `content.primary` and `surface.page` to exist and parse. When
1300 + /// either is missing the step is skipped and anything authored is left where it
1301 + /// is, mirroring the skip-missing behaviour of the rest of the crate — a
1302 + /// half-written theme keeps whatever it has rather than losing it.
1303 + pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
1304 + let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
1305 + let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
1306 + let (Some(ink), Some(page)) = (ink, page) else {
1307 + return;
1308 + };
1309 + for (key, step) in [
1310 + ("content.secondary", Emphasis::Secondary),
1311 + ("content.muted", Emphasis::Muted),
1312 + ] {
1313 + colors.insert(key.to_string(), emphasized(ink, page, step).to_hex());
1314 + }
1315 + }
1316 +
1159 1317 /// Scan directories for `.toml` theme files and return metadata for each.
1160 1318 ///
1161 1319 /// Directories are checked in order; later entries override earlier ones by ID.
@@ -1777,13 +1935,108 @@
1777 1935 let t = resolve(&theme);
1778 1936 assert_eq!(t.hex("surface-page"), Some("#2e3440"));
1779 1937 assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
1780 - assert_eq!(t.hex("content-muted"), Some("#616e88"));
1938 + // Not a passthrough: a tonal step of the ink, whatever the file said.
1939 + assert_eq!(
1940 + t.hex("content-muted").unwrap(),
1941 + emphasized(
1942 + Rgb::from_hex("#d8dee9").unwrap(),
1943 + Rgb::from_hex("#2e3440").unwrap(),
1944 + Emphasis::Muted
1945 + )
1946 + .to_hex()
1947 + );
1781 1948 assert_eq!(t.hex("action"), Some("#81a1c1"));
1782 1949 assert_eq!(t.hex("danger"), Some("#bf616a"));
1783 1950 assert_eq!(t.hex("border"), Some("#4c566a"));
1784 1951 assert_eq!(t.hex("category-five"), Some("#b48ead"));
1785 1952 }
1786 1953
1954 + #[test]
1955 + fn a_tonal_step_lands_between_its_base_and_its_ground() {
1956 + let ink = Rgb::from_hex("#d8dee9").unwrap();
1957 + let page = Rgb::from_hex("#2e3440").unwrap();
1958 + for step in [Emphasis::Full, Emphasis::Secondary, Emphasis::Muted] {
1959 + let out = emphasized(ink, page, step).to_oklab().l;
1960 + assert!(
1961 + out <= ink.to_oklab().l && out >= page.to_oklab().l,
1962 + "{step:?} left the interval between the ink and the page"
1963 + );
1964 + }
1965 + assert_eq!(emphasized(ink, page, Emphasis::Full).to_hex(), ink.to_hex());
1966 + }
1967 +
1968 + #[test]
1969 + fn tonal_steps_compose_rather_than_compound() {
1970 + // Two steps toward one ground are one step toward it, which is what
1971 + // makes deriving a family recursively well-defined. Within a rounding
1972 + // step, since each hop lands back in 8-bit sRGB.
1973 + let ink = Rgb::from_hex("#d8dee9").unwrap();
1974 + let page = Rgb::from_hex("#2e3440").unwrap();
1975 + let (a, b) = (0.12f32, 0.42f32);
1976 + let twice = tonal(tonal(ink, page, a), page, b);
1977 + let once = tonal(ink, page, a + b - a * b);
1978 + let (x, y) = (twice.tuple(), once.tuple());
1979 + for (l, r) in [(x.0, y.0), (x.1, y.1), (x.2, y.2)] {
1980 + assert!(l.abs_diff(r) <= 1, "{twice:?} is not {once:?}");
1981 + }
1982 + }
1983 +
1984 + #[test]
1985 + fn a_ratio_outside_the_interval_is_clamped_rather_than_extrapolated() {
1986 + let ink = Rgb::from_hex("#d8dee9").unwrap();
1987 + let page = Rgb::from_hex("#2e3440").unwrap();
1988 + assert_eq!(tonal(ink, page, -1.0).to_hex(), ink.to_hex());
1989 + assert_eq!(tonal(ink, page, 2.0).to_hex(), page.to_hex());
1990 + }
1991 +
1992 + #[test]
1993 + fn a_derived_token_key_is_the_family_plus_the_step() {
1994 + assert_eq!(Emphasis::Muted.token("content"), "content-muted");
1995 + assert_eq!(Emphasis::Secondary.token("content"), "content-secondary");
1996 + assert_eq!(Emphasis::Full.token("content"), "content");
1997 + // The point of the suffix being a property of the step: any family can
1998 + // be grouped the same way without a second table saying what it means.
1999 + assert_eq!(Emphasis::Muted.token("danger"), "danger-muted");
2000 + }
2001 +
2002 + #[test]
2003 + fn every_shipped_theme_ramps_one_way() {
2004 + // The property authoring the steps separately could not hold: three
2005 + // themes had shipped a secondary lighter than their own primary, so a
2006 + // renderer reading the emphasis order got the reverse of it.
2007 + for (id, toml) in embedded_themes() {
2008 + let theme = parse_theme_str(id, toml, false).unwrap();
2009 + let t = resolve(&theme);
2010 + let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
2011 + let steps = ["content", "content-secondary", "content-muted"]
2012 + .map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
2013 + assert!(
2014 + steps[0] > steps[1] && steps[1] > steps[2],
2015 + "{id}: emphasis does not fall monotonically: {steps:?}"
2016 + );
2017 + }
2018 + }
2019 +
2020 + #[test]
2021 + fn an_authored_emphasis_step_does_not_survive_loading() {
2022 + // `nord_toml` still authors both, because a user's theme file might and
2023 + // the answer has to be the same one.
2024 + let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2025 + assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
2026 + assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
2027 + }
2028 +
2029 + #[test]
2030 + fn a_theme_with_no_page_keeps_what_it_authored() {
2031 + // Skip-missing: there is nothing to read the step against, so the step
2032 + // is not taken and a half-written theme does not lose a colour.
2033 + let mut colors = HashMap::new();
2034 + colors.insert("content.primary".to_string(), "#d8dee9".to_string());
2035 + colors.insert("content.muted".to_string(), "#616e88".to_string());
2036 + derive_tonal_steps(&mut colors);
2037 + assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
2038 + }
2039 +
1787 2040 #[test]
1788 2041 fn resolve_derived_intents() {
1789 2042 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
@@ -24,10 +24,8 @@
24 24 sunken = "#434c5e"
25 25 overlay = "#3b4252"
26 26
27 - [content] # text and ink by emphasis
27 + [content] # the ink
28 28 primary = "#d8dee9"
29 - secondary = "#e5e9f0"
30 - muted = "#616e88"
31 29
32 30 [action] # interactive / brand color
33 31 primary = "#81a1c1"
@@ -48,6 +46,14 @@
48 46 are not authored. `resolve()` derives them perceptually in OKLab, so a theme
49 47 file stays small and every consuming app derives them identically.
50 48
49 + Neither are the ink's emphasis steps. `content-secondary` and `content-muted`
50 + are `content.primary` moved 12% and 42% of the way toward `surface.page`, and a
51 + value written under those keys is overwritten at load. They were authored until
52 + 2026-08-16, and separately-authored steps drift: three of the themes here had
53 + ended up with a `secondary` that was lighter than their own `primary`, inverting
54 + the emphasis order every renderer reads. What a theme controls is its ink and
55 + its page; how far back the quieter tones sit is one rule, in one place.
56 +
51 57 ### Theme ID
52 58
53 59 The filename without `.toml`. IDs may contain only alphanumeric characters,
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#1a1816"
20 - secondary = "#222d38"
21 - muted = "#514b45"
22 20
23 21 [action]
24 22 primary = "#8a4530"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#e6ded3"
20 - secondary = "#c0b8ad"
21 - muted = "#8e847a"
22 20
23 21 [action]
24 22 primary = "#e26a3b"
@@ -20,8 +20,6 @@
20 20
21 21 [content]
22 22 primary = "#000000"
23 - secondary = "#333333"
24 - muted = "#808080"
25 23
26 24 [action]
27 25 primary = "#3B5A9F"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#5c6166"
20 - secondary = "#6b7580"
21 - muted = "#8b9199"
22 20
23 21 [action]
24 22 primary = "#399ee6"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#cccac2"
20 - secondary = "#b8b4aa"
21 - muted = "#707a8c"
22 20
23 21 [action]
24 22 primary = "#5ccfe6"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#f2f4f8"
20 - secondary = "#dde1e6"
21 - muted = "#878d96"
22 20
23 21 [action]
24 22 primary = "#78a9ff"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#c6d0f5"
20 - secondary = "#b5bfe2"
21 - muted = "#a5adce"
22 20
23 21 [action]
24 22 primary = "#8caaee"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#4c4f69"
20 - secondary = "#5c5f77"
21 - muted = "#6c6f82"
22 20
23 21 [action]
24 22 primary = "#1e66f5"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#cad3f5"
20 - secondary = "#b8c0e0"
21 - muted = "#a5adcb"
22 20
23 21 [action]
24 22 primary = "#8aadf4"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#cdd6f4"
20 - secondary = "#bac2de"
21 - muted = "#9399b2"
22 20
23 21 [action]
24 22 primary = "#89b4fa"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#575279"
20 - secondary = "#625c87"
21 - muted = "#a8a3b3"
22 20
23 21 [action]
24 22 primary = "#286983"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#f8f8f2"
20 - secondary = "#f8f8f2"
21 - muted = "#6272A4"
22 20
23 21 [action]
24 22 primary = "#8be9fd"
@@ -17,8 +17,6 @@
17 17
18 18 [content]
19 19 primary = "#d3c6aa"
20 - secondary = "#9da9a0"
21 - muted = "#7a8478"
22 20
23 21 [action]
24 22 primary = "#7fbbb3"
@@ -20,8 +20,6 @@
20 20
21 21 [content]
22 22 primary = "#605a52"
23 - secondary = "#786d5e"
24 - muted = "#9a8b78"
25 23
26 24 [action]
27 25 primary = "#4c5361"
@@ -14,8 +14,6 @@
14 14
15 15 [content]
16 16 primary = "#000000"
17 - secondary = "#2D2D2D"
18 - muted = "#6B6B6B"
19 17
20 18 [action]
21 19 primary = "#6196FF"