| 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 |
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 |
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 |
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 |
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 |
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();
|