| 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 |
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 |
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
|