Skip to main content

max / makeover

Floor the derived emphasis step so it clears its own ink 2.6.0 derived content.secondary and content.muted as a fixed fraction of the way from the ink to the page. A fraction says how far to travel, not how far that lands: with a pure-black ink OKLab L is 0, so twelve percent of the range is nearly nothing and sRGB's transfer curve compresses what is left into a 3/255 move. The audiofiles and goingson themes shipped a secondary their own ink is 1.02 from, which is not a step. Each step now starts at its named ratio and is pushed toward the page until it clears STEP_FLOOR against the ink -- 1.21, the bottom of the band the other twenty-nine themes already reach. Muted starts no nearer than secondary landed, so pushing one out cannot invert the ramp. The test that would have caught this is the one that was missing: the ramp falling monotonically says nothing about how far it falls. Three themes move, and only two of them visibly: audiofiles and goingson gain a secondary that can be seen, and solarized-dark shifts by 1/255 for sitting a rounding step under the floor.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-18 01:14 UTC
Signed with PGP, not checked
Commit: 77cb93d8d00c49babc6e238606d306fe0a3513d4
Parent: 28bb7a0
2 files changed, +76 insertions, -2 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover"
3 - version = "2.8.0"
3 + version = "2.9.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 +75 -1
@@ -300,6 +300,22 @@
300 300 }
301 301 }
302 302
303 + /// The contrast a tonal step must clear against the token it is a step of.
304 + ///
305 + /// A ratio says how far to travel, not how far that lands, and the two are the
306 + /// same thing only when the base has room to travel in. Across the bundled
307 + /// themes a derived `content.secondary` sits between 1.21 and 1.44 of its ink;
308 + /// the exceptions were the two themes whose ink is `#000000`, where OKLab L is
309 + /// 0, 12 percent of nothing is nothing, and the sRGB transfer curve compresses
310 + /// what is left into a 3/255 move. So the floor is the bottom of the band the
311 + /// healthy themes already reach, and a theme inside it does not move.
312 + ///
313 + /// Deliberately below [`DISTINCT`]: that is the 3:1 two *areas* need to read as
314 + /// separate, and an emphasis step is one voice quieter rather than a second
315 + /// region. Asking 3:1 of it would flatten every theme's ramp into three widely
316 + /// spaced greys.
317 + pub const STEP_FLOOR: f32 = 1.21;
318 +
303 319 /// A tonal step of `base`, `ratio` of the way toward the `ground` it is read
304 320 /// against.
305 321 ///
@@ -1717,17 +1733,56 @@
1717 1733 /// either is missing the step is skipped and anything authored is left where it
1718 1734 /// is, mirroring the skip-missing behaviour of the rest of the crate — a
1719 1735 /// half-written theme keeps whatever it has rather than losing it.
1736 + ///
1737 + /// # The ratio is a starting point, not the answer
1738 + ///
1739 + /// Each step is pushed further toward the page until it clears [`STEP_FLOOR`]
1740 + /// against the ink, so what the theme gets is a step that can be seen rather
1741 + /// than a step of the agreed size. The two are the same number in every bundled
1742 + /// theme but the two with a pure-black ink, where the ratio has no range to
1743 + /// travel in and the nominal step lands 3/255 from where it started.
1720 1744 pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
1721 1745 let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
1722 1746 let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
1723 1747 let (Some(ink), Some(page)) = (ink, page) else {
1724 1748 return;
1725 1749 };
1750 + // Each step starts no nearer than the one before it landed, so pushing
1751 + // secondary out cannot carry it past muted and invert the ramp.
1752 + let mut reached = 0.0;
1726 1753 for (key, step) in [
1727 1754 ("content.secondary", Emphasis::Secondary),
1728 1755 ("content.muted", Emphasis::Muted),
1729 1756 ] {
1730 - colors.insert(key.to_string(), emphasized(ink, page, step).to_hex());
1757 + let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached));
1758 + reached = ratio;
1759 + colors.insert(key.to_string(), color.to_hex());
1760 + }
1761 + }
1762 +
1763 + /// The step `from` of the way from `ink` to `page`, pushed toward `page` until
1764 + /// it clears [`STEP_FLOOR`] against the ink it is a step of. Returns the colour
1765 + /// and the ratio it was found at.
1766 + ///
1767 + /// A forward scan rather than a solve, because it wants the *first* ratio that
1768 + /// clears: contrast against the base rises with the distance travelled, but it
1769 + /// rises through sRGB's transfer curve and OKLab's chroma path, and a bisection
1770 + /// would trust a monotonicity nothing here guarantees.
1771 + ///
1772 + /// Travel stops at the ground. A theme whose ink and page are the same colour
1773 + /// has no step to take, and the ground is the honest answer — nothing past it
1774 + /// is a step of the ink any more.
1775 + fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) {
1776 + // Finer than 8-bit sRGB can resolve on the shortest ramp in the corpus, so
1777 + // the scan never steps over the first colour that clears.
1778 + const PROBE: f32 = 0.005;
1779 + let mut ratio = from.clamp(0.0, 1.0);
1780 + loop {
1781 + let color = tonal(ink, page, ratio);
1782 + if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 {
1783 + return (color, ratio);
1784 + }
1785 + ratio = (ratio + PROBE).min(1.0);
1731 1786 }
1732 1787 }
1733 1788
@@ -2434,6 +2489,25 @@
2434 2489 }
2435 2490 }
2436 2491
2492 + #[test]
2493 + fn every_shipped_theme_takes_a_visible_first_step() {
2494 + // The property that was missing when 2.6.0 derived these, and the
2495 + // reason a pure-black ink shipped a secondary 3/255 away from it: the
2496 + // ramp falling monotonically says nothing about how far it falls, and
2497 + // a step nobody can see is not a step.
2498 + for (id, toml) in embedded_themes() {
2499 + let theme = parse_theme_str(id, toml, false).unwrap();
2500 + let t = resolve(&theme);
2501 + let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap();
2502 + let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap();
2503 + let step = wcag_contrast(ink, secondary);
2504 + assert!(
2505 + step >= STEP_FLOOR,
2506 + "{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor"
2507 + );
2508 + }
2509 + }
2510 +
2437 2511 #[test]
2438 2512 fn an_authored_emphasis_step_does_not_survive_loading() {
2439 2513 // `nord_toml` still authors both, because a user's theme file might and