Skip to main content

max / makeover

Split lib.rs along the ten banners it already carried lib.rs was 4326 lines with ten banner-marked sections, so the seams are the author's rather than invented. Each becomes a module; the root keeps the header and the facade. Every public path is preserved by re-export, verified against six in-tree consumers built from this working copy: makeover-tui, makeover-webview, makeover-immediate, makeover-touch, goingson and audiofiles all compile unedited. That verification needed a deliberate step, worth recording because the obvious one is a no-op. The tree's [patch] block redirects git-URL deps only, and consumers take makeover from crates.io by version, so a plain `cargo check` in a consumer builds the published crate and says nothing about a local change. These were built with `--config patch.crates-io.makeover.path=...` in throwaway worktrees, so the split was actually exercised and no real checkout's lockfile moved. FontFace::css widens to pub(crate): typography::font_face_css calls it across the new seam. No public path moved, so no version bump.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:45 UTC
Signed with PGP, not checked
Commit: 70f2676065ad731d1dfb9809f2160398ed31ee77
Parent: 3997520
12 files changed, +3753 insertions, -449 deletions
M src/lib.rs +42 -449
@@ -40,8 +40,48 @@
40 40 #![allow(clippy::many_single_char_names, clippy::unreadable_literal)]
41 41
42 42 use serde::Serialize;
43 - use std::collections::{BTreeMap, HashMap};
44 - use std::path::{Path, PathBuf};
43 + use std::collections::HashMap;
44 +
45 + mod ansi;
46 + mod color;
47 + mod dirs;
48 + mod emphasis;
49 + mod font;
50 + mod intent;
51 + mod load;
52 + mod selection;
53 + mod sheet;
54 + mod typography;
55 +
56 + #[cfg(test)]
57 + pub(crate) mod fixture;
58 +
59 + // Every public path this crate has ever offered is a root path. The named
60 + // re-exports below are that promise: a module is an internal seam, never an
61 + // address a caller has to learn.
62 + pub use ansi::{
63 + ANSI_16, ANSI_240, ANSI_240_OFFSET, ANSI_256, DISTINCT, ansi_intent, quantize, quantize_against,
64 + };
65 + pub use color::{Oklab, Rgb, darken, lighten, mix, readable_on, wcag_contrast};
66 + pub use dirs::{ThemeDirs, bundled_themes_dir, embedded_themes, find_theme_path};
67 + pub use emphasis::{Emphasis, STEP_FLOOR, emphasized, tonal};
68 + pub use font::{FontFace, FontOverride, FontSlot, Typography};
69 + pub use intent::{BASE_INTENTS, SemanticTokens, intent_css_declarations, intent_css_vars, resolve};
70 + pub use load::{
71 + ThemePreview, delete_theme, derive_tonal_steps, export_theme, extract_colors, import_theme,
72 + list_themes_from_dirs, load_semantic, load_theme, load_theme_preview, parse_meta,
73 + parse_theme_str, validate_theme_id,
74 + };
75 + pub use selection::{
76 + ContrastTier, FOLLOW, ThemeDefaults, ThemeOption, ThemeSelection, Variant, order_theme_options,
77 + theme_options,
78 + };
79 + pub use sheet::{THEME_ATTRIBUTE, all_themes_css, keyed_intent_css_vars};
80 + pub use typography::{
81 + FONT_MONO, FONT_SANS, HOUSE_MONO_FAMILY, HOUSE_SANS_FAMILY, HOUSE_WEIGHT_RANGE,
82 + WEBFONT_MONO_FILE, WEBFONT_SANS_FILE, font_face_css, typography_css_declarations,
83 + typography_css_vars,
84 + };
45 85
46 86 /// The color sections an authored theme may declare.
47 87 pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
@@ -65,4262 +105,26 @@
65 105 pub colors: HashMap<String, String>,
66 106 }
67 107
68 - // ============================================================================
69 - // Color math — perceptual (OKLab) derivations + WCAG contrast.
70 - //
71 - // Interactive states (hover/active/selection/surfaces) are derived in OKLab so
72 - // equal steps look equal across every theme's hues (Ottosson 2020; the modern
73 - // CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive
74 - // luminance threshold, so the choice actually meets AA where achievable.
75 - // This is the single source of truth shared by every product.
76 - // ============================================================================
77 -
78 - /// An sRGB color. Hex round-trips losslessly.
79 - #[derive(Clone, Copy, Debug, PartialEq, Eq)]
80 - pub struct Rgb {
81 - pub r: u8,
82 - pub g: u8,
83 - pub b: u8,
84 - }
85 -
86 - impl Rgb {
87 - /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
88 - pub fn from_hex(s: &str) -> Option<Rgb> {
89 - let h = s.strip_prefix('#')?;
90 - let (r, g, b) = match h.len() {
91 - 6 => (
92 - u8::from_str_radix(&h[0..2], 16).ok()?,
93 - u8::from_str_radix(&h[2..4], 16).ok()?,
94 - u8::from_str_radix(&h[4..6], 16).ok()?,
95 - ),
96 - 3 => {
97 - let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
98 - (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
99 - }
100 - _ => return None,
101 - };
102 - Some(Rgb { r, g, b })
103 - }
104 -
105 - /// Lowercase `#rrggbb`.
106 - pub fn to_hex(self) -> String {
107 - format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
108 - }
109 -
110 - pub fn tuple(self) -> (u8, u8, u8) {
111 - (self.r, self.g, self.b)
112 - }
113 - }
114 -
115 - /// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes.
116 - #[derive(Clone, Copy, Debug)]
117 - pub struct Oklab {
118 - pub l: f32,
119 - pub a: f32,
120 - pub b: f32,
121 - }
122 -
123 - fn srgb_to_linear(c: u8) -> f32 {
124 - let c = c as f32 / 255.0;
125 - if c <= 0.04045 {
126 - c / 12.92
127 - } else {
128 - ((c + 0.055) / 1.055).powf(2.4)
129 - }
130 - }
131 -
132 - fn linear_to_srgb(c: f32) -> u8 {
133 - let c = c.clamp(0.0, 1.0);
134 - let v = if c <= 0.0031308 {
135 - c * 12.92
136 - } else {
137 - 1.055 * c.powf(1.0 / 2.4) - 0.055
138 - };
139 - (v * 255.0).round().clamp(0.0, 255.0) as u8
140 - }
141 -
142 - impl Rgb {
143 - /// Convert to OKLab (Ottosson's sRGB matrices).
144 - ///
145 - /// The matrix coefficients are quoted at their published precision so they
146 - /// can be diffed against the reference. `f32` rounds them at compile time;
147 - /// truncating the literals would only make them harder to check.
148 - #[allow(clippy::excessive_precision)]
149 - pub fn to_oklab(self) -> Oklab {
150 - let (r, g, b) = (
151 - srgb_to_linear(self.r),
152 - srgb_to_linear(self.g),
153 - srgb_to_linear(self.b),
154 - );
155 - let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
156 - let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
157 - let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
158 - let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
159 - Oklab {
160 - l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
161 - a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
162 - b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
163 - }
164 - }
165 -
166 - /// Convert from OKLab back to the nearest in-gamut sRGB.
167 - ///
168 - /// Published precision, as in [`Rgb::to_oklab`].
169 - #[allow(clippy::excessive_precision)]
170 - pub fn from_oklab(c: Oklab) -> Rgb {
171 - let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
172 - let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
173 - let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
174 - let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
175 - Rgb {
176 - r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
177 - g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
178 - b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
179 - }
180 - }
181 - }
182 -
183 - /// WCAG 2.x relative luminance of an sRGB color.
184 - fn rel_luminance(c: Rgb) -> f32 {
185 - 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
186 - }
187 -
188 - /// WCAG 2.x contrast ratio between two colors, in [1, 21].
189 - pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
190 - let (la, lb) = (rel_luminance(a), rel_luminance(b));
191 - let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
192 - (hi + 0.05) / (lo + 0.05)
193 - }
194 -
195 - /// Pick black or white for legible text on `bg`, by the higher WCAG contrast
196 - /// ratio (so the choice meets AA wherever the background allows it).
197 - pub fn readable_on(bg: Rgb) -> Rgb {
198 - let white = Rgb {
199 - r: 255,
200 - g: 255,
201 - b: 255,
202 - };
203 - let black = Rgb { r: 0, g: 0, b: 0 };
204 - if wcag_contrast(white, bg) >= wcag_contrast(black, bg) {
205 - white
206 - } else {
207 - black
208 - }
209 - }
210 -
211 - /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens.
212 - pub fn lighten(c: Rgb, delta: f32) -> Rgb {
213 - let mut lab = c.to_oklab();
214 - lab.l = (lab.l + delta).clamp(0.0, 1.0);
215 - Rgb::from_oklab(lab)
216 - }
217 -
218 - /// Shift OKLab lightness down by `delta` (perceptually uniform).
219 - pub fn darken(c: Rgb, delta: f32) -> Rgb {
220 - lighten(c, -delta)
221 - }
222 -
223 - /// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend).
224 - pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
225 - let (x, y) = (a.to_oklab(), b.to_oklab());
226 - Rgb::from_oklab(Oklab {
227 - l: x.l + (y.l - x.l) * t,
228 - a: x.a + (y.a - x.a) * t,
229 - b: x.b + (y.b - x.b) * t,
230 - })
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 - /// 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 -
319 - /// A tonal step of `base`, `ratio` of the way toward the `ground` it is read
320 - /// against.
321 - ///
322 - /// The numerical form of [`Emphasis`], for a consumer that wants a step the
323 - /// named set does not have. `ratio` is clamped to [0,1]: past 1 the step is no
324 - /// longer a step of `base` but a colour beyond the ground, which is a different
325 - /// operation wearing this one's name.
326 - ///
327 - /// # Toward the ground, not toward grey
328 - ///
329 - /// A tonal step is a *reduction in contrast against what it is read on*, so it
330 - /// interpolates toward the surface rather than desaturating or lightening. That
331 - /// is why it takes two colours: lightening is wrong on a light theme and
332 - /// darkening is wrong on a dark one, and mixing toward the ground is correct on
333 - /// both without asking which theme this is. It is also why the ground is a
334 - /// parameter rather than assumed — text in a well is read against the well.
335 - ///
336 - /// # It composes
337 - ///
338 - /// Two steps toward the same ground are one step toward that ground, since
339 - /// OKLab interpolation is linear: `tonal(tonal(c, g, a), g, b)` is
340 - /// `tonal(c, g, a + b - a*b)`. So a family can be derived recursively — the
341 - /// muted form of a secondary is a well-defined colour and not a compounding
342 - /// error — and re-deriving a token that was already derived is stable rather
343 - /// than a slow slide into the background.
344 - #[must_use]
345 - pub fn tonal(base: Rgb, ground: Rgb, ratio: f32) -> Rgb {
346 - mix(base, ground, ratio.clamp(0.0, 1.0))
347 - }
348 -
349 - /// A named tonal step of `base` against the `ground` it is read on.
350 - ///
351 - /// [`tonal`] with [`Emphasis::ratio`], and the form to reach for: the two
352 - /// spellings of "muted" a pair of consumers pick independently are the drift
353 - /// this replaces.
354 - #[must_use]
355 - pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb {
356 - tonal(base, ground, emphasis.ratio())
357 - }
358 -
359 - // ============================================================================
360 - // Low-color terminals
361 - // ============================================================================
362 -
363 - /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA
364 - /// arrangement the Linux console and most emulators start from.
365 - ///
366 - /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray
367 - /// rather than white, which is the entry a themed surface usually lands on, and
368 - /// index 15 is the true white.
369 - ///
370 - /// Emulators let the user repaint all sixteen, so this is the standard
371 - /// arrangement rather than a promise about any one terminal. The Linux console
372 - /// keeps it, which is the case that matters: a console app cannot fall back to
373 - /// 24-bit color there.
374 - pub const ANSI_16: [Rgb; 16] = [
375 - Rgb {
376 - r: 0x00,
377 - g: 0x00,
378 - b: 0x00,
379 - },
380 - Rgb {
381 - r: 0xaa,
382 - g: 0x00,
383 - b: 0x00,
384 - },
385 - Rgb {
386 - r: 0x00,
387 - g: 0xaa,
388 - b: 0x00,
389 - },
390 - Rgb {
391 - r: 0xaa,
392 - g: 0x55,
393 - b: 0x00,
394 - },
395 - Rgb {
396 - r: 0x00,
397 - g: 0x00,
398 - b: 0xaa,
399 - },
400 - Rgb {
401 - r: 0xaa,
402 - g: 0x00,
403 - b: 0xaa,
404 - },
405 - Rgb {
406 - r: 0x00,
407 - g: 0xaa,
408 - b: 0xaa,
409 - },
410 - Rgb {
411 - r: 0xaa,
412 - g: 0xaa,
413 - b: 0xaa,
414 - },
415 - Rgb {
416 - r: 0x55,
417 - g: 0x55,
418 - b: 0x55,
419 - },
420 - Rgb {
421 - r: 0xff,
422 - g: 0x55,
423 - b: 0x55,
424 - },
425 - Rgb {
426 - r: 0x55,
427 - g: 0xff,
428 - b: 0x55,
429 - },
430 - Rgb {
431 - r: 0xff,
432 - g: 0xff,
433 - b: 0x55,
434 - },
435 - Rgb {
436 - r: 0x55,
437 - g: 0x55,
438 - b: 0xff,
439 - },
440 - Rgb {
441 - r: 0xff,
442 - g: 0x55,
443 - b: 0xff,
444 - },
445 - Rgb {
446 - r: 0x55,
447 - g: 0xff,
448 - b: 0xff,
449 - },
450 - Rgb {
451 - r: 0xff,
452 - g: 0xff,
453 - b: 0xff,
454 - },
455 - ];
456 -
457 - /// The 256 colors an xterm-compatible terminal addresses by index, so that
458 - /// entry `i` is what the terminal paints for `38;5;i`.
459 - ///
460 - /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`]
461 - /// system colors, which every emulator lets the user repaint. 16-231 are a
462 - /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed.
463 - ///
464 - /// So a color whose whole job is to be told apart from another should quantize
465 - /// against [`ANSI_240`] rather than against this table: a match landing in the
466 - /// low sixteen is a match against a color the user may have moved.
467 - pub const ANSI_256: [Rgb; 256] = build_ansi_256();
468 -
469 - /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without
470 - /// the sixteen repaintable system colors.
471 - ///
472 - /// Quantizing against this returns an index into *this* slice; add
473 - /// [`ANSI_240_OFFSET`] to get the index the terminal wants.
474 - pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
475 -
476 - /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
477 - pub const ANSI_240_OFFSET: usize = 16;
478 -
479 - /// The twelve chromatic ANSI slots, as the intents that paint them.
480 - ///
481 - /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light
482 - /// or dark, since red is the theme's danger tone either way, which is exactly
483 - /// why the four achromatic slots are not in this table.
484 - ///
485 - /// Here rather than in each consumer, so a program that paints its own palette
486 - /// at runtime resolves the same slots as one reading a generated config. Slot
487 - /// 14 is `category.six`.
488 - const CHROMATIC: [(usize, &str); 12] = [
489 - (1, "status.danger"),
490 - (2, "status.success"),
491 - (3, "status.warning"),
492 - (4, "status.info"),
493 - (5, "category.five"),
494 - (6, "category.six"),
495 - (9, "action.primary"), // bright red, the theme's warm accent
496 - (10, "status.success"),
497 - (11, "status.warning"),
498 - (12, "status.info"),
499 - (13, "category.five"),
500 - (14, "category.six"),
501 - ];
502 -
503 - /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme.
504 - ///
505 - /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is
506 - /// "white", but what a terminal wants there is *the darkest tone* and *the
507 - /// lightest tone*, and which intent that is flips with the theme's polarity. A
508 - /// light theme's darkest tone is its ink; a dark theme's is its deepest
509 - /// surface. Pinning slot 0 to `content.primary` reads correctly on a light
510 - /// theme and hands a dark one a pale cream as "black".
511 - ///
512 - /// Slot 7 is a surface and not a text tone, because it is what a program with
513 - /// no way to name anything else draws its container on: a greeter's login card
514 - /// is a light card on the darker field slot 0 paints.
Lines truncated
A src/ansi.rs +500
@@ -1,0 +1,599 @@
1 + //! Low-color terminals
2 +
3 + use crate::{Rgb, wcag_contrast};
4 +
5 + // Names this module's prose links to, resolved for rustdoc.
6 + #[allow(unused_imports)]
7 + use crate::{ThemeColors, mix};
8 +
9 + /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA
10 + /// arrangement the Linux console and most emulators start from.
11 + ///
12 + /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray
13 + /// rather than white, which is the entry a themed surface usually lands on, and
14 + /// index 15 is the true white.
15 + ///
16 + /// Emulators let the user repaint all sixteen, so this is the standard
17 + /// arrangement rather than a promise about any one terminal. The Linux console
18 + /// keeps it, which is the case that matters: a console app cannot fall back to
19 + /// 24-bit color there.
20 + pub const ANSI_16: [Rgb; 16] = [
21 + Rgb {
22 + r: 0x00,
23 + g: 0x00,
24 + b: 0x00,
25 + },
26 + Rgb {
27 + r: 0xaa,
28 + g: 0x00,
29 + b: 0x00,
30 + },
31 + Rgb {
32 + r: 0x00,
33 + g: 0xaa,
34 + b: 0x00,
35 + },
36 + Rgb {
37 + r: 0xaa,
38 + g: 0x55,
39 + b: 0x00,
40 + },
41 + Rgb {
42 + r: 0x00,
43 + g: 0x00,
44 + b: 0xaa,
45 + },
46 + Rgb {
47 + r: 0xaa,
48 + g: 0x00,
49 + b: 0xaa,
50 + },
51 + Rgb {
52 + r: 0x00,
53 + g: 0xaa,
54 + b: 0xaa,
55 + },
56 + Rgb {
57 + r: 0xaa,
58 + g: 0xaa,
59 + b: 0xaa,
60 + },
61 + Rgb {
62 + r: 0x55,
63 + g: 0x55,
64 + b: 0x55,
65 + },
66 + Rgb {
67 + r: 0xff,
68 + g: 0x55,
69 + b: 0x55,
70 + },
71 + Rgb {
72 + r: 0x55,
73 + g: 0xff,
74 + b: 0x55,
75 + },
76 + Rgb {
77 + r: 0xff,
78 + g: 0xff,
79 + b: 0x55,
80 + },
81 + Rgb {
82 + r: 0x55,
83 + g: 0x55,
84 + b: 0xff,
85 + },
86 + Rgb {
87 + r: 0xff,
88 + g: 0x55,
89 + b: 0xff,
90 + },
91 + Rgb {
92 + r: 0x55,
93 + g: 0xff,
94 + b: 0xff,
95 + },
96 + Rgb {
97 + r: 0xff,
98 + g: 0xff,
99 + b: 0xff,
100 + },
101 + ];
102 +
103 + /// The 256 colors an xterm-compatible terminal addresses by index, so that
104 + /// entry `i` is what the terminal paints for `38;5;i`.
105 + ///
106 + /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`]
107 + /// system colors, which every emulator lets the user repaint. 16-231 are a
108 + /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed.
109 + ///
110 + /// So a color whose whole job is to be told apart from another should quantize
111 + /// against [`ANSI_240`] rather than against this table: a match landing in the
112 + /// low sixteen is a match against a color the user may have moved.
113 + pub const ANSI_256: [Rgb; 256] = build_ansi_256();
114 +
115 + /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without
116 + /// the sixteen repaintable system colors.
117 + ///
118 + /// Quantizing against this returns an index into *this* slice; add
119 + /// [`ANSI_240_OFFSET`] to get the index the terminal wants.
120 + pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
121 +
122 + /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
123 + pub const ANSI_240_OFFSET: usize = 16;
124 +
125 + /// The twelve chromatic ANSI slots, as the intents that paint them.
126 + ///
127 + /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light
128 + /// or dark, since red is the theme's danger tone either way, which is exactly
129 + /// why the four achromatic slots are not in this table.
130 + ///
131 + /// Here rather than in each consumer, so a program that paints its own palette
132 + /// at runtime resolves the same slots as one reading a generated config. Slot
133 + /// 14 is `category.six`.
134 + const CHROMATIC: [(usize, &str); 12] = [
135 + (1, "status.danger"),
136 + (2, "status.success"),
137 + (3, "status.warning"),
138 + (4, "status.info"),
139 + (5, "category.five"),
140 + (6, "category.six"),
141 + (9, "action.primary"), // bright red, the theme's warm accent
142 + (10, "status.success"),
143 + (11, "status.warning"),
144 + (12, "status.info"),
145 + (13, "category.five"),
146 + (14, "category.six"),
147 + ];
148 +
149 + /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme.
150 + ///
151 + /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is
152 + /// "white", but what a terminal wants there is *the darkest tone* and *the
153 + /// lightest tone*, and which intent that is flips with the theme's polarity. A
154 + /// light theme's darkest tone is its ink; a dark theme's is its deepest
155 + /// surface. Pinning slot 0 to `content.primary` reads correctly on a light
156 + /// theme and hands a dark one a pale cream as "black".
157 + ///
158 + /// Slot 7 is a surface and not a text tone, because it is what a program with
159 + /// no way to name anything else draws its container on: a greeter's login card
160 + /// is a light card on the darker field slot 0 paints.
161 + ///
162 + /// Anything that is not `dark`, including `high-contrast`, follows the light
163 + /// anchors.
164 + fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
165 + let dark = variant == "dark";
166 + Some(match (index, dark) {
167 + (0, false) => "content.primary", // darkest text tone
168 + (0, true) => "surface.sunken", // darkest surface
169 + (7, false) => "surface.raised", // the login card
170 + (7, true) => "content.secondary", // a readable light tone
171 + (8, _) => "content.muted", // muted chrome, either way
172 + (15, false) => "surface.overlay", // lightest surface
173 + (15, true) => "content.primary", // lightest text tone
174 + _ => return None,
175 + })
176 + }
177 +
178 + /// The authored intent painting ANSI slot `index` under a theme of `variant`,
179 + /// as a dotted key into [`ThemeColors::colors`].
180 + ///
181 + /// `None` for an index outside 0-15. Every slot in range resolves, so a caller
182 + /// that has the intent can fill all sixteen.
183 + ///
184 + /// This is what makes a bare console, a terminal emulator and a generated
185 + /// config agree on what red means. They disagreed for as long as each kept its
186 + /// own table.
187 + #[must_use]
188 + pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
189 + achromatic_slot(index, variant).or_else(|| {
190 + CHROMATIC
191 + .iter()
192 + .find(|(slot, _)| *slot == index)
193 + .map(|(_, intent)| *intent)
194 + })
195 + }
196 +
197 + const fn build_ansi_256() -> [Rgb; 256] {
198 + let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
199 +
200 + let mut i = 0;
201 + while i < 16 {
202 + table[i] = ANSI_16[i];
203 + i += 1;
204 + }
205 +
206 + // The cube's six levels are not evenly spaced. The step from black to the
207 + // first is more than twice any later one, which is xterm's arrangement
208 + // rather than a choice available here, and it is why the darkest tones a
209 + // theme can reach on 256 colors come from the gray ramp instead.
210 + const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
211 + let mut r = 0;
212 + while r < 6 {
213 + let mut g = 0;
214 + while g < 6 {
215 + let mut b = 0;
216 + while b < 6 {
217 + table[16 + 36 * r + 6 * g + b] = Rgb {
218 + r: LEVELS[r],
219 + g: LEVELS[g],
220 + b: LEVELS[b],
221 + };
222 + b += 1;
223 + }
224 + g += 1;
225 + }
226 + r += 1;
227 + }
228 +
229 + // 8 to 238 in steps of 10. Neither end is black or white; both of those are
230 + // in the cube, so the ramp is 24 steps of gray between them rather than 24
231 + // steps of the whole range.
232 + let mut k = 0;
233 + while k < 24 {
234 + let v = 8 + 10 * k as u8;
235 + table[232 + k as usize] = Rgb { r: v, g: v, b: v };
236 + k += 1;
237 + }
238 +
239 + table
240 + }
241 +
242 + /// The contrast ratio two colors must clear to read as separate areas.
243 + ///
244 + /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
245 + /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
246 + /// text can ask for more by checking [`wcag_contrast`] itself.
247 + pub const DISTINCT: f32 = 3.0;
248 +
249 + /// Perceptual distance between two colors, for choosing the closest of a set.
250 + fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
251 + let (x, y) = (a.to_oklab(), b.to_oklab());
252 + ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
253 + }
254 +
255 + /// Index of the entry in `palette` that looks most like `c`.
256 + ///
257 + /// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
258 + /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
259 + /// nearest match computed in it picks visibly wrong entries in the mid tones.
260 + ///
261 + /// # Panics
262 + ///
263 + /// If `palette` is empty.
264 + pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
265 + assert!(!palette.is_empty(), "a palette needs at least one color");
266 + let mut best = 0;
267 + let mut best_distance = f32::INFINITY;
268 + for (index, entry) in palette.iter().enumerate() {
269 + let distance = oklab_distance(c, *entry);
270 + if distance < best_distance {
271 + best = index;
272 + best_distance = distance;
273 + }
274 + }
275 + best
276 + }
277 +
278 + /// Index of the entry in `palette` closest to `fg` that still reads against
279 + /// `bg`.
280 + ///
281 + /// [`quantize`] answers about one color at a time, and two colors that differ
282 + /// can quantize to the same entry: a themed page and a border drawn on it are
283 + /// often a few steps apart in a 24-bit theme and land together on a 16-color
284 + /// terminal, leaving one flat area where there was a frame. Alloy's console
285 + /// showed exactly this, and it is not a contrived pairing: a light page and the
286 + /// mid-tone border derived from it both land on index 7.
287 + ///
288 + /// So the background is quantized first, because what the border must be
289 + /// distinguished from is the entry the terminal will actually paint, not the
290 + /// color the theme asked for. Then the nearest entry to `fg` clearing
291 + /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
292 + /// furthest does: at that point the palette cannot honor the design, and the
293 + /// most legible approximation beats the closest invisible one.
294 + ///
295 + /// Only for colors whose whole job is to be told apart from their background.
296 + /// Applied to every token it would push a deliberately quiet one until it
297 + /// shouted.
298 + ///
299 + /// # Panics
300 + ///
301 + /// If `palette` is empty.
302 + pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
303 + assert!(!palette.is_empty(), "a palette needs at least one color");
304 + let shown = palette[quantize(bg, palette)];
305 +
306 + let mut order: Vec<usize> = (0..palette.len()).collect();
307 + order.sort_by(|a, b| {
308 + oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b]))
309 + });
310 +
311 + order
312 + .iter()
313 + .copied()
314 + .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
315 + .unwrap_or_else(|| {
316 + order
317 + .iter()
318 + .copied()
319 + .max_by(|a, b| {
320 + wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown))
321 + })
322 + .expect("the palette is not empty")
323 + })
324 + }
325 +
326 + #[cfg(test)]
327 + mod tests {
328 + use super::*;
329 + use crate::color::rel_luminance;
330 + use crate::fixture::bundled;
331 + use crate::{embedded_themes, parse_theme_str, resolve};
332 +
333 + // ---- low-color terminals ----
334 +
335 + #[test]
336 + fn the_ansi_palette_is_sixteen_distinct_colors() {
337 + let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
338 + seen.sort_unstable();
339 + seen.dedup();
340 + assert_eq!(seen.len(), 16);
341 + }
342 +
343 + // ---- the intent-to-slot table ----
344 +
345 + // Sixteen slots, every one of them answered. A caller filling a terminal
346 + // palette has no fallback for a hole: the slot would keep whatever the
347 + // emulator started with, and one raw ANSI colour in a themed table is more
348 + // obviously wrong than all sixteen would be.
349 + #[test]
350 + fn every_ansi_slot_names_an_intent_on_either_polarity() {
351 + for variant in ["light", "dark", "high-contrast"] {
352 + for index in 0..16 {
353 + assert!(
354 + ansi_intent(index, variant).is_some(),
355 + "slot {index} unanswered on {variant}"
356 + );
357 + }
358 + assert_eq!(ansi_intent(16, variant), None);
359 + }
360 + }
361 +
362 + // The property the four achromatic slots exist to hold: 0 is the darkest
363 + // tone the theme offers and 15 the lightest, in either polarity. A table
364 + // that pins slot 0 to `content.primary` passes this on a light theme and
365 + // inverts on a dark one, which is the bug the polarity split fixes.
366 + #[test]
367 + fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
368 + for id in ["akari-dawn", "akari-night"] {
369 + let theme = bundled(id);
370 + let slot = |i: usize| -> Rgb {
371 + let key = ansi_intent(i, &theme.meta.variant).expect("in range");
372 + Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
373 + };
374 + assert!(
375 + rel_luminance(slot(0)) < rel_luminance(slot(15)),
376 + "{id}: ANSI 0 {} should be darker than ANSI 15 {}",
377 + slot(0).to_hex(),
378 + slot(15).to_hex(),
379 + );
380 + }
381 + }
382 +
383 + // The pair a greeter draws with: its container on 7, its text on 0. If
384 + // those collapse the login screen is one flat block, and slot 7 being a
385 + // surface rather than a text tone is what keeps them apart.
386 + #[test]
387 + fn the_container_slot_and_the_text_slot_stay_legible() {
388 + for id in ["akari-dawn", "akari-night"] {
389 + let theme = bundled(id);
390 + let slot = |i: usize| -> Rgb {
391 + let key = ansi_intent(i, &theme.meta.variant).expect("in range");
392 + Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
393 + };
394 + let contrast = wcag_contrast(slot(0), slot(7));
395 + assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
396 + }
397 + }
398 +
399 + // The hues do not move with polarity. Red is the theme's danger tone on a
400 + // light theme and on a dark one, which is why only four slots are in the
401 + // polarity table at all.
402 + #[test]
403 + fn the_chromatic_slots_do_not_vary_with_polarity() {
404 + for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
405 + assert_eq!(
406 + ansi_intent(index, "light"),
407 + ansi_intent(index, "dark"),
408 + "slot {index} moved with polarity"
409 + );
410 + }
411 + }
412 +
413 + #[test]
414 + fn quantize_picks_the_obvious_entry() {
415 + let black = Rgb { r: 0, g: 0, b: 0 };
416 + let white = Rgb {
417 + r: 255,
418 + g: 255,
419 + b: 255,
420 + };
421 + assert_eq!(quantize(black, &ANSI_16), 0);
422 + assert_eq!(quantize(white, &ANSI_16), 15);
423 + }
424 +
425 + // Nearest-entry quantization is per-color, so two colors a theme keeps
426 + // apart can arrive as one. These two are both closest to the palette's
427 + // light gray, and a border drawn in one on a page painted the other is not
428 + // drawn at all.
429 + #[test]
430 + fn two_colors_can_quantize_to_one_entry() {
431 + let page = Rgb::from_hex("#a8a8a8").unwrap();
432 + let border = Rgb::from_hex("#b4b4b4").unwrap();
433 +
434 + assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
435 + assert_ne!(
436 + quantize_against(border, page, &ANSI_16),
437 + quantize(page, &ANSI_16)
438 + );
439 + }
440 +
441 + #[test]
442 + fn quantize_against_keeps_the_border_off_the_page() {
443 + let page = Rgb::from_hex("#e4ded6").unwrap();
444 + let border = Rgb::from_hex("#7f786d").unwrap();
445 +
446 + let shown_page = ANSI_16[quantize(page, &ANSI_16)];
447 + let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
448 +
449 + assert!(
450 + wcag_contrast(shown_border, shown_page) >= DISTINCT,
451 + "border {} on page {} is {:.2}:1",
452 + shown_border.to_hex(),
453 + shown_page.to_hex(),
454 + wcag_contrast(shown_border, shown_page)
455 + );
456 + }
457 +
458 + // A color that already reads against its background is left where it is,
459 + // so this can be applied without redesigning what already worked.
460 + #[test]
461 + fn quantize_against_leaves_a_readable_color_alone() {
462 + let page = Rgb::from_hex("#e4ded6").unwrap();
463 + let text = Rgb::from_hex("#1a1816").unwrap();
464 +
465 + assert_eq!(
466 + quantize_against(text, page, &ANSI_16),
467 + quantize(text, &ANSI_16)
468 + );
469 + }
470 +
471 + // With nothing in the palette to satisfy the request, the most legible
472 + // entry is the answer. Returning the nearest one would return the
473 + // background itself, which is the failure this function exists to avoid.
474 + #[test]
475 + fn an_impossible_palette_gets_the_most_legible_entry() {
476 + let page = Rgb::from_hex("#ffffff").unwrap();
477 + let border = Rgb::from_hex("#fefefe").unwrap();
478 + let palette = [
479 + Rgb::from_hex("#ffffff").unwrap(),
480 + Rgb::from_hex("#fdfdfd").unwrap(),
481 + ];
482 +
483 + let chosen = palette[quantize_against(border, page, &palette)];
484 + assert_eq!(chosen.to_hex(), "#fdfdfd");
485 + }
486 +
487 + // What the bevel pair does on a sixteen-color terminal, measured across the
488 + // shipped set rather than assumed. Two results, both load-bearing for a
489 + // consumer that has to render one there.
490 + //
491 + // Exactly one edge survives, never both. A raised face quantizes onto one of
492 + // the palette's three grays, and the palette is too coarse to hold anything
493 + // between that entry and its neighbour, so whichever edge is pushed toward
494 + // the end of the ramp the face already sits on lands back on the face. Light
495 + // themes and most dark ones keep the shadow and lose the highlight; a face
496 + // that quantizes to black keeps the highlight and loses the shadow.
497 + //
498 + // So a low-color consumer draws the single edge it can render, on the side
499 + // the palette left it, rather than a bevel that resolves on two sides.
500 + //
Lines truncated
A src/color.rs +265
@@ -1,0 +1,265 @@
1 + //! Color math — perceptual (OKLab) derivations + WCAG contrast.
2 + //!
3 + //! Interactive states (hover/active/selection/surfaces) are derived in OKLab so
4 + //! equal steps look equal across every theme's hues (Ottosson 2020; the modern
5 + //! CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive
6 + //! luminance threshold, so the choice actually meets AA where achievable.
7 + //! This is the single source of truth shared by every product.
8 +
9 + /// An sRGB color. Hex round-trips losslessly.
10 + #[derive(Clone, Copy, Debug, PartialEq, Eq)]
11 + pub struct Rgb {
12 + pub r: u8,
13 + pub g: u8,
14 + pub b: u8,
15 + }
16 +
17 + impl Rgb {
18 + /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
19 + pub fn from_hex(s: &str) -> Option<Rgb> {
20 + let h = s.strip_prefix('#')?;
21 + let (r, g, b) = match h.len() {
22 + 6 => (
23 + u8::from_str_radix(&h[0..2], 16).ok()?,
24 + u8::from_str_radix(&h[2..4], 16).ok()?,
25 + u8::from_str_radix(&h[4..6], 16).ok()?,
26 + ),
27 + 3 => {
28 + let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
29 + (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
30 + }
31 + _ => return None,
32 + };
33 + Some(Rgb { r, g, b })
34 + }
35 +
36 + /// Lowercase `#rrggbb`.
37 + pub fn to_hex(self) -> String {
38 + format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
39 + }
40 +
41 + pub fn tuple(self) -> (u8, u8, u8) {
42 + (self.r, self.g, self.b)
43 + }
44 + }
45 +
46 + /// A color in OKLab (perceptually uniform): `l` lightness in \[0,1\], `a`/`b` opponent axes.
47 + #[derive(Clone, Copy, Debug)]
48 + pub struct Oklab {
49 + pub l: f32,
50 + pub a: f32,
51 + pub b: f32,
52 + }
53 +
54 + fn srgb_to_linear(c: u8) -> f32 {
55 + let c = c as f32 / 255.0;
56 + if c <= 0.04045 {
57 + c / 12.92
58 + } else {
59 + ((c + 0.055) / 1.055).powf(2.4)
60 + }
61 + }
62 +
63 + fn linear_to_srgb(c: f32) -> u8 {
64 + let c = c.clamp(0.0, 1.0);
65 + let v = if c <= 0.0031308 {
66 + c * 12.92
67 + } else {
68 + 1.055 * c.powf(1.0 / 2.4) - 0.055
69 + };
70 + (v * 255.0).round().clamp(0.0, 255.0) as u8
71 + }
72 +
73 + impl Rgb {
74 + /// Convert to OKLab (Ottosson's sRGB matrices).
75 + ///
76 + /// The matrix coefficients are quoted at their published precision so they
77 + /// can be diffed against the reference. `f32` rounds them at compile time;
78 + /// truncating the literals would only make them harder to check.
79 + #[allow(clippy::excessive_precision)]
80 + pub fn to_oklab(self) -> Oklab {
81 + let (r, g, b) = (
82 + srgb_to_linear(self.r),
83 + srgb_to_linear(self.g),
84 + srgb_to_linear(self.b),
85 + );
86 + let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
87 + let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
88 + let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
89 + let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
90 + Oklab {
91 + l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
92 + a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
93 + b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
94 + }
95 + }
96 +
97 + /// Convert from OKLab back to the nearest in-gamut sRGB.
98 + ///
99 + /// Published precision, as in [`Rgb::to_oklab`].
100 + #[allow(clippy::excessive_precision)]
101 + pub fn from_oklab(c: Oklab) -> Rgb {
102 + let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
103 + let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
104 + let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
105 + let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
106 + Rgb {
107 + r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
108 + g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
109 + b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
110 + }
111 + }
112 + }
113 +
114 + /// WCAG 2.x relative luminance of an sRGB color.
115 + pub(crate) fn rel_luminance(c: Rgb) -> f32 {
116 + 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
117 + }
118 +
119 + /// WCAG 2.x contrast ratio between two colors, in [1, 21].
120 + pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
121 + let (la, lb) = (rel_luminance(a), rel_luminance(b));
122 + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
123 + (hi + 0.05) / (lo + 0.05)
124 + }
125 +
126 + /// Pick black or white for legible text on `bg`, by the higher WCAG contrast
127 + /// ratio (so the choice meets AA wherever the background allows it).
128 + pub fn readable_on(bg: Rgb) -> Rgb {
129 + let white = Rgb {
130 + r: 255,
131 + g: 255,
132 + b: 255,
133 + };
134 + let black = Rgb { r: 0, g: 0, b: 0 };
135 + if wcag_contrast(white, bg) >= wcag_contrast(black, bg) {
136 + white
137 + } else {
138 + black
139 + }
140 + }
141 +
142 + /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens.
143 + pub fn lighten(c: Rgb, delta: f32) -> Rgb {
144 + let mut lab = c.to_oklab();
145 + lab.l = (lab.l + delta).clamp(0.0, 1.0);
146 + Rgb::from_oklab(lab)
147 + }
148 +
149 + /// Shift OKLab lightness down by `delta` (perceptually uniform).
150 + pub fn darken(c: Rgb, delta: f32) -> Rgb {
151 + lighten(c, -delta)
152 + }
153 +
154 + /// Interpolate between `a` and `b` by `t` in \[0,1\] in OKLab (perceptual blend).
155 + pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
156 + let (x, y) = (a.to_oklab(), b.to_oklab());
157 + Rgb::from_oklab(Oklab {
158 + l: x.l + (y.l - x.l) * t,
159 + a: x.a + (y.a - x.a) * t,
160 + b: x.b + (y.b - x.b) * t,
161 + })
162 + }
163 +
164 + #[cfg(test)]
165 + mod tests {
166 + use super::*;
167 +
168 + // ---- color math (formulas must match the apps they came from) ----
169 +
170 + #[test]
171 + fn rgb_hex_roundtrip() {
172 + assert_eq!(
173 + Rgb::from_hex("#6196FF").unwrap(),
174 + Rgb {
175 + r: 0x61,
176 + g: 0x96,
177 + b: 0xff
178 + }
179 + );
180 + assert_eq!(
181 + Rgb::from_hex("#abc").unwrap(),
182 + Rgb {
183 + r: 0xaa,
184 + g: 0xbb,
185 + b: 0xcc
186 + }
187 + );
188 + assert_eq!(
189 + Rgb {
190 + r: 0x61,
191 + g: 0x96,
192 + b: 0xff
193 + }
194 + .to_hex(),
195 + "#6196ff"
196 + );
197 + assert!(Rgb::from_hex("not-a-color").is_none());
198 + }
199 +
200 + #[test]
201 + fn oklab_roundtrips_within_tolerance() {
202 + for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
203 + let c = Rgb::from_hex(hex).unwrap();
204 + let back = Rgb::from_oklab(c.to_oklab());
205 + // Gamut round-trip is near-exact (±1 per channel from rounding).
206 + assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
207 + assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
208 + assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
209 + }
210 + }
211 +
212 + #[test]
213 + fn wcag_contrast_known_pairs() {
214 + let white = Rgb {
215 + r: 255,
216 + g: 255,
217 + b: 255,
218 + };
219 + let black = Rgb { r: 0, g: 0, b: 0 };
220 + assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
221 + assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
222 + }
223 +
224 + #[test]
225 + fn readable_on_picks_by_wcag() {
226 + assert_eq!(
227 + readable_on(Rgb {
228 + r: 255,
229 + g: 255,
230 + b: 255
231 + }),
232 + Rgb { r: 0, g: 0, b: 0 }
233 + );
234 + assert_eq!(
235 + readable_on(Rgb { r: 0, g: 0, b: 0 }),
236 + Rgb {
237 + r: 255,
238 + g: 255,
239 + b: 255
240 + }
241 + );
242 + // A light blue action -> black text reads better.
243 + let action = Rgb::from_hex("#6196ff").unwrap();
244 + assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
245 + }
246 +
247 + #[test]
248 + fn lighten_darken_move_oklab_lightness() {
249 + let c = Rgb::from_hex("#6196ff").unwrap();
250 + let l0 = c.to_oklab().l;
251 + assert!(lighten(c, 0.05).to_oklab().l > l0);
252 + assert!(darken(c, 0.05).to_oklab().l < l0);
253 + }
254 +
255 + #[test]
256 + fn mix_endpoints_and_midpoint() {
257 + let a = Rgb::from_hex("#000000").unwrap();
258 + let b = Rgb::from_hex("#6196ff").unwrap();
259 + assert_eq!(mix(a, b, 0.0), a);
260 + assert_eq!(mix(a, b, 1.0), b);
261 + // Midpoint sits between the endpoints in OKLab lightness.
262 + let mid = mix(a, b, 0.5).to_oklab().l;
263 + assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
264 + }
265 + }
A src/dirs.rs +323
@@ -1,0 +1,323 @@
1 + //! Where themes are looked for.
2 + //!
3 + //! Four apps built this vector by hand, two of them byte-for-byte identically,
4 + //! and one of them built it backwards: the Alloy console pushed the user's own
5 + //! directory first, under a comment saying "highest precedence first", when both
6 + //! consumers of the vector resolve *last* wins. A user's custom theme lost to
7 + //! the packaged one of the same id.
8 + //!
9 + //! Hence a builder that names the tiers rather than a function taking a vector.
10 + //! The precedence is stated once, here, and a caller cannot express it backwards
11 + //! because the order is not theirs to choose.
12 +
13 + use std::path::{Path, PathBuf};
14 +
15 + // Names this module's prose links to, resolved for rustdoc.
16 + #[allow(unused_imports)]
17 + use crate::{derive_tonal_steps, list_themes_from_dirs, load_theme};
18 +
19 + /// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take.
20 + ///
21 + /// Tiers are added in whatever order is convenient and always end up in
22 + /// precedence order: the user's own themes win, then whatever the system
23 + /// ships, then whatever the app bundles.
24 + ///
25 + /// A directory that does not exist is dropped rather than carried, so callers
26 + /// can offer every tier they might have without checking each one.
27 + #[derive(Debug, Default, Clone)]
28 + pub struct ThemeDirs {
29 + bundled: Vec<PathBuf>,
30 + system: Vec<PathBuf>,
31 + custom: Option<PathBuf>,
32 + }
33 +
34 + impl ThemeDirs {
35 + #[must_use]
36 + pub fn new() -> Self {
37 + Self::default()
38 + }
39 +
40 + /// Themes the app ships with. Lowest precedence.
41 + ///
42 + /// Takes more than one because a Tauri app has two: the bundled resource
43 + /// directory in production, and the tree `build.rs` materialized for a
44 + /// `cargo run` that has no resource directory at all.
45 + #[must_use]
46 + pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
47 + self.bundled.extend(dir);
48 + self
49 + }
50 +
51 + /// Themes the machine ships, from an image or a package. Overrides bundled.
52 + #[must_use]
53 + pub fn system(mut self, dir: Option<PathBuf>) -> Self {
54 + self.system.extend(dir);
55 + self
56 + }
57 +
58 + /// The user's own themes. Highest precedence, and the only tier flagged
59 + /// custom, which is what makes them exportable and deletable.
60 + #[must_use]
61 + pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
62 + self.custom = dir;
63 + self
64 + }
65 +
66 + /// The search path, lowest precedence first.
67 + #[must_use]
68 + pub fn build(self) -> Vec<(PathBuf, bool)> {
69 + let mut dirs = Vec::new();
70 + for dir in self.bundled.into_iter().chain(self.system) {
71 + if dir.is_dir() {
72 + dirs.push((dir, false));
73 + }
74 + }
75 + if let Some(dir) = self.custom
76 + && dir.is_dir()
77 + {
78 + dirs.push((dir, true));
79 + }
80 + dirs
81 + }
82 + }
83 +
84 + /// Find a theme file by ID in the given directories.
85 + ///
86 + /// Checks directories in reverse order so the highest-priority directory wins.
87 + /// Returns `(path, is_custom)` or `None` if not found.
88 + pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
89 + let filename = format!("{id}.toml");
90 +
91 + for (dir, is_custom) in dirs.iter().rev() {
92 + let path = dir.join(&filename);
93 + if path.is_file() {
94 + return Some((path, *is_custom));
95 + }
96 + }
97 +
98 + None
99 + }
100 +
101 + /// The themes this crate ships, embedded at compile time.
102 + ///
103 + /// `include_dir` is an implementation detail: the public API hands back plain
104 + /// `(id, toml_source)` pairs, so how the data is embedded can change without
105 + /// a breaking release.
106 + static EMBEDDED: include_dir::Dir<'static> =
107 + include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
108 +
109 + /// The themes this crate ships, as `(id, toml_source)` pairs.
110 + ///
111 + /// This is the path-free way to reach the bundled set, for consumers that
112 + /// cannot rely on a directory existing at runtime: a crate pulled from
113 + /// crates.io lives in a registry checkout whose location is not knowable at
114 + /// compile time, so `include_dir!` and asset-bundling globs in the depending
115 + /// crate have nothing stable to point at. Embedding here and re-exporting the
116 + /// contents gives them one source of truth without a path.
117 + ///
118 + /// Ordering follows the embedded directory and is not guaranteed; collect and
119 + /// sort by id where a stable order matters (a theme picker, say).
120 + pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
121 + EMBEDDED.files().filter_map(|file| {
122 + let path = file.path();
123 + if path.extension().and_then(|e| e.to_str()) != Some("toml") {
124 + return None;
125 + }
126 + let id = path.file_stem()?.to_str()?;
127 + Some((id, file.contents_utf8()?))
128 + })
129 + }
130 +
131 + /// The theme directory this crate ships, for use as a build-from-source
132 + /// fallback.
133 + ///
134 + /// Resolves against `makeover`'s own manifest directory, fixed at compile
135 + /// time, so it works from a path dependency and from a cargo git checkout
136 + /// alike. Installed systems should put their packaged theme directory ahead
137 + /// of this in the search path; this is the entry that keeps `cargo run` in a
138 + /// fresh clone from coming up with no themes at all.
139 + ///
140 + /// Returns `None` when the directory is absent — a cargo cache that has been
141 + /// cleaned, or a vendored copy that dropped the data — so callers degrade to
142 + /// their remaining search path rather than failing.
143 + pub fn bundled_themes_dir() -> Option<PathBuf> {
144 + let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
145 + if themes.is_dir() { Some(themes) } else { None }
146 + }
147 +
148 + #[cfg(test)]
149 + mod tests {
150 + use super::*;
151 + use crate::parse_theme_str;
152 + use std::fs;
153 +
154 + // The bug this builder exists to prevent: the Alloy console pushed the
155 + // user's directory first under a comment reading "highest precedence
156 + // first", when both consumers of this vector resolve last-wins. A custom
157 + // theme lost to the packaged one of the same id.
158 + #[test]
159 + fn the_users_own_themes_outrank_everything() {
160 + let root = tempfile::tempdir().unwrap();
161 + let make = |name: &str| {
162 + let dir = root.path().join(name);
163 + std::fs::create_dir_all(&dir).unwrap();
164 + dir
165 + };
166 + let (bundled, system, custom) = (make("bundled"), make("system"), make("custom"));
167 +
168 + let dirs = ThemeDirs::new()
169 + .custom(Some(custom.clone()))
170 + .bundled(Some(bundled.clone()))
171 + .system(Some(system.clone()))
172 + .build();
173 +
174 + assert_eq!(
175 + dirs,
176 + vec![(bundled, false), (system, false), (custom.clone(), true)],
177 + "lowest precedence first, whatever order the tiers were added in",
178 + );
179 + assert!(dirs.last().unwrap().1, "only the user's tier is custom");
180 +
181 + // And the ordering means what the consumers think it means.
182 + for dir in dirs.iter().map(|(dir, _)| dir) {
183 + std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
184 + }
185 + assert_eq!(
186 + find_theme_path(&dirs, "shared").unwrap().0,
187 + custom.join("shared.toml"),
188 + "the user's copy is the one that loads",
189 + );
190 + }
191 +
192 + #[test]
193 + fn a_directory_that_does_not_exist_is_dropped() {
194 + let root = tempfile::tempdir().unwrap();
195 + let real = root.path().join("real");
196 + std::fs::create_dir_all(&real).unwrap();
197 +
198 + let dirs = ThemeDirs::new()
199 + .bundled(Some(root.path().join("nope")))
200 + .system(None)
201 + .custom(Some(real.clone()))
202 + .build();
203 +
204 + assert_eq!(dirs, vec![(real, true)]);
205 + }
206 +
207 + // A Tauri app has two bundled tiers: the resource dir in production and the
208 + // tree build.rs materialized for a dev run with no resource dir.
209 + #[test]
210 + fn more_than_one_bundled_tier_is_allowed() {
211 + let root = tempfile::tempdir().unwrap();
212 + let (first, second) = (root.path().join("a"), root.path().join("b"));
213 + std::fs::create_dir_all(&first).unwrap();
214 + std::fs::create_dir_all(&second).unwrap();
215 +
216 + let dirs = ThemeDirs::new()
217 + .bundled(Some(first.clone()))
218 + .bundled(Some(second.clone()))
219 + .build();
220 + assert_eq!(dirs, vec![(first, false), (second, false)]);
221 + }
222 +
223 + #[test]
224 + fn find_theme_path_reverse_priority() {
225 + let d1 = tempfile::tempdir().unwrap();
226 + let d2 = tempfile::tempdir().unwrap();
227 + fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap();
228 + fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap();
229 + let dirs = vec![
230 + (d1.path().to_path_buf(), false),
231 + (d2.path().to_path_buf(), true),
232 + ];
233 + let (path, is_custom) = find_theme_path(&dirs, "s").unwrap();
234 + assert!(is_custom);
235 + assert_eq!(path, d2.path().join("s.toml"));
236 + }
237 +
238 + #[test]
239 + fn bundled_themes_dir_resolves_to_shipped_themes() {
240 + // The crate ships its themes, so this must resolve in-tree and the
241 + // Akari defaults the console falls back to must be present.
242 + let dir = bundled_themes_dir().expect("makeover ships a themes/ directory");
243 + assert!(dir.join("akari-dawn.toml").is_file());
244 + assert!(dir.join("akari-night.toml").is_file());
245 + }
246 +
247 + #[test]
248 + fn every_theme_is_accounted_for_in_third_party_notices() {
249 + // Attribution is a redistribution obligation, not a nicety: adding a
250 + // theme without a notice entry silently ships someone's work
251 + // uncredited. Fail here instead.
252 + let notices = std::fs::read_to_string(
253 + Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"),
254 + )
255 + .expect("THIRD-PARTY-NOTICES.md must exist");
256 + let missing: Vec<&str> = embedded_themes()
257 + .map(|(id, _)| id)
258 + .filter(|id| !notices.contains(*id))
259 + .collect();
260 + assert!(
261 + missing.is_empty(),
262 + "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}"
263 + );
264 + }
265 +
266 + #[test]
267 + fn adapted_themes_carry_inline_attribution() {
268 + // Each adapted file must name its upstream in-file, so the credit
269 + // survives someone copying a single .toml out of the crate.
270 + const ORIGINALS: [&str; 5] = [
271 + "makenotwork",
272 + "goingson",
273 + "audiofiles",
274 + "high-contrast",
275 + "neobrute",
276 + ];
277 + for (id, source) in embedded_themes() {
278 + if ORIGINALS.contains(&id) {
279 + continue;
280 + }
281 + assert!(
282 + source.contains("adapted from"),
283 + "adapted theme `{id}` is missing its inline attribution header"
284 + );
285 + }
286 + }
287 +
288 + #[test]
289 + fn embedded_themes_match_the_directory() {
290 + // The embedded copy and themes/ are two views of one source. If they
291 + // ever disagree, path-based and path-free consumers render different
292 + // theme sets, which is exactly the drift shipping the data was meant
293 + // to prevent.
294 + let dir = bundled_themes_dir().unwrap();
295 + let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
296 + .unwrap()
297 + .filter_map(|e| {
298 + let path = e.ok()?.path();
299 + if path.extension()? != "toml" {
300 + return None;
301 + }
302 + Some(path.file_stem()?.to_str()?.to_string())
303 + })
304 + .collect();
305 + let mut embedded: Vec<String> = embedded_themes().map(|(id, _)| id.to_string()).collect();
306 + on_disk.sort();
307 + embedded.sort();
308 + assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/");
309 + }
310 +
311 + #[test]
312 + fn every_embedded_theme_parses() {
313 + // Guards the path-free consumers (MNW server, the Tauri build steps)
314 + // the same way every_shipped_theme_loads guards the path-based ones.
315 + let mut count = 0;
316 + for (id, source) in embedded_themes() {
317 + parse_theme_str(id, source, false)
318 + .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}"));
319 + count += 1;
320 + }
321 + assert!(count >= 30, "expected the full theme set, got {count}");
322 + }
323 + }
@@ -1,0 +1,182 @@
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 + }
@@ -1,0 +1,47 @@
1 + //! Theme sources the tests of several modules share.
2 +
3 + use crate::{ThemeColors, bundled_themes_dir, load_theme};
4 +
5 + pub(crate) fn bundled(id: &str) -> ThemeColors {
6 + let dir = bundled_themes_dir().expect("makeover ships its themes");
7 + load_theme(&[(dir, false)], id).expect("the akari pair ships")
8 + }
9 +
10 + pub(crate) fn nord_toml() -> &'static str {
11 + r##"
12 + [meta]
13 + name = "Nord"
14 + variant = "dark"
15 +
16 + [surface]
17 + page = "#2e3440"
18 + raised = "#3b4252"
19 + sunken = "#434c5e"
20 + overlay = "#3b4252"
21 +
22 + [content]
23 + primary = "#d8dee9"
24 + secondary = "#e5e9f0"
25 + muted = "#616e88"
26 +
27 + [action]
28 + primary = "#81a1c1"
29 +
30 + [status]
31 + danger = "#bf616a"
32 + success = "#a3be8c"
33 + warning = "#ebcb8b"
34 + info = "#88c0d0"
35 +
36 + [line]
37 + border = "#4c566a"
38 +
39 + [category]
40 + one = "#bf616a"
41 + two = "#a3be8c"
42 + three = "#81a1c1"
43 + four = "#ebcb8b"
44 + five = "#b48ead"
45 + six = "#88c0d0"
46 + "##
47 + }
A src/font.rs +500
@@ -1,0 +1,651 @@
1 + //! Typography — layer 0, the app override.
2 + //!
3 + //! Wiki `typography-standard`, GO makeover `174ab3c1`. Layer 1 above is what
4 + //! every product shares; this is the one declaration a product is allowed to
5 + //! make for itself:
6 + //!
7 + //! ```text
8 + //! layer 0 app override per product, optional MNW display -> Young Serif
9 + //! layer 1 house default the quasi-* slot font quasi-mono -> Quasi Mono
10 + //! layer 2 system generic one hop, no further monospace / sans-serif
11 + //! ```
12 + //!
13 + //! The brand tier was already exempt by decision (`cdf8ac09`), and the exemption
14 + //! was enforced by those faces simply not being in the vocabulary — so each
15 + //! product reached its own face through a hardcoded `font-family` and an
16 + //! `@font-face` block it maintained by hand, which is the exact shape the
17 + //! unification is deleting everywhere else. This turns the carve-out into a
18 + //! mechanism: the per-product face is declared once, in the build script that
19 + //! already writes the typography layer, and is readable as an override rather
20 + //! than as a stylesheet nobody unified.
21 + //!
22 + //! It permits overriding `mono` and `sans` too. No product wants that today,
23 + //! and a layer that only allows overriding the slot nobody describes is not a
24 + //! layer, it is the exemption restated.
25 + //!
26 + //! **One declaration per product per slot.** [`Typography::with_override`]
27 + //! panics on a second override of the same slot rather than letting the last
28 + //! one win: a product with two answers for a slot has the vocabulary wrong, and
29 + //! that is the thing to fix.
30 + //!
31 + //! # What a renderer does when it cannot honour one
32 + //!
33 + //! Declare once, renderers honour what they can. Today only the webview surface
34 + //! has a face to honour at all — neither `makeover-tui` nor `makeover-immediate`
35 + //! emits a `font-family` from anywhere, because the terminal owns the face in
36 + //! one and the app loads its own font stack in the other. So an override is
37 + //! honoured by the generated stylesheet and ignored, silently and correctly, by
38 + //! the other two. That last clause was too strong and 2.10.0 corrected it: egui
39 + //! can reach a face perfectly well, it just needs the file rather than a stack.
40 + //! audiofiles honours its override with no stylesheet anywhere in the path. A renderer that gains font control later reads
41 + //! [`Typography::resolve`] rather than the CSS, which is why the resolution is
42 + //! a method on the data and not a string-building detail. Loading a file needs
43 + //! one thing more than the stack — the family name and the source to load it
44 + //! from — so [`Typography::faces`] is the same data read the other way, and
45 + //! between them an egui or TUI surface can honour an override without a
46 + //! stylesheet anywhere in the path. audiofiles is the first to do it.
47 +
48 + use crate::{
49 + FONT_MONO, FONT_SANS, HOUSE_MONO_FAMILY, HOUSE_SANS_FAMILY, HOUSE_WEIGHT_RANGE,
50 + WEBFONT_MONO_FILE, WEBFONT_SANS_FILE, font_face_css,
51 + };
52 +
53 + // Names this module's prose links to, resolved for rustdoc.
54 + #[allow(unused_imports)]
55 + use crate::typography_css_vars;
56 +
57 + /// A slot in the house font vocabulary — the unit an override replaces.
58 + ///
59 + /// Three, and the third is deliberately empty by default: `display` is the
60 + /// brand tier, it has no house answer, and a product that does not override it
61 + /// leaves the token undefined so whatever the consumer wrote as a fallback
62 + /// renders. The MNW embeds rely on exactly that.
63 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64 + pub enum FontSlot {
65 + /// Code, data, identifiers, cell grids. [`FONT_MONO`] by default.
66 + Mono,
67 + /// Body and UI text: everything that is not mono or brand. [`FONT_SANS`].
68 + Sans,
69 + /// The brand / display tier. No house default.
70 + Display,
71 + }
72 +
73 + impl FontSlot {
74 + /// Every slot, in the order they are emitted.
75 + pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display];
76 +
77 + /// The custom property this slot is read through.
78 + pub fn token(self) -> &'static str {
79 + match self {
80 + FontSlot::Mono => "--font-mono",
81 + FontSlot::Sans => "--font-sans",
82 + FontSlot::Display => "--font-display",
83 + }
84 + }
85 +
86 + /// The house stack, or `None` for the brand tier.
87 + pub fn house_default(self) -> Option<&'static str> {
88 + match self {
89 + FontSlot::Mono => Some(FONT_MONO),
90 + FontSlot::Sans => Some(FONT_SANS),
91 + FontSlot::Display => None,
92 + }
93 + }
94 +
95 + /// The house face behind that stack, or `None` for the brand tier.
96 + ///
97 + /// The counterpart of [`house_default`](Self::house_default), and the same
98 + /// split as [`Typography::resolve`] against [`Typography::faces`]: one
99 + /// names the family that wins, the other names the file behind it. The
100 + /// house tier was a format string until this existed, so it could be
101 + /// emitted and not read — which made [`Typography::faces`] answer for the
102 + /// brand tier and stay silent about the other two.
103 + ///
104 + /// The sources are the **web** copies. A native loader wants a `ttf` and
105 + /// cuts its own through `quasi-type`, whose `cut_native` writes the file
106 + /// and hands back the family, style and default weight to register it
107 + /// under; there is no house `ttf` named here, and there should not be. This
108 + /// crate is on crates.io and `quasi-type` is `publish = false`, so naming a
109 + /// native file here would assert a path the web build does not write and
110 + /// save the consumer nothing, since it still has to run the pipeline.
111 + ///
112 + /// One hazard travels with that arrangement and is not solved: a native
113 + /// consumer takes `quasi-type` as a git dep and pins a rev, so a stale pin
114 + /// ships an older glyph set silently. Advance it deliberately.
115 + pub fn house_face(self) -> Option<FontFace> {
116 + let (family, file) = match self {
117 + FontSlot::Mono => (HOUSE_MONO_FAMILY, WEBFONT_MONO_FILE),
118 + FontSlot::Sans => (HOUSE_SANS_FAMILY, WEBFONT_SANS_FILE),
119 + FontSlot::Display => return None,
120 + };
121 + Some(
122 + FontFace::new(family, [file])
123 + .with_weight(HOUSE_WEIGHT_RANGE)
124 + .with_style("normal"),
125 + )
126 + }
127 + }
128 +
129 + /// One `@font-face` an override brings with it.
130 + ///
131 + /// A product overriding a slot usually has to ship the face too, and the two
132 + /// halves have to agree on a family name. Declaring them together is what
133 + /// makes that agreement structural rather than a string typed twice.
134 + #[derive(Debug, Clone)]
135 + pub struct FontFace {
136 + family: String,
137 + sources: Vec<String>,
138 + weight: Option<String>,
139 + style: Option<String>,
140 + }
141 +
142 + impl FontFace {
143 + /// A face named `family`, fetched from `sources`.
144 + ///
145 + /// Each source is either a bare filename, resolved against the
146 + /// [`Typography`] base URL, or an absolute one (`/…` or `https://…`) taken
147 + /// as written. The `format()` hint is inferred from the extension —
148 + /// `woff2`, `woff`, `ttf`, `otf` — and omitted for anything else rather
149 + /// than guessed, since a wrong hint is worse than none.
150 + pub fn new<S: Into<String>>(
151 + family: impl Into<String>,
152 + sources: impl IntoIterator<Item = S>,
153 + ) -> Self {
154 + Self {
155 + family: family.into(),
156 + sources: sources.into_iter().map(Into::into).collect(),
157 + weight: None,
158 + style: None,
159 + }
160 + }
161 +
162 + /// `font-weight`, as CSS writes it: `"700"`, or `"200 800"` for a variable
163 + /// axis. Omitted when unset, which means `normal`.
164 + ///
165 + /// A variable face MUST name its range here for the same reason the house
166 + /// faces do: a `@font-face` with no range makes the browser resolve every
167 + /// weight to the file's default instance.
168 + #[must_use]
169 + pub fn with_weight(mut self, weight: impl Into<String>) -> Self {
170 + self.weight = Some(weight.into());
171 + self
172 + }
173 +
174 + /// `font-style`. Omitted when unset, which means `normal`.
175 + #[must_use]
176 + pub fn with_style(mut self, style: impl Into<String>) -> Self {
177 + self.style = Some(style.into());
178 + self
179 + }
180 +
181 + /// The declared `font-weight`, or `None` when the face never named one.
182 + ///
183 + /// A renderer loading a variable face directly has to name a weight — the
184 + /// file's own default instance is whatever the base shipped, which for the
185 + /// house faces is ExtraLight — so this is the half of the declaration that
186 + /// stops the load from being a guess.
187 + pub fn weight(&self) -> Option<&str> {
188 + self.weight.as_deref()
189 + }
190 +
191 + /// The declared `font-style`, or `None`, which means `normal`.
192 + pub fn style(&self) -> Option<&str> {
193 + self.style.as_deref()
194 + }
195 +
196 + /// The family name, as the stack has to spell it.
197 + ///
198 + /// For a renderer that loads faces rather than emitting CSS this is the
199 + /// name it registers the file under, and reading it here is what keeps
200 + /// that name from being typed a second time.
201 + pub fn family(&self) -> &str {
202 + &self.family
203 + }
204 +
205 + /// The sources, unresolved — bare filenames as they were declared, not
206 + /// joined to any base URL. A renderer loading from disk or from an
207 + /// `include_bytes!` wants the filename; only the CSS wants the URL.
208 + pub fn sources(&self) -> &[String] {
209 + &self.sources
210 + }
211 +
212 + pub(crate) fn css(&self, base: &str) -> String {
213 + use std::fmt::Write as _;
214 +
215 + let src = self
216 + .sources
217 + .iter()
218 + .map(|s| {
219 + let url = if s.starts_with('/') || s.contains("://") {
220 + s.clone()
221 + } else {
222 + format!("{base}/{s}")
223 + };
224 + match font_format(s) {
225 + Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"),
226 + None => format!("url(\"{url}\")"),
227 + }
228 + })
229 + .collect::<Vec<_>>()
230 + .join(",\n ");
231 +
232 + let mut out = format!(
233 + "@font-face {{\n font-family: \"{}\";\n src: {src};\n",
234 + self.family
235 + );
236 + if let Some(w) = &self.weight {
237 + let _ = writeln!(out, " font-weight: {w};");
238 + }
239 + if let Some(s) = &self.style {
240 + let _ = writeln!(out, " font-style: {s};");
241 + }
242 + out.push_str(" font-display: swap;\n}\n\n");
243 + out
244 + }
245 + }
246 +
247 + /// The `format()` hint for a source, by extension. `None` when unrecognised.
248 + fn font_format(source: &str) -> Option<&'static str> {
249 + match source.rsplit('.').next()?.to_ascii_lowercase().as_str() {
250 + "woff2" => Some("woff2"),
251 + "woff" => Some("woff"),
252 + "ttf" => Some("truetype"),
253 + "otf" => Some("opentype"),
254 + _ => None,
255 + }
256 + }
257 +
258 + /// One product's answer for one slot: the stack, and any faces it ships.
259 + #[derive(Debug, Clone)]
260 + pub struct FontOverride {
261 + slot: FontSlot,
262 + stack: String,
263 + faces: Vec<FontFace>,
264 + }
265 +
266 + impl FontOverride {
267 + /// Point `slot` at `stack`.
268 + ///
269 + /// `stack` is the CSS value the token takes, written the way the house
270 + /// stacks are: the family, then one hop to a system generic. Layer 2 is
271 + /// still one hop and no further — an override is a different answer to the
272 + /// slot, not a licence to write the fallback chain the standard deleted.
273 + pub fn new(slot: FontSlot, stack: impl Into<String>) -> Self {
274 + Self {
275 + slot,
276 + stack: stack.into(),
277 + faces: Vec::new(),
278 + }
279 + }
280 +
281 + /// Ship a face with the override.
282 + #[must_use]
283 + pub fn with_face(mut self, face: FontFace) -> Self {
284 + self.faces.push(face);
285 + self
286 + }
287 +
288 + /// The slot this answers.
289 + pub fn slot(&self) -> FontSlot {
290 + self.slot
291 + }
292 +
293 + /// The stack it resolves to.
294 + pub fn stack(&self) -> &str {
295 + &self.stack
296 + }
297 +
298 + /// The faces it ships, in declaration order.
299 + pub fn faces(&self) -> &[FontFace] {
300 + &self.faces
301 + }
302 + }
303 +
304 + /// The whole typography layer for one product: the house defaults, plus
305 + /// whatever it overrides.
306 + ///
307 + /// This is what a build script composes and what
308 + /// `makeover_build::typography_css_from` writes. [`typography_css_vars`] and
309 + /// [`font_face_css`] are the no-override case of it and stay for callers that
310 + /// have nothing to declare.
311 + #[derive(Debug, Clone)]
312 + pub struct Typography {
313 + base_url: String,
314 + overrides: Vec<FontOverride>,
315 + }
316 +
317 + impl Typography {
318 + /// The house layer alone, fetching faces from `base_url` — the directory
319 + /// the consumer serves fonts from, with or without a trailing slash.
320 + pub fn house(base_url: impl Into<String>) -> Self {
321 + Self {
322 + base_url: base_url.into(),
323 + overrides: Vec::new(),
324 + }
325 + }
326 +
327 + /// Add one product override.
328 + ///
329 + /// # Panics
330 + ///
331 + /// If the slot is already overridden. One declaration per product per
332 + /// slot: a second is not a merge to resolve, it is two answers to a
333 + /// question that has one, and the vocabulary is what wants fixing.
334 + #[must_use]
335 + pub fn with_override(mut self, ov: FontOverride) -> Self {
336 + assert!(
337 + !self.overrides.iter().any(|o| o.slot == ov.slot),
338 + "{} is overridden twice; one declaration per product per slot",
339 + ov.slot.token()
340 + );
341 + self.overrides.push(ov);
342 + self
343 + }
344 +
345 + /// What `slot` resolves to under this layer, or `None` for a brand slot
346 + /// nobody overrode.
347 + ///
348 + /// The resolution, for a renderer that has a face to choose rather than a
349 + /// stylesheet to emit.
350 + pub fn resolve(&self, slot: FontSlot) -> Option<&str> {
351 + self.overrides
352 + .iter()
353 + .find(|o| o.slot == slot)
354 + .map(|o| o.stack.as_str())
355 + .or_else(|| slot.house_default())
356 + }
357 +
358 + /// The faces a product ships for `slot`, in declaration order, or an
359 + /// empty slice for a slot it did not override.
360 + ///
361 + /// The other half of [`resolve`](Self::resolve), for a renderer that has
362 + /// to load a file rather than name a stack: `resolve` says which family
363 + /// wins, this says where the bytes come from, what to call them, and at
364 + /// what weight. The
365 + /// house faces are not here — they belong to the slot rather than to any
366 + /// one product, and [`FontSlot::house_face`] is where they answer.
367 + pub fn faces(&self, slot: FontSlot) -> &[FontFace] {
368 + self.overrides
369 + .iter()
370 + .find(|o| o.slot == slot)
371 + .map_or(&[], |o| o.faces())
372 + }
373 +
374 + /// The `@font-face` rules: the two house faces, then each override's.
375 + pub fn font_face_css(&self) -> String {
376 + let base = self.base_url.trim_end_matches('/');
377 + let mut out = font_face_css(base);
378 + for ov in &self.overrides {
379 + for face in &ov.faces {
380 + out.push_str(&face.css(base));
381 + }
382 + }
383 + out
384 + }
385 +
386 + /// The resolved tokens as CSS declarations, no selector.
387 + pub fn css_declarations(&self) -> String {
388 + use std::fmt::Write as _;
389 +
390 + let mut out = String::new();
391 + for slot in FontSlot::ALL {
392 + if let Some(stack) = self.resolve(slot) {
393 + let _ = writeln!(out, " {}: {stack};", slot.token());
394 + }
395 + }
396 + out
397 + }
398 +
399 + /// The resolved tokens as a `:root { … }` block.
400 + pub fn css_vars(&self) -> String {
401 + format!(":root {{\n{}}}\n", self.css_declarations())
402 + }
403 +
404 + /// Faces then tokens, in the order a stylesheet wants them.
405 + pub fn css(&self) -> String {
406 + format!("{}{}", self.font_face_css(), self.css_vars())
407 + }
408 + }
409 +
410 + #[cfg(test)]
411 + mod tests {
412 + use super::*;
413 +
414 + // ---- typography, layer 0 ----
415 +
416 + /// The live case: MNW's Young Serif, which reached the page through a
417 + /// hand-maintained `@font-face` and a `--font-heading` nothing else knew
418 + /// about.
419 + fn young_serif() -> FontOverride {
420 + FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
421 + .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"]))
422 + }
423 +
424 + #[test]
425 + fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() {
426 + let t = Typography::house("/static/fonts");
427 + assert_eq!(t.font_face_css(), font_face_css("/static/fonts"));
428 + assert_eq!(t.css_vars(), typography_css_vars());
429 + }
430 +
431 + #[test]
432 + fn an_unoverridden_display_slot_defines_no_token_at_all() {
433 + // Not "defined empty": undefined, so the consumer's own fallback in
434 + // `var(--font-display, …)` renders. The MNW embeds depend on it.
435 + let t = Typography::house("fonts");
436 + assert!(!t.css_vars().contains("--font-display"));
437 + assert_eq!(t.resolve(FontSlot::Display), None);
438 + assert_eq!(t.css_vars().matches("--font-").count(), 2);
439 + }
440 +
441 + #[test]
442 + fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() {
443 + let t = Typography::house("/static/fonts").with_override(young_serif());
444 +
445 + assert!(
446 + t.css_vars()
447 + .contains(" --font-display: \"Young Serif\", serif;\n")
448 + );
449 + assert!(
450 + t.css_vars()
451 + .contains(" --font-mono: \"Quasi Mono\", monospace;\n")
452 + );
453 + assert!(
454 + t.css_vars()
455 + .contains(" --font-sans: \"Quasi Body\", sans-serif;\n")
456 + );
457 + assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif"));
458 +
459 + let faces = t.font_face_css();
460 + assert_eq!(faces.matches("@font-face").count(), 3);
461 + assert!(faces.contains("font-family: \"Young Serif\";"));
462 + assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")"));
463 + assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
464 +
465 + // The house faces still come first, so a product face never shadows a
466 + // slot it did not claim.
467 + assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap());
468 + }
469 +
470 + #[test]
471 + fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() {
472 + // Nobody wants this today. A layer that only permits overriding the
473 + // slot nobody describes is the exemption restated, not a layer.
474 + let t = Typography::house("fonts").with_override(FontOverride::new(
475 + FontSlot::Mono,
476 + "\"Departure Mono\", monospace",
477 + ));
478 +
479 + assert!(
480 + t.css_vars()
481 + .contains(" --font-mono: \"Departure Mono\", monospace;\n")
482 + );
483 + assert!(!t.css_vars().contains("Quasi Mono"));
484 + assert_eq!(t.css_vars().matches("--font-").count(), 2);
485 + }
486 +
487 + #[test]
488 + #[should_panic(expected = "--font-display is overridden twice")]
489 + fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() {
490 + let _ = Typography::house("fonts")
491 + .with_override(young_serif())
492 + .with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif"));
493 + }
494 +
495 + #[test]
496 + fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() {
497 + let t = Typography::house("/static/fonts").with_override(
498 + FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face(
499 + FontFace::new(
500 + "Reglo",
Lines truncated
A src/intent.rs +500
@@ -1,0 +1,698 @@
1 + //! Intent resolution
2 +
3 + use crate::{Rgb, ThemeColors, ThemeMeta, darken, lighten, readable_on};
4 + use serde::Serialize;
5 + use std::collections::BTreeMap;
6 +
7 + // Names this module's prose links to, resolved for rustdoc.
8 + #[allow(unused_imports)]
9 + use crate::derive_tonal_steps;
10 +
11 + /// Base intents: (TOML dotted source key, canonical token key). The token key
12 + /// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key.
13 + ///
14 + /// Read straight from the loaded theme, which is not quite the same as read
15 + /// from the file: `content.secondary` and `content.muted` are tonal steps of
16 + /// `content.primary` and are filled in at load by [`derive_tonal_steps`], so
17 + /// they arrive here already computed and take this path like any other.
18 + pub const BASE_INTENTS: &[(&str, &str)] = &[
19 + ("surface.page", "surface-page"),
20 + ("surface.raised", "surface-raised"),
21 + ("surface.sunken", "surface-sunken"),
22 + ("surface.overlay", "surface-overlay"),
23 + ("content.primary", "content"),
24 + ("content.secondary", "content-secondary"),
25 + ("content.muted", "content-muted"),
26 + ("action.primary", "action"),
27 + ("status.danger", "danger"),
28 + ("status.success", "success"),
29 + ("status.warning", "warning"),
30 + ("status.info", "info"),
31 + ("line.border", "border"),
32 + ("category.one", "category-one"),
33 + ("category.two", "category-two"),
34 + ("category.three", "category-three"),
35 + ("category.four", "category-four"),
36 + ("category.five", "category-five"),
37 + ("category.six", "category-six"),
38 + ];
39 +
40 + /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
41 + /// Includes both authored base intents and the computed derived intents.
42 + #[derive(Debug, Clone, Serialize)]
43 + #[serde(rename_all = "camelCase")]
44 + pub struct SemanticTokens {
45 + pub meta: ThemeMeta,
46 + /// token-key → resolved hex. Stable, deterministic ordering.
47 + pub intents: BTreeMap<String, String>,
48 + }
49 +
50 + impl SemanticTokens {
51 + /// Resolved hex for a token key, if present.
52 + pub fn hex(&self, key: &str) -> Option<&str> {
53 + self.intents.get(key).map(String::as_str)
54 + }
55 +
56 + /// Resolved RGB tuple for a token key (for egui / native consumers).
57 + ///
58 + /// `None` for a translucent token. Two intents are emitted as `rgba(...)`
59 + /// rather than hex, `overlay` and `elevation`, and dropping the alpha would
60 + /// hand a native consumer an opaque near-black where it asked for a scrim.
61 + /// Those want [`rgba`](Self::rgba).
62 + pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
63 + self.intents
64 + .get(key)
65 + .and_then(|h| Rgb::from_hex(h))
66 + .map(Rgb::tuple)
67 + }
68 +
69 + /// Resolved RGBA tuple for a token key, alpha as 0-255.
70 + ///
71 + /// Reads both spellings, so a caller that does not care whether an intent
72 + /// happens to be translucent can use this for everything: an opaque token
73 + /// comes back at 255.
74 + ///
75 + /// It exists because a CSS consumer can take `rgba(...)` as a string
76 + /// straight out of [`hex`](Self::hex) and a native one cannot. Without it
77 + /// the two translucent intents are reachable from a stylesheet and from
78 + /// nowhere else, which is the coupling deriving in the crate was meant to
79 + /// avoid.
80 + pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> {
81 + let value = self.intents.get(key)?;
82 + if let Some(rgb) = Rgb::from_hex(value) {
83 + let (r, g, b) = rgb.tuple();
84 + return Some((r, g, b, 255));
85 + }
86 + let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?;
87 + let mut parts = inner.split(',').map(str::trim);
88 + let r = parts.next()?.parse().ok()?;
89 + let g = parts.next()?.parse().ok()?;
90 + let b = parts.next()?.parse().ok()?;
91 + let alpha: f32 = parts.next()?.parse().ok()?;
92 + if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) {
93 + return None;
94 + }
95 + Some((r, g, b, (alpha * 255.0).round() as u8))
96 + }
97 + }
98 +
99 + /// Resolve an authored theme into the full intent token set.
100 + ///
101 + /// 1. Copy each present base intent from the authored colors.
102 + /// 2. Compute the derived interactive states from the base intents, so every
103 + /// consumer gets identical output.
104 + ///
105 + /// Each derived token is emitted only when its source intents exist, mirroring
106 + /// the skip-missing behavior of the rest of the crate.
107 + pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
108 + let mut intents: BTreeMap<String, String> = BTreeMap::new();
109 +
110 + // 1. Base intents (authored). Copy only values that parse as a hex color and
111 + // re-emit them in canonical `#rrggbb` form, so an authored value can never
112 + // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
113 + // raw into a `<style>` block by the web server). A malformed value is skipped,
114 + // mirroring the skip-missing behavior for absent intents.
115 + for (src, token) in BASE_INTENTS {
116 + if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
117 + intents.insert((*token).to_string(), rgb.to_hex());
118 + }
119 + }
120 +
121 + // Helper: parse an already-resolved token to Rgb.
122 + let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
123 +
124 + // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
125 + // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
126 + let mut derived: Vec<(String, Rgb)> = Vec::new();
127 + if let Some(action) = get(&intents, "action") {
128 + derived.push(("action-hover".into(), lighten(action, 0.05)));
129 + derived.push(("content-on-action".into(), readable_on(action)));
130 + // The focus ring is the action colour itself, not a tint of it: a ring
131 + // is a statement that the keyboard is here, and a faded one reads as a
132 + // disabled control rather than an emphatic one.
133 + //
134 + // One ring, not one per primitive. Where the ring sits is a depth
135 + // question and not a per-component choice: a well takes it inside its
136 + // own edge and a raised surface takes it outside. That is one decision
137 + // with two renderings rather than one decision per component, which is
138 + // how the three apps ended up with three rings. This token is the one
139 + // shared artifact; which thing wears it, and how it is drawn, is each
140 + // renderer's own (see `makeover_layout`'s crate header, "reach, focus
141 + // and the focus ring").
142 + derived.push(("focus-ring".into(), action));
143 + }
144 + if let Some(page) = get(&intents, "surface-page") {
145 + // Modal scrim: a near-black tone carrying a faint hint of the theme's
146 + // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
147 + // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
148 + // it is inserted directly rather than through the hex loop below.
149 + let mut o = page.to_oklab();
150 + o.l = 0.08;
151 + let s = Rgb::from_oklab(o);
152 + intents.insert(
153 + "overlay".into(),
154 + format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
155 + );
156 +
157 + // What a surface that FLOATS OVER the page is cast onto it with.
158 + //
159 + // The one intent here about a surface's relationship to the page rather
160 + // than about the surface itself, which is why it is derived from `page`
161 + // and not from `surface-raised`. A shadow is not the thing, it is the
162 + // absence of light on what is behind the thing.
163 + //
164 + // SCOPE, and it is the whole point of this intent existing rather than
165 + // a general "shadow": a surface that overlays the page takes this, a
166 + // surface IN the page takes a bevel. Menus, toasts, popovers and
167 + // dropdowns overlay. A card, a plate and a framed image do not, and
168 + // reaching for this on one of those is how a pre-Platinum look survives
169 + // a conversion wearing a token's name. `.raised` is the answer there.
170 + //
171 + // Same anchor as the scrim above and for the same reason: a tone read
172 + // off the theme's hue but pinned very dark, so it reads as absence of
173 + // light on a light theme and on a dark one alike. A shadow tinted to a
174 + // dark theme's own lightness would not be a shadow.
175 + //
176 + // The alpha is the only number here that is a look decision rather than
177 + // a derivation. 0.18 sits between the two literal scales it replaces:
178 + // the MNW server's --shadow-2 (0.10) reads as nothing under a menu, and
179 + // its --shadow-3 (0.15) was measured invisible at plate size. Geometry
180 + // stays with the consumer, the way bevel thickness does.
181 + intents.insert(
182 + "elevation".into(),
183 + format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b),
184 + );
185 + }
186 + if let Some(raised) = get(&intents, "surface-raised") {
187 + // The two edges of a bevel: a raised control is lit from the top left,
188 + // so its top and left edges take `bevel-light` and its bottom and right
189 + // edges `bevel-dark`. Inverting the pair gives a pressed state and an
190 + // inset well, which is what makes the idiom cheap for a consumer.
191 + //
192 + // Derived here rather than composed per-app because the two webviews
193 + // could do it in `color-mix()` and audiofiles, which is egui, could not.
194 + // Geometry (thickness, radius, which side gets which) stays app-side.
195 + //
196 + // The deltas are asymmetric because the eye is: an equal step down reads
197 + // as a smaller change than the same step up, so the shadow is cut deeper
198 + // than the highlight is raised.
199 + //
200 + // A face already at the top of the ramp cannot hold a highlight — the
201 + // lightening clamps and the control bevels on two sides without ever
202 + // resolving as lit. That is a property of the theme, not of this
203 + // derivation; `bevel_edges_are_distinct_from_their_face` names the
204 + // shipped themes it currently bites.
205 + derived.push(("bevel-light".into(), lighten(raised, 0.14)));
206 + derived.push(("bevel-dark".into(), darken(raised, 0.18)));
207 +
208 + // An inset well: the content surface inside a raised container, so a
209 + // list reads as content in a container rather than as bands on a panel.
210 + // `surface-sunken` cannot serve, because a theme is free to author it
211 + // darker than raised (goingson does) and a well has to go the other way.
212 + //
213 + // Which way is "the other way" depends on the theme, and this is the one
214 + // derivation here that inverts. A well is lighter than its face on a
215 + // light theme and darker on a dark one, where the bevel pair sidesteps
216 + // the question by emitting both directions at once.
217 + //
218 + // Read the direction off `content` rather than off `Variant`. A theme
219 + // whose text is dark is a theme whose surfaces are light, whatever its
220 + // `variant` field claims, so this resolves correctly even when that
221 + // field is wrong and it keeps the branch on measured color rather than
222 + // on metadata.
223 + //
224 + // Deltas are asymmetric for the same reason the bevel's are, and smaller
225 + // than the bevel's because a well is an area rather than an edge. The
226 + // step up is the specimen's, measured: #D9DDF4 to #F3F5FD is 0.069.
227 + //
228 + // A face at the top of its ramp cannot hold a lighter well, the same
229 + // clamp `bevel-light` hits; `well_is_visible_against_its_face` names the
230 + // shipped themes where it bites.
231 + if let Some(content) = get(&intents, "content") {
232 + let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
233 + let well = if content_is_darker {
234 + lighten(raised, 0.07)
235 + } else {
236 + darken(raised, 0.09)
237 + };
238 + derived.push(("surface-well".into(), well));
239 + }
240 + }
241 + if let Some(sunken) = get(&intents, "surface-sunken") {
242 + derived.push(("hover-surface".into(), sunken));
243 + }
244 + if let Some(border) = get(&intents, "border") {
245 + derived.push(("border-strong".into(), darken(border, 0.05)));
246 + }
247 +
248 + for (token, rgb) in derived {
249 + intents.insert(token, rgb.to_hex());
250 + }
251 +
252 + SemanticTokens {
253 + meta: theme.meta.clone(),
254 + intents,
255 + }
256 + }
257 +
258 + /// Emit the resolved intent layer as CSS declarations (no selector), one
259 + /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
260 + pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
261 + let mut out = String::new();
262 + for (token, hex) in &tokens.intents {
263 + out.push_str(" --");
264 + out.push_str(token);
265 + out.push_str(": ");
266 + out.push_str(hex);
267 + out.push_str(";\n");
268 + }
269 + out
270 + }
271 +
272 + /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
273 + /// CSS mapping every web surface injects.
274 + pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
275 + format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
276 + }
277 +
278 + #[cfg(test)]
279 + mod tests {
280 + use super::*;
281 + use crate::fixture::nord_toml;
282 + use crate::{Emphasis, embedded_themes, emphasized, parse_theme_str};
283 +
284 + #[test]
285 + fn resolve_base_intents_passthrough() {
286 + let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
287 + let t = resolve(&theme);
288 + assert_eq!(t.hex("surface-page"), Some("#2e3440"));
289 + assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
290 + // Not a passthrough: a tonal step of the ink, whatever the file said.
291 + assert_eq!(
292 + t.hex("content-muted").unwrap(),
293 + emphasized(
294 + Rgb::from_hex("#d8dee9").unwrap(),
295 + Rgb::from_hex("#2e3440").unwrap(),
296 + Emphasis::Muted
297 + )
298 + .to_hex()
299 + );
300 + assert_eq!(t.hex("action"), Some("#81a1c1"));
301 + assert_eq!(t.hex("danger"), Some("#bf616a"));
302 + assert_eq!(t.hex("border"), Some("#4c566a"));
303 + assert_eq!(t.hex("category-five"), Some("#b48ead"));
304 + }
305 +
306 + #[test]
307 + fn resolve_derived_intents() {
308 + let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
309 + let t = resolve(&theme);
310 + let action = Rgb::from_hex("#81a1c1").unwrap();
311 + let page = Rgb::from_hex("#2e3440").unwrap();
312 + let _ = page;
313 + assert_eq!(
314 + t.hex("action-hover").unwrap(),
315 + lighten(action, 0.05).to_hex()
316 + );
317 + assert_eq!(
318 + t.hex("content-on-action").unwrap(),
319 + readable_on(action).to_hex()
320 + );
321 + assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
322 + assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
323 + // Pruned by the usage audit (0 consumers): action-active, the *-surface
324 + // tints, selection, row-stripe. Apps that need them derive inline via
325 + // the shared mix().
326 + assert!(t.hex("action-active").is_none());
327 + assert!(t.hex("danger-surface").is_none());
328 + assert!(t.hex("selection").is_none());
329 + assert!(t.hex("row-stripe").is_none());
330 + }
331 +
332 + #[test]
333 + fn resolve_bevel_intents() {
334 + let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
335 + let t = resolve(&theme);
336 + let raised = Rgb::from_hex("#3b4252").unwrap();
337 + assert_eq!(
338 + t.hex("bevel-light").unwrap(),
339 + lighten(raised, 0.14).to_hex()
340 + );
341 + assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
342 + }
343 +
344 + // A bevel is two edges around one face, so both edges have to be visibly off
345 + // that face or the control never resolves as lit. The lightening clamps at
346 + // the top of the ramp, which means a theme authoring a white raised surface
347 + // gets a highlight identical to the surface it is meant to sit on.
348 + //
349 + // The list is asserted rather than merely reported so that changing a theme
350 + // has to come here and say so. Shrinking it is the fix; growing it is a
351 + // regression in the theme, not in this derivation.
352 + #[test]
353 + fn bevel_edges_are_distinct_from_their_face() {
354 + const CANNOT_BEVEL: &[&str] = &["neobrute", "oxocarbon-light"];
355 +
356 + let mut degenerate: Vec<String> = Vec::new();
357 + for (id, source) in embedded_themes() {
358 + let theme = parse_theme_str(id, source, false).unwrap();
359 + let t = resolve(&theme);
360 + let Some(raised) = t.hex("surface-raised") else {
361 + continue;
362 + };
363 + let light = t.hex("bevel-light").expect("raised implies bevel-light");
364 + let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
365 + if light == raised || dark == raised {
366 + degenerate.push(id.to_string());
367 + }
368 + }
369 + degenerate.sort();
370 +
371 + assert_eq!(
372 + degenerate, CANNOT_BEVEL,
373 + "themes whose raised surface cannot hold both bevel edges"
374 + );
375 + }
376 +
377 + // The well inverts by theme, so assert both directions explicitly rather
378 + // than only the one the light themes happen to take.
379 + #[test]
380 + fn resolve_well_intent_follows_the_content_direction() {
381 + // nord is dark: light text on a dark raised surface, so the well goes
382 + // down and away from the text.
383 + let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
384 + let dark_raised = Rgb::from_hex("#3b4252").unwrap();
385 + assert_eq!(
386 + dark.hex("surface-well").unwrap(),
387 + darken(dark_raised, 0.09).to_hex()
388 + );
389 +
390 + // The shipped light themes take the other branch.
391 + let goingson = embedded_themes()
392 + .into_iter()
393 + .find(|(id, _)| *id == "goingson")
394 + .expect("goingson is embedded")
395 + .1;
396 + let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
397 + let light_raised = light
398 + .hex("surface-raised")
399 + .and_then(Rgb::from_hex)
400 + .expect("goingson authors a raised surface");
401 + assert_eq!(
402 + light.hex("surface-well").unwrap(),
403 + lighten(light_raised, 0.07).to_hex()
404 + );
405 + }
406 +
407 + // A well is a fill, not an edge, so the only thing that makes it read is
408 + // being a different color from the surface it is cut into.
409 + //
410 + // Same shape and the same asserted-list discipline as
411 + // `bevel_edges_are_distinct_from_their_face`, and it bites the same two
412 + // themes for the same reason: a raised surface already at the top of the
413 + // ramp has nothing lighter to go to.
414 + #[test]
415 + fn well_is_distinct_from_its_face() {
416 + const CANNOT_WELL: &[&str] = &["neobrute", "oxocarbon-light"];
417 +
418 + let mut degenerate: Vec<String> = Vec::new();
419 + for (id, source) in embedded_themes() {
420 + let theme = parse_theme_str(id, source, false).unwrap();
421 + let t = resolve(&theme);
422 + let Some(raised) = t.hex("surface-raised") else {
423 + continue;
424 + };
425 + let well = t.hex("surface-well").expect("raised implies surface-well");
426 + if well == raised {
427 + degenerate.push(id.to_string());
428 + }
429 + }
430 + degenerate.sort();
431 +
432 + assert_eq!(
433 + degenerate, CANNOT_WELL,
434 + "themes whose raised surface cannot hold a well"
435 + );
436 + }
437 +
438 + // Distinct is not the same as visible. A face near the top of the ramp
439 + // clamps partway rather than exactly, which yields a well that differs from
440 + // its face by a hex digit and by nothing the eye can find. `rosepine-dawn`
441 + // authors raised at L=0.987 and gets 0.009 of the 0.07 it asked for.
442 + //
443 + // Worth a separate test from the one above because the fix differs: an
444 + // exactly-degenerate theme needs its raised surface off the ramp end, while
445 + // these need it merely lowered. Both fixes are the theme's, not this
446 + // derivation's, which is why the list is asserted rather than warned about.
447 + #[test]
448 + fn well_is_visible_against_its_face() {
449 + // Below this, the well and its face are the same surface to a reader.
450 + const MIN_DELTA_L: f32 = 0.02;
451 + const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] =
452 + &["neobrute", "oxocarbon-light", "rosepine-dawn"];
453 +
454 + let mut invisible: Vec<String> = Vec::new();
455 + for (id, source) in embedded_themes() {
456 + let theme = parse_theme_str(id, source, false).unwrap();
457 + let t = resolve(&theme);
458 + let (Some(raised), Some(well)) = (
459 + t.hex("surface-raised").and_then(Rgb::from_hex),
460 + t.hex("surface-well").and_then(Rgb::from_hex),
461 + ) else {
462 + continue;
463 + };
464 + if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_DELTA_L {
465 + invisible.push(id.to_string());
466 + }
467 + }
468 + invisible.sort();
469 +
470 + assert_eq!(
471 + invisible, CANNOT_HOLD_A_VISIBLE_WELL,
472 + "themes whose well is too close to its face to read as one"
473 + );
474 + }
475 +
476 + // The three tests above each measure a derived color against the face it was
477 + // derived from, so a theme can pass all of them and still have nothing lift
478 + // off anything: the face itself sits on the page, and that relationship is
479 + // the one a bevel needs in order to read as an object rather than as a
480 + // rectangle with decorated edges. makenot.work passed all three and could
481 + // not hold a bevel, which is what this covers.
482 + //
483 + // The threshold is picked against the ramps already ruled on rather than
484 + // against a round number. makenot.work shipped at 0.024 and was invisible,
485 + // was tried at 0.036 and rejected as marginal on badges and chips, and was
486 + // accepted at 0.058; goingson and audiofiles sit at 0.119 and 0.065. Every
487 + // ramp judged inadequate is below 0.036 and every one judged adequate is
488 + // above 0.058, so the line goes in the gap between them. Note the unit: this
489 + // is oklab L on 0 to 1, not the CIE L* on 0 to 100 that the theme files quote
490 + // in their comments, and the two are not interchangeable.
491 + //
492 + // Most of the list is imported palettes, which were authored for syntax
493 + // highlighting and owe our depth model nothing. Failing here says a theme
494 + // cannot hold a bevel, not that it is wrong. Shrinking the list is the fix;
495 + // growing it is a regression in the theme, not in this derivation.
496 + //
497 + // An entry leaves this list only when the fix is upstream's own, never a
498 + // color we picked. The remaining entries are shallow ramps in published
499 + // palettes, deferred until every app is migrated and eyeballed.
500 + #[test]
Lines truncated
A src/load.rs +500
@@ -1,0 +1,567 @@
1 + //! Loading / parsing
2 +
3 + use crate::{
4 + COLOR_SECTIONS, Emphasis, Rgb, STEP_FLOOR, SemanticTokens, ThemeColors, ThemeMeta,
5 + find_theme_path, resolve, tonal, wcag_contrast,
6 + };
7 + use serde::Serialize;
8 + use std::collections::HashMap;
9 + use std::path::{Path, PathBuf};
10 +
11 + // Names this module's prose links to, resolved for rustdoc.
12 + #[allow(unused_imports)]
13 + use crate::ansi_intent;
14 +
15 + /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
16 + pub fn validate_theme_id(id: &str) -> Result<(), String> {
17 + if !id
18 + .chars()
19 + .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
20 + {
21 + return Err(format!("Invalid theme ID: {id}"));
22 + }
23 + Ok(())
24 + }
25 +
26 + /// Parse the `[meta]` section into `ThemeMeta`.
27 + ///
28 + /// Falls back to the file ID as the name and `"dark"` as the variant.
29 + pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
30 + let meta = table.get("meta").and_then(|m| m.as_table());
31 + let name = meta
32 + .and_then(|m| m.get("name"))
33 + .and_then(|v| v.as_str())
34 + .unwrap_or(id)
35 + .to_string();
36 + let variant = meta
37 + .and_then(|m| m.get("variant"))
38 + .and_then(|v| v.as_str())
39 + .unwrap_or("dark")
40 + .to_string();
41 +
42 + ThemeMeta {
43 + id: id.to_string(),
44 + name,
45 + variant,
46 + is_custom,
47 + }
48 + }
49 +
50 + /// Extract the intent color sections into a flat `HashMap` with dotted keys
51 + /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
52 + ///
53 + /// The tonal steps of `content.primary` are filled in here rather than read, by
54 + /// [`derive_tonal_steps`]. Anything a theme authored under those keys is
55 + /// replaced.
56 + pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
57 + let mut colors = HashMap::new();
58 + for section in COLOR_SECTIONS {
59 + if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
60 + for (key, val) in sect {
61 + if let Some(color) = val.as_str() {
62 + colors.insert(format!("{section}.{key}"), color.to_string());
63 + }
64 + }
65 + }
66 + }
67 + derive_tonal_steps(&mut colors);
68 + colors
69 + }
70 +
71 + /// Fill in the tonal steps of `content.primary`, overwriting whatever the theme
72 + /// authored under those keys.
73 + ///
74 + /// # Why they are not authored
75 + ///
76 + /// `content.secondary` and `content.muted` are not independent colours. They are
77 + /// the ink, one step and two steps back, and a theme that names them separately
78 + /// is stating three times something it stated once — which is how three of the
79 + /// bundled themes came to author a `secondary` *lighter* than their own
80 + /// `primary` (nord, solarized-dark) or identical to it (dracula), inverting the
81 + /// emphasis ramp the whole vocabulary rests on. Deriving them makes
82 + /// `content` > `content-secondary` > `content-muted` true by construction in
83 + /// every theme, including one a user writes.
84 + ///
85 + /// Applied at load rather than in [`resolve`] so that there is one answer: the
86 + /// resolved token layer, the ANSI table ([`ansi_intent`] reads authored keys),
87 + /// and every consumer holding a [`ThemeColors`] all see the same value. A
88 + /// derivation visible from only one of those is how a terminal and a webview
89 + /// come to disagree about what muted means.
90 + ///
91 + /// Both keys need `content.primary` and `surface.page` to exist and parse. When
92 + /// either is missing the step is skipped and anything authored is left where it
93 + /// is, mirroring the skip-missing behaviour of the rest of the crate — a
94 + /// half-written theme keeps whatever it has rather than losing it.
95 + ///
96 + /// # The ratio is a starting point, not the answer
97 + ///
98 + /// Each step is pushed further toward the page until it clears [`STEP_FLOOR`]
99 + /// against the ink, so what the theme gets is a step that can be seen rather
100 + /// than a step of the agreed size. The two are the same number in every bundled
101 + /// theme but the two with a pure-black ink, where the ratio has no range to
102 + /// travel in and the nominal step lands 3/255 from where it started.
103 + pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
104 + let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
105 + let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
106 + let (Some(ink), Some(page)) = (ink, page) else {
107 + return;
108 + };
109 + // Each step starts no nearer than the one before it landed, so pushing
110 + // secondary out cannot carry it past muted and invert the ramp.
111 + let mut reached = 0.0;
112 + for (key, step) in [
113 + ("content.secondary", Emphasis::Secondary),
114 + ("content.muted", Emphasis::Muted),
115 + ] {
116 + let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached));
117 + reached = ratio;
118 + colors.insert(key.to_string(), color.to_hex());
119 + }
120 + }
121 +
122 + /// The step `from` of the way from `ink` to `page`, pushed toward `page` until
123 + /// it clears [`STEP_FLOOR`] against the ink it is a step of. Returns the colour
124 + /// and the ratio it was found at.
125 + ///
126 + /// A forward scan rather than a solve, because it wants the *first* ratio that
127 + /// clears: contrast against the base rises with the distance travelled, but it
128 + /// rises through sRGB's transfer curve and OKLab's chroma path, and a bisection
129 + /// would trust a monotonicity nothing here guarantees.
130 + ///
131 + /// Travel stops at the ground. A theme whose ink and page are the same colour
132 + /// has no step to take, and the ground is the honest answer — nothing past it
133 + /// is a step of the ink any more.
134 + fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) {
135 + // Finer than 8-bit sRGB can resolve on the shortest ramp in the corpus, so
136 + // the scan never steps over the first colour that clears.
137 + const PROBE: f32 = 0.005;
138 + let mut ratio = from.clamp(0.0, 1.0);
139 + loop {
140 + let color = tonal(ink, page, ratio);
141 + if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 {
142 + return (color, ratio);
143 + }
144 + ratio = (ratio + PROBE).min(1.0);
145 + }
146 + }
147 +
148 + /// Scan directories for `.toml` theme files and return metadata for each.
149 + ///
150 + /// Directories are checked in order; later entries override earlier ones by ID.
151 + /// Each entry in `dirs` is `(path, is_custom)`.
152 + pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
153 + let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
154 +
155 + for (dir, is_custom) in dirs {
156 + let Ok(entries) = std::fs::read_dir(dir) else {
157 + continue;
158 + };
159 +
160 + for entry in entries {
161 + let Ok(entry) = entry else {
162 + continue;
163 + };
164 + let path = entry.path();
165 + if path.extension().and_then(|e| e.to_str()) != Some("toml") {
166 + continue;
167 + }
168 +
169 + let id = path
170 + .file_stem()
171 + .and_then(|s| s.to_str())
172 + .unwrap_or_default()
173 + .to_string();
174 +
175 + let Ok(content) = std::fs::read_to_string(&path) else {
176 + continue;
177 + };
178 + let table: toml::Table = match content.parse() {
179 + Ok(t) => t,
180 + Err(_) => continue,
181 + };
182 +
183 + seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
184 + }
185 + }
186 +
187 + let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
188 + themes.sort_by(|a, b| a.name.cmp(&b.name));
189 + themes
190 + }
191 +
192 + /// Parse a complete theme (metadata + colors) from raw TOML content, with no
193 + /// filesystem access. For callers that embed themes at compile time.
194 + pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
195 + validate_theme_id(id)?;
196 + let table: toml::Table = content
197 + .parse()
198 + .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
199 + let meta = parse_meta(id, &table, is_custom);
200 + let colors = extract_colors(&table);
201 + Ok(ThemeColors { meta, colors })
202 + }
203 +
204 + /// Load a complete theme (metadata + colors) by ID from the given directories.
205 + pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
206 + validate_theme_id(id)?;
207 +
208 + let (path, is_custom) =
209 + find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
210 +
211 + let content = std::fs::read_to_string(&path)
212 + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
213 +
214 + let table: toml::Table = content
215 + .parse()
216 + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
217 +
218 + let meta = parse_meta(id, &table, is_custom);
219 + let colors = extract_colors(&table);
220 +
221 + Ok(ThemeColors { meta, colors })
222 + }
223 +
224 + /// Load a theme and resolve it to the full intent token set in one step.
225 + pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
226 + Ok(resolve(&load_theme(dirs, id)?))
227 + }
228 +
229 + /// Import a theme TOML file into the custom themes directory.
230 + ///
231 + /// Validates that the file is parseable TOML with at least one intent color
232 + /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
233 + pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
234 + let content = std::fs::read_to_string(source_path)
235 + .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
236 +
237 + let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
238 +
239 + let has_colors = COLOR_SECTIONS
240 + .iter()
241 + .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
242 + if !has_colors {
243 + return Err(format!(
244 + "Theme file must have at least one color section ({})",
245 + COLOR_SECTIONS.join(", ")
246 + ));
247 + }
248 +
249 + let id = source_path
250 + .file_stem()
251 + .and_then(|s| s.to_str())
252 + .ok_or("Invalid file name")?
253 + .to_string();
254 + validate_theme_id(&id)?;
255 +
256 + std::fs::create_dir_all(custom_dir)
257 + .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
258 +
259 + let dest = custom_dir.join(format!("{id}.toml"));
260 + std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
261 +
262 + Ok(parse_meta(&id, &table, true))
263 + }
264 +
265 + /// Delete a custom theme by ID.
266 + ///
267 + /// Only operates on `custom_dir` — bundled themes are not deletable through
268 + /// this entry point.
269 + pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
270 + validate_theme_id(id)?;
271 +
272 + let path = custom_dir.join(format!("{id}.toml"));
273 + if !path.is_file() {
274 + return Err(format!("Custom theme '{id}' not found"));
275 + }
276 +
277 + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
278 + }
279 +
280 + /// A four-color preview for theme thumbnails: the representative swatch from
281 + /// each of the principal roles.
282 + #[derive(Debug, Clone, Serialize)]
283 + #[serde(rename_all = "camelCase")]
284 + pub struct ThemePreview {
285 + pub meta: ThemeMeta,
286 + /// Page background (`surface.page`).
287 + pub background: Option<String>,
288 + /// Body text (`content.primary`).
289 + pub foreground: Option<String>,
290 + /// Brand/interactive color (`action.primary`).
291 + pub accent: Option<String>,
292 + /// Divider/outline color (`line.border`).
293 + pub border: Option<String>,
294 + }
295 +
296 + fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
297 + table
298 + .get(section)
299 + .and_then(|s| s.as_table())
300 + .and_then(|s| s.get(key))
301 + .and_then(|v| v.as_str())
302 + .map(std::string::ToString::to_string)
303 + }
304 +
305 + /// Load just the preview swatches for a theme — for UI thumbnails.
306 + pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
307 + validate_theme_id(id)?;
308 +
309 + let (path, is_custom) =
310 + find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
311 +
312 + let content = std::fs::read_to_string(&path)
313 + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
314 +
315 + let table: toml::Table = content
316 + .parse()
317 + .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
318 +
319 + Ok(ThemePreview {
320 + meta: parse_meta(id, &table, is_custom),
321 + background: color_at(&table, "surface", "page"),
322 + foreground: color_at(&table, "content", "primary"),
323 + accent: color_at(&table, "action", "primary"),
324 + border: color_at(&table, "line", "border"),
325 + })
326 + }
327 +
328 + /// Export a theme to a user-chosen path.
329 + pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
330 + validate_theme_id(id)?;
331 +
332 + let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
333 +
334 + std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
335 +
336 + Ok(())
337 + }
338 +
339 + #[cfg(test)]
340 + mod tests {
341 + use super::*;
342 + use crate::fixture::nord_toml;
343 + use crate::{bundled_themes_dir, embedded_themes};
344 + use std::fs;
345 +
346 + // ---- id validation ----
347 +
348 + #[test]
349 + fn validate_theme_id_alphanumeric() {
350 + assert!(validate_theme_id("darkmode").is_ok());
351 + assert!(validate_theme_id("Theme123").is_ok());
352 + }
353 +
354 + #[test]
355 + fn validate_theme_id_hyphens_underscores() {
356 + assert!(validate_theme_id("dark-mode").is_ok());
357 + assert!(validate_theme_id("my_theme_v2").is_ok());
358 + }
359 +
360 + #[test]
361 + fn validate_theme_id_rejects_path_traversal() {
362 + assert!(validate_theme_id("../etc/passwd").is_err());
363 + assert!(validate_theme_id("foo/bar").is_err());
364 + assert!(validate_theme_id("theme.toml").is_err());
365 + }
366 +
367 + // ---- meta ----
368 +
369 + #[test]
370 + fn parse_meta_with_name_and_variant() {
371 + let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
372 + .parse()
373 + .unwrap();
374 + let meta = parse_meta("nord", &table, false);
375 + assert_eq!(meta.id, "nord");
376 + assert_eq!(meta.name, "Nord");
377 + assert_eq!(meta.variant, "light");
378 + assert!(!meta.is_custom);
379 + }
380 +
381 + #[test]
382 + fn parse_meta_defaults_to_id_and_dark() {
383 + let table: toml::Table = "".parse().unwrap();
384 + let meta = parse_meta("fallback", &table, true);
385 + assert_eq!(meta.name, "fallback");
386 + assert_eq!(meta.variant, "dark");
387 + assert!(meta.is_custom);
388 + }
389 +
390 + #[test]
391 + fn extract_colors_reads_intent_sections() {
392 + let table: toml::Table = nord_toml().parse().unwrap();
393 + let colors = extract_colors(&table);
394 + assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
395 + assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
396 + assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
397 + assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
398 + assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
399 + assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
400 + assert_eq!(colors.len(), 19);
401 + }
402 +
403 + #[test]
404 + fn every_shipped_theme_ramps_one_way() {
405 + // The property authoring the steps separately could not hold: three
406 + // themes had shipped a secondary lighter than their own primary, so a
407 + // renderer reading the emphasis order got the reverse of it.
408 + for (id, toml) in embedded_themes() {
409 + let theme = parse_theme_str(id, toml, false).unwrap();
410 + let t = resolve(&theme);
411 + let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
412 + let steps = ["content", "content-secondary", "content-muted"]
413 + .map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
414 + assert!(
415 + steps[0] > steps[1] && steps[1] > steps[2],
416 + "{id}: emphasis does not fall monotonically: {steps:?}"
417 + );
418 + }
419 + }
420 +
421 + #[test]
422 + fn every_shipped_theme_takes_a_visible_first_step() {
423 + // The property that was missing when 2.6.0 derived these, and the
424 + // reason a pure-black ink shipped a secondary 3/255 away from it: the
425 + // ramp falling monotonically says nothing about how far it falls, and
426 + // a step nobody can see is not a step.
427 + for (id, toml) in embedded_themes() {
428 + let theme = parse_theme_str(id, toml, false).unwrap();
429 + let t = resolve(&theme);
430 + let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap();
431 + let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap();
432 + let step = wcag_contrast(ink, secondary);
433 + assert!(
434 + step >= STEP_FLOOR,
435 + "{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor"
436 + );
437 + }
438 + }
439 +
440 + #[test]
441 + fn an_authored_emphasis_step_does_not_survive_loading() {
442 + // `nord_toml` still authors both, because a user's theme file might and
443 + // the answer has to be the same one.
444 + let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
445 + assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
446 + assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
447 + }
448 +
449 + #[test]
450 + fn a_theme_with_no_page_keeps_what_it_authored() {
451 + // Skip-missing: there is nothing to read the step against, so the step
452 + // is not taken and a half-written theme does not lose a colour.
453 + let mut colors = HashMap::new();
454 + colors.insert("content.primary".to_string(), "#d8dee9".to_string());
455 + colors.insert("content.muted".to_string(), "#616e88".to_string());
456 + derive_tonal_steps(&mut colors);
457 + assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
458 + }
459 +
460 + // ---- loading / fs ----
461 +
462 + #[test]
463 + fn load_and_resolve_round_trip() {
464 + let dir = tempfile::tempdir().unwrap();
465 + fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
466 + let dirs = vec![(dir.path().to_path_buf(), false)];
467 + let t = load_semantic(&dirs, "nord").unwrap();
468 + assert_eq!(t.meta.name, "Nord");
469 + assert_eq!(t.hex("action"), Some("#81a1c1"));
470 + }
471 +
472 + #[test]
473 + fn load_theme_rejects_invalid_id() {
474 + assert!(load_theme(&[], "../evil").is_err());
475 + }
476 +
477 + #[test]
478 + fn list_themes_from_dirs_finds_toml_files() {
479 + let dir = tempfile::tempdir().unwrap();
480 + fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
481 + fs::write(dir.path().join("x.txt"), "ignored").unwrap();
482 + let dirs = vec![(dir.path().to_path_buf(), false)];
483 + let themes = list_themes_from_dirs(&dirs);
484 + assert_eq!(themes.len(), 1);
485 + assert_eq!(themes[0].id, "t");
486 + }
487 +
488 + #[test]
489 + fn import_theme_valid_and_rejects_empty() {
490 + let src_dir = tempfile::tempdir().unwrap();
491 + let custom_dir = tempfile::tempdir().unwrap();
492 +
493 + let good = src_dir.path().join("my-theme.toml");
494 + fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
495 + let meta = import_theme(&good, custom_dir.path()).unwrap();
496 + assert_eq!(meta.id, "my-theme");
497 + assert!(custom_dir.path().join("my-theme.toml").exists());
498 +
499 + let empty = src_dir.path().join("empty.toml");
500 + fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
Lines truncated
@@ -1,0 +1,631 @@
1 + //! Choosing a theme.
2 + //!
3 + //! The file half of this crate was always shared; the *selection* half was not,
4 + //! and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
5 + //! localStorage, Balanced Breakfast treats an absent value as follow-the-system
6 + //! and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
7 + //! in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
8 + //! disagreed about what a variant string means: this crate defaults a missing
9 + //! one to "dark" while alloy_tui parsed an unrecognized one as light.
10 + //!
11 + //! What cannot be shared is the store — localStorage, a synced config table and
12 + //! a TOML file are genuinely different places. What can be shared, and is here,
13 + //! is the *meaning*: one vocabulary for variants, one encoding for "what did the
14 + //! user choose", and one rule for turning that into an id that exists.
15 +
16 + use crate::{Rgb, ThemeColors, ThemeMeta, list_themes_from_dirs, load_theme, wcag_contrast};
17 + use serde::Serialize;
18 + use std::path::PathBuf;
19 +
20 + // Names this module's prose links to, resolved for rustdoc.
21 + #[allow(unused_imports)]
22 + use crate::parse_meta;
23 +
24 + /// A theme's kind, as declared by `meta.variant`.
25 + ///
26 + /// Three, not two: one shipped theme is `high-contrast`, and an app that
27 + /// matched on light-or-dark alone would quietly file it under the wrong one.
28 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
29 + #[serde(rename_all = "kebab-case")]
30 + pub enum Variant {
31 + Light,
32 + Dark,
33 + HighContrast,
34 + }
35 +
36 + impl Variant {
37 + /// The spelling used in a theme file and in [`ThemeMeta::variant`].
38 + #[must_use]
39 + pub const fn as_str(self) -> &'static str {
40 + match self {
41 + Variant::Light => "light",
42 + Variant::Dark => "dark",
43 + Variant::HighContrast => "high-contrast",
44 + }
45 + }
46 +
47 + /// Read a variant string, or `None` if it names none of them.
48 + #[must_use]
49 + pub fn parse(raw: &str) -> Option<Self> {
50 + match raw {
51 + "light" => Some(Variant::Light),
52 + "dark" => Some(Variant::Dark),
53 + "high-contrast" => Some(Variant::HighContrast),
54 + _ => None,
55 + }
56 + }
57 + }
58 +
59 + impl std::fmt::Display for Variant {
60 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 + f.write_str(self.as_str())
62 + }
63 + }
64 +
65 + /// Anything unrecognized reads as dark, which is what [`parse_meta`] already
66 + /// does with a missing one. Consumers that guessed light for an unknown string
67 + /// were disagreeing with the crate that produced it.
68 + impl From<&str> for Variant {
69 + fn from(raw: &str) -> Self {
70 + Variant::parse(raw).unwrap_or(Variant::Dark)
71 + }
72 + }
73 +
74 + impl ThemeMeta {
75 + /// This theme's variant as a value rather than a string.
76 + #[must_use]
77 + pub fn kind(&self) -> Variant {
78 + Variant::from(self.variant.as_str())
79 + }
80 + }
81 +
82 + /// The spelling of "follow whatever the system is doing", in every store.
83 + pub const FOLLOW: &str = "system";
84 +
85 + /// What the user chose, as opposed to what is being rendered.
86 + ///
87 + /// The distinction is the whole point: `Follow` is a standing instruction that
88 + /// resolves differently as the ambient mode changes, and a `Fixed` id is an
89 + /// answer that does not. An app that stored only the rendered id could not tell
90 + /// the two apart the next time the system flipped to dark.
91 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
92 + pub enum ThemeSelection {
93 + /// Track the ambient light/dark mode.
94 + #[default]
95 + Follow,
96 + /// Always this theme.
97 + Fixed(String),
98 + }
99 +
100 + impl ThemeSelection {
101 + /// Read a stored selection. An empty or absent value is [`Follow`], which
102 + /// is what an app with nothing saved yet should do.
103 + ///
104 + /// [`Follow`]: ThemeSelection::Follow
105 + #[must_use]
106 + pub fn parse(raw: Option<&str>) -> Self {
107 + match raw.map(str::trim) {
108 + None | Some("" | FOLLOW) => ThemeSelection::Follow,
109 + Some(id) => ThemeSelection::Fixed(id.to_string()),
110 + }
111 + }
112 +
113 + /// The string to persist, whatever the store is.
114 + #[must_use]
115 + pub fn as_str(&self) -> &str {
116 + match self {
117 + ThemeSelection::Follow => FOLLOW,
118 + ThemeSelection::Fixed(id) => id,
119 + }
120 + }
121 +
122 + /// Turn a selection into a theme id that exists.
123 + ///
124 + /// `ambient` is the light/dark mode the app learned however it can: a
125 + /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
126 + /// from a terminal. `available` is what [`list_themes_from_dirs`] found.
127 + ///
128 + /// A `Fixed` id that is no longer on disk falls through to the same path as
129 + /// `Follow` rather than being returned anyway. Themes are deletable in
130 + /// three of the four apps, and handing back an id that will fail to load
131 + /// only moves the error somewhere less helpful.
132 + ///
133 + /// The fallback chain is: the app's own default for the ambient mode if it
134 + /// is installed, then any installed theme of that variant, then the app's
135 + /// default regardless. The last step means this always returns something,
136 + /// and an app with no theme directory at all gets the id it ships with and
137 + /// the load error it would have had anyway.
138 + #[must_use]
139 + pub fn resolve(
140 + &self,
141 + ambient: Variant,
142 + defaults: &ThemeDefaults,
143 + available: &[ThemeMeta],
144 + ) -> String {
145 + let installed = |id: &str| available.iter().any(|meta| meta.id == id);
146 +
147 + if let ThemeSelection::Fixed(id) = self
148 + && installed(id)
149 + {
150 + return id.clone();
151 + }
152 +
153 + let preferred = defaults.for_variant(ambient);
154 + if installed(preferred) {
155 + return preferred.to_string();
156 + }
157 + available
158 + .iter()
159 + .find(|meta| meta.kind() == ambient)
160 + .map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
161 + }
162 + }
163 +
164 + impl std::fmt::Display for ThemeSelection {
165 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 + f.write_str(self.as_str())
167 + }
168 + }
169 +
170 + /// The themes an app falls back to, one per ambient mode.
171 + ///
172 + /// App-specific on purpose: which theme is "the app's own" is the app's
173 + /// identity, not this crate's business. What is shared is everything around it.
174 + #[derive(Debug, Clone)]
175 + pub struct ThemeDefaults {
176 + light: String,
177 + dark: String,
178 + high_contrast: Option<String>,
179 + }
180 +
181 + impl ThemeDefaults {
182 + pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
183 + Self {
184 + light: light.into(),
185 + dark: dark.into(),
186 + high_contrast: None,
187 + }
188 + }
189 +
190 + /// Name a theme for a high-contrast ambient mode. Without one, that mode
191 + /// falls back to the dark default, which is the safer of the two to read.
192 + #[must_use]
193 + pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
194 + self.high_contrast = Some(id.into());
195 + self
196 + }
197 +
198 + /// Whether a high-contrast default was named.
199 + ///
200 + /// [`for_variant`] answers for every mode by falling back to the dark
201 + /// theme, which is right for resolving a selection and wrong for emitting
202 + /// a `prefers-contrast: more` block: that block would then answer the
203 + /// preference with a theme that does not honour it. A caller that renders
204 + /// per ambient mode asks this first.
205 + ///
206 + /// [`for_variant`]: ThemeDefaults::for_variant
207 + #[must_use]
208 + pub const fn names_high_contrast(&self) -> bool {
209 + self.high_contrast.is_some()
210 + }
211 +
212 + #[must_use]
213 + pub fn for_variant(&self, variant: Variant) -> &str {
214 + match variant {
215 + Variant::Light => &self.light,
216 + Variant::Dark => &self.dark,
217 + Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
218 + }
219 + }
220 + }
221 +
222 + /// How legible a theme's muted text is, measured rather than declared.
223 + ///
224 + /// The worst WCAG contrast ratio of `content.muted` against the two panel
225 + /// grounds a reader actually meets it on, `surface.page` and `surface.sunken`,
226 + /// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average,
227 + /// because a theme that is legible on one panel and not the other is a theme
228 + /// with an illegible panel.
229 + ///
230 + /// It is measured here rather than authored in the theme file for the reason
231 + /// the whole crate exists: a curated palette keeps its identity and the reader
232 + /// still gets told what it costs them. An author cannot mis-declare it, and a
233 + /// theme edited on disk re-measures on the next scan.
234 + ///
235 + /// Ordered worst-first, so `sort` puts the most legible theme last and
236 + /// [`theme_options`] reverses it into what a picker wants at the top.
237 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
238 + #[serde(rename_all = "kebab-case")]
239 + pub enum ContrastTier {
240 + /// Muted text below the 3:1 floor WCAG sets for large text and UI parts.
241 + Low,
242 + /// Muted text meets 3:1 but not the 4.5:1 bar for normal text.
243 + Standard,
244 + /// Muted text meets WCAG AA on every panel ground, 4.5:1 or better.
245 + High,
246 + }
247 +
248 + impl ContrastTier {
249 + /// The machine spelling, for a data attribute or a stored value.
250 + #[must_use]
251 + pub const fn as_str(self) -> &'static str {
252 + match self {
253 + ContrastTier::Low => "low",
254 + ContrastTier::Standard => "standard",
255 + ContrastTier::High => "high",
256 + }
257 + }
258 +
259 + /// Measure a loaded theme.
260 + ///
261 + /// A theme missing either ground or the muted content colour reads as
262 + /// [`Standard`](Self::Standard): the measurement did not happen, and
263 + /// claiming `Low` would badge a theme for the scan's failure rather than
264 + /// its own.
265 + #[must_use]
266 + pub fn of(theme: &ThemeColors) -> Self {
267 + let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v));
268 + let (Some(muted), Some(page), Some(sunken)) = (
269 + colour("content.muted"),
270 + colour("surface.page"),
271 + colour("surface.sunken"),
272 + ) else {
273 + return ContrastTier::Standard;
274 + };
275 +
276 + let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken));
277 + if worst >= 4.5 {
278 + ContrastTier::High
279 + } else if worst >= 3.0 {
280 + ContrastTier::Standard
281 + } else {
282 + ContrastTier::Low
283 + }
284 + }
285 + }
286 +
287 + /// One theme, as a picker offers it.
288 + ///
289 + /// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies:
290 + /// the variant as a value rather than a string, and the measured contrast tier.
291 + /// Owned, because it outlives the directory scan that produced it and is held
292 + /// by an app across the frames or requests that draw the control.
293 + ///
294 + /// It carries no `is_custom`. A picker that sorted the user's own themes apart
295 + /// from the shipped ones would be answering a different question, and
296 + /// [`ThemeMeta`] is still there for a screen that wants it.
297 + #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298 + #[serde(rename_all = "camelCase")]
299 + pub struct ThemeOption {
300 + /// The id stored, and the value the picker submits.
301 + pub id: String,
302 + /// What the picker reads.
303 + pub name: String,
304 + /// Which group it belongs to.
305 + pub variant: Variant,
306 + /// How legible its muted text measured.
307 + pub contrast: ContrastTier,
308 + }
309 +
310 + /// Every installed theme, in the order a picker should offer them.
311 + ///
312 + /// This is the half of a theme picker that is not the control: which themes
313 + /// exist, which group each is in, how legible each one is, and what order that
314 + /// puts them in. Three apps derived it three ways and two of them lost it
315 + /// entirely when their pickers were described, which is what makes it the
316 + /// crate's job rather than each app's.
317 + ///
318 + /// # The order
319 + ///
320 + /// By variant in [`Variant`]'s own order — light, dark, high contrast — then
321 + /// by measured contrast **best first**, then by name. The middle key is the one
322 + /// no app can supply without redoing the work this crate has already done: the
323 + /// tier comes off the resolved colours, and an app sorting a `Vec<ThemeMeta>`
324 + /// has only the names.
325 + ///
326 + /// Grouping is left implicit in the order rather than returned as groups. A
327 + /// renderer that draws headings walks the run of one variant; one that cannot
328 + /// draw headings still gets the useful order. Handing back
329 + /// `Vec<(Variant, Vec<ThemeOption>)>` would force the second renderer to
330 + /// flatten what the first wanted, and neither shape is more true.
331 + ///
332 + /// # What it costs
333 + ///
334 + /// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its
335 + /// metadata, once here for the colours the tier is measured from. Measured
336 + /// rather than assumed to be cheap: a picker is drawn on a settings screen, the
337 + /// shipped set is around twenty files, and the alternative is caching a
338 + /// derived value that a theme edited on disk would then be wrong about.
339 + /// A theme whose colours will not load keeps its metadata and reads as
340 + /// [`ContrastTier::Standard`], on the same footing as one missing a ground.
341 + ///
342 + /// A host whose themes are not all on disk builds its own [`ThemeOption`]s and
343 + /// calls [`order_theme_options`], which is this function's second half.
344 + #[must_use]
345 + pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
346 + let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs)
347 + .into_iter()
348 + .map(|meta| {
349 + let contrast = load_theme(dirs, &meta.id)
350 + .map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme));
351 + ThemeOption {
352 + variant: meta.kind(),
353 + contrast,
354 + id: meta.id,
355 + name: meta.name,
356 + }
357 + })
358 + .collect();
359 +
360 + order_theme_options(&mut options);
361 + options
362 + }
363 +
364 + /// Put an already-collected set into the order a picker offers them in.
365 + ///
366 + /// [`theme_options`]' second half, reachable on its own because not every host
367 + /// resolves its themes by scanning a directory. audiofiles embeds its shipped
368 + /// set at compile time and reads only its custom themes off disk, so a
369 + /// directory scan cannot see most of what it offers, and the alternative to
370 + /// this being public was that app re-deriving the sort — which is exactly the
371 + /// three-apps-three-orders state the picker was described to end.
372 + ///
373 + /// The order is by variant in [`Variant`]'s own order, then by measured
374 + /// contrast **best first**, then by name.
375 + pub fn order_theme_options(options: &mut [ThemeOption]) {
376 + options.sort_by(|a, b| {
377 + variant_order(a.variant)
378 + .cmp(&variant_order(b.variant))
379 + .then(b.contrast.cmp(&a.contrast))
380 + .then_with(|| a.name.cmp(&b.name))
381 + });
382 + }
383 +
384 + /// Where a variant sits in a picker, light first.
385 + ///
386 + /// Not `Variant as usize`: the declaration order of an enum is not a promise
387 + /// about how it reads, and a member inserted for a fourth variant would
388 + /// silently reorder every picker in the tree.
389 + const fn variant_order(variant: Variant) -> u8 {
390 + match variant {
391 + Variant::Light => 0,
392 + Variant::Dark => 1,
393 + Variant::HighContrast => 2,
394 + }
395 + }
396 +
397 + #[cfg(test)]
398 + mod tests {
399 + use super::*;
400 + use crate::bundled_themes_dir;
401 + use std::collections::HashMap;
402 +
403 + fn meta(id: &str, variant: &str) -> ThemeMeta {
404 + ThemeMeta {
405 + id: id.to_string(),
406 + name: id.to_string(),
407 + variant: variant.to_string(),
408 + is_custom: false,
409 + }
410 + }
411 +
412 + fn defaults() -> ThemeDefaults {
413 + ThemeDefaults::new("flatwhite", "nord")
414 + }
415 +
416 + // The three the shipped themes actually declare.
417 + #[test]
418 + fn every_shipped_variant_parses() {
419 + assert_eq!(Variant::parse("light"), Some(Variant::Light));
420 + assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
421 + assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
422 + assert_eq!(Variant::parse("sepia"), None);
423 + }
424 +
425 + // parse_meta already defaults a *missing* variant to dark, so an
426 + // unrecognized one reading as light would have the crate disagreeing with
427 + // itself. alloy_tui did exactly that before this existed.
428 + #[test]
429 + fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
430 + assert_eq!(Variant::from("sepia"), Variant::Dark);
431 + assert_eq!(Variant::from(""), Variant::Dark);
432 +
433 + let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
434 + assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
435 + }
436 +
437 + #[test]
438 + fn a_selection_round_trips_through_any_store() {
439 + for (stored, expect) in [
440 + (Some("system"), ThemeSelection::Follow),
441 + (None, ThemeSelection::Follow),
442 + (Some(""), ThemeSelection::Follow),
443 + (Some(" "), ThemeSelection::Follow),
444 + (Some("nord"), ThemeSelection::Fixed("nord".into())),
445 + ] {
446 + let parsed = ThemeSelection::parse(stored);
447 + assert_eq!(parsed, expect, "{stored:?}");
448 + assert_eq!(
449 + ThemeSelection::parse(Some(parsed.as_str())),
450 + expect,
451 + "what is written reads back as what was meant",
452 + );
453 + }
454 + }
455 +
456 + // Nothing saved is follow-the-system, which is what Balanced Breakfast
457 + // expressed as an absent value and GoingsOn as a sentinel. Both are now the
458 + // same thing.
459 + #[test]
460 + fn nothing_chosen_yet_is_follow() {
461 + assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
462 + }
463 +
464 + #[test]
465 + fn a_fixed_selection_wins_when_its_theme_is_installed() {
466 + let available = [meta("nord", "dark"), meta("flatwhite", "light")];
467 + let fixed = ThemeSelection::Fixed("nord".into());
468 + assert_eq!(
469 + fixed.resolve(Variant::Light, &defaults(), &available),
470 + "nord",
471 + "a chosen theme is not overridden by the ambient mode",
472 + );
473 + }
474 +
475 + // Themes are deletable in three of the four apps. Handing back an id that
476 + // will fail to load only moves the error somewhere less helpful.
477 + #[test]
478 + fn a_fixed_selection_whose_theme_is_gone_falls_back() {
479 + let available = [meta("nord", "dark"), meta("flatwhite", "light")];
480 + let fixed = ThemeSelection::Fixed("deleted".into());
481 + assert_eq!(
482 + fixed.resolve(Variant::Light, &defaults(), &available),
483 + "flatwhite",
484 + );
485 + }
486 +
487 + #[test]
488 + fn follow_picks_the_apps_default_for_the_ambient_mode() {
489 + let available = [meta("nord", "dark"), meta("flatwhite", "light")];
490 + let follow = ThemeSelection::Follow;
491 + assert_eq!(
492 + follow.resolve(Variant::Dark, &defaults(), &available),
493 + "nord",
494 + );
495 + assert_eq!(
496 + follow.resolve(Variant::Light, &defaults(), &available),
497 + "flatwhite",
498 + );
499 + }
500 +
Lines truncated
A src/sheet.rs +227
@@ -1,0 +1,227 @@
1 + //! Every theme in one sheet, keyed by a root attribute.
2 + //!
3 + //! The block above serves one theme: a consumer resolves the chosen id, renders
4 + //! `:root`, and links the result. Changing the pin then means rendering a new
5 + //! sheet and getting the document to re-link it, which an htmx navigation does
6 + //! not do -- so a pinned change landed at the next launch and the screen had to
7 + //! apologise for it in a hint.
8 + //!
9 + //! The fix is to stop encoding the choice in *which* sheet is linked. One sheet
10 + //! carries every theme, each behind `:root[data-theme="<id>"]`, and choosing is
11 + //! setting an attribute. No reload, no second request, and the picker can
12 + //! preview a theme by writing the attribute and undo by writing the old one.
13 + //!
14 + //! It is a separate emitter rather than a wider `intent_css_vars` because the
15 + //! bundle is not free: 31 themes of custom properties, against the one block a
16 + //! server-rendered page injects per response. MNW ships a single theme and must
17 + //! keep paying for a single theme, so this is opt-in by being its own call.
18 +
19 + use crate::{
20 + SemanticTokens, ThemeDefaults, ThemeSelection, Variant, intent_css_declarations,
21 + intent_css_vars, list_themes_from_dirs, load_semantic,
22 + };
23 + use std::path::PathBuf;
24 +
25 + /// The root attribute [`all_themes_css`] keys its blocks on.
26 + ///
27 + /// Stated here so a consumer's frontend and its stylesheet cannot disagree
28 + /// about the spelling; a picker writes this attribute on `document
29 + /// .documentElement` and nothing else has to change.
30 + pub const THEME_ATTRIBUTE: &str = "data-theme";
31 +
32 + /// Emit one theme's intent layer keyed by [`THEME_ATTRIBUTE`], as
33 + /// `:root[data-theme="<id>"] { … }`.
34 + ///
35 + /// The attribute selector outranks the bare `:root` of [`intent_css_vars`],
36 + /// including one inside a media query, so a sheet may carry an
37 + /// ambient-following default and let a pin override it without `!important`
38 + /// and without ordering games.
39 + pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
40 + format!(
41 + ":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n",
42 + intent_css_declarations(tokens)
43 + )
44 + }
45 +
46 + /// Every theme in `dirs` as one stylesheet: an ambient-following default, then
47 + /// a keyed block per theme.
48 + ///
49 + /// The sheet a consumer links once and never re-links. Setting
50 + /// [`THEME_ATTRIBUTE`] on the root element pins a theme; removing it, or
51 + /// setting it to anything that names no theme (`"system"`, say), falls back to
52 + /// the default blocks, which follow the OS through `prefers-color-scheme` and
53 + /// `prefers-contrast`. Those are the same three ambient modes
54 + /// [`ThemeSelection::resolve`] answers, so a sheet and a Rust-side resolution
55 + /// of the same selection agree.
56 + ///
57 + /// `defaults` names the app's own fallbacks. A high-contrast default is only
58 + /// emitted when [`ThemeDefaults::high_contrast`] named one: falling back to the
59 + /// dark theme is right for a resolution and wrong for a media query, where it
60 + /// would answer `prefers-contrast: more` with a theme that is not one.
61 + ///
62 + /// Themes that fail to load are skipped rather than failing the sheet: a
63 + /// consumer's custom directory is user-writable, and one unparseable file
64 + /// there should cost that file's block and nothing else.
65 + ///
66 + /// Blocks are ordered by id so the output is byte-stable, which is what lets a
67 + /// caller cache it or compare two builds.
68 + pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String {
69 + let available = list_themes_from_dirs(dirs);
70 + let mut out = String::new();
71 +
72 + let mut default_block = |variant: Variant, query: Option<&str>| {
73 + let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
74 + let Ok(tokens) = load_semantic(dirs, &id) else {
75 + return;
76 + };
77 + match query {
78 + None => out.push_str(&intent_css_vars(&tokens)),
79 + Some(query) => {
80 + out.push_str("\n@media (");
81 + out.push_str(query);
82 + out.push_str(") {\n");
83 + out.push_str(&intent_css_vars(&tokens));
84 + out.push_str("}\n");
85 + }
86 + }
87 + };
88 +
89 + default_block(Variant::Light, None);
90 + default_block(Variant::Dark, Some("prefers-color-scheme: dark"));
91 + if defaults.names_high_contrast() {
92 + default_block(Variant::HighContrast, Some("prefers-contrast: more"));
93 + }
94 +
95 + let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
96 + ids.sort_unstable();
97 + for id in ids {
98 + if let Ok(tokens) = load_semantic(dirs, id) {
99 + out.push('\n');
100 + out.push_str(&keyed_intent_css_vars(id, &tokens));
101 + }
102 + }
103 +
104 + out
105 + }
106 +
107 + #[cfg(test)]
108 + mod tests {
109 + use super::*;
110 + use crate::fixture::nord_toml;
111 + use crate::{bundled_themes_dir, parse_theme_str, resolve};
112 + use std::fs;
113 +
114 + // ---- every theme in one sheet ----
115 +
116 + /// The shipped themes, as the search path a consumer hands the emitter.
117 + fn shipped() -> Vec<(PathBuf, bool)> {
118 + vec![(
119 + bundled_themes_dir().expect("makeover ships its themes"),
120 + false,
121 + )]
122 + }
123 +
124 + #[test]
125 + fn a_keyed_block_carries_the_same_declarations_as_a_root_one() {
126 + let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
127 + let keyed = keyed_intent_css_vars("nord", &tokens);
128 + assert!(
129 + keyed.starts_with(":root[data-theme=\"nord\"] {\n"),
130 + "{keyed}"
131 + );
132 + assert_eq!(
133 + keyed.replace(":root[data-theme=\"nord\"]", ":root"),
134 + intent_css_vars(&tokens),
135 + "the two emitters differ only in the selector"
136 + );
137 + }
138 +
139 + #[test]
140 + fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
141 + let dirs = shipped();
142 + let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
143 +
144 + let keys: Vec<&str> = css
145 + .match_indices(":root[data-theme=\"")
146 + .map(|(at, prefix)| {
147 + let rest = &css[at + prefix.len()..];
148 + &rest[..rest.find('"').unwrap()]
149 + })
150 + .collect();
151 +
152 + let mut expected: Vec<String> = list_themes_from_dirs(&dirs)
153 + .into_iter()
154 + .map(|meta| meta.id)
155 + .collect();
156 + expected.sort();
157 + assert_eq!(keys, expected, "one block per theme, ordered by id");
158 + assert!(
159 + keys.len() > 20,
160 + "the shipped set is the whole picker: {keys:?}"
161 + );
162 + }
163 +
164 + /// The property the whole sheet exists for: a pin is an attribute, and it
165 + /// beats the ambient default without `!important` or ordering games.
166 + #[test]
167 + fn the_default_follows_the_system_and_a_pin_outranks_it() {
168 + let css = all_themes_css(
169 + &shipped(),
170 + &ThemeDefaults::new("goingson", "catppuccin-mocha"),
171 + );
172 +
173 + assert!(css.starts_with(":root {\n"), "the light default is first");
174 + assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n"));
175 +
176 + // Specificity, not order: (0,1,0) for the default against (0,2,0) for
177 + // a keyed block. Asserted as the fact that the keyed blocks follow the
178 + // defaults, which is the ordering that would matter if they tied.
179 + let dark = css.find("prefers-color-scheme").unwrap();
180 + let first_key = css.find(":root[data-theme=").unwrap();
181 + assert!(dark < first_key, "defaults, then the keyed blocks");
182 + }
183 +
184 + /// `for_variant` answers every mode by falling back to dark, so emitting a
185 + /// `prefers-contrast` block unconditionally would answer the preference
186 + /// with a theme that does not honour it.
187 + #[test]
188 + fn a_high_contrast_block_appears_only_when_one_was_named() {
189 + let dirs = shipped();
190 + let plain = ThemeDefaults::new("goingson", "catppuccin-mocha");
191 + assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
192 +
193 + let named = plain.clone().high_contrast("high-contrast");
194 + let css = all_themes_css(&dirs, &named);
195 + assert!(
196 + css.contains("@media (prefers-contrast: more) {\n:root {\n"),
197 + "{css}"
198 + );
199 + }
200 +
201 + /// A consumer's custom directory is user-writable, so one bad file there
202 + /// costs its own block and nothing else.
203 + #[test]
204 + fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() {
205 + let custom = tempfile::tempdir().unwrap();
206 + fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap();
207 + fs::write(
208 + custom.path().join("mine.toml"),
209 + "[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n",
210 + )
211 + .unwrap();
212 +
213 + let mut dirs = shipped();
214 + dirs.push((custom.path().to_path_buf(), true));
215 + let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
216 +
217 + assert!(
218 + css.contains(":root[data-theme=\"mine\"] {"),
219 + "a custom theme is switchable too"
220 + );
221 + assert!(!css.contains("data-theme=\"broken\""), "{css}");
222 + assert!(
223 + css.contains(":root[data-theme=\"nord\"] {"),
224 + "the rest of the sheet survives"
225 + );
226 + }
227 + }
@@ -1,0 +1,167 @@
1 + //! Typography — layer 1 of the house font model.
2 + //!
3 + //! Wiki `typography-standard`. The model is three layers: an app override, the
4 + //! house default, then a system generic, and this is the middle one. Two needs,
5 + //! two names, and no others in the suite:
6 + //!
7 + //! ```text
8 + //! --font-mono Quasi Mono -> monospace
9 + //! --font-sans Quasi Body -> sans-serif
10 + //! ```
11 + //!
12 + //! Both are cut by `quasi-type` from the Atkinson Hyperlegible superfamily plus
13 + //! the house glyph set. This crate does not cut them and cannot: quasi-type is
14 + //! `publish = false` and makeover is on crates.io, so the cut lives in each
15 + //! consumer's own build script (`quasi_type::cut`, taken as a git dependency,
16 + //! the way `shop-font` does it). What lives here is the vocabulary, which is
17 + //! the half that was scattered.
18 + //!
19 + //! Font is not a theme's business and none of this is themeable. A theme
20 + //! declares colour by role; nothing in a theme file names a face, and the two
21 + //! tokens below are the same in every theme. That is why they are constants
22 + //! rather than another section of `SemanticTokens`, and why they belong in a
23 + //! stylesheet generated once at build time rather than in the block that gets
24 + //! re-injected on a theme switch.
25 + //!
26 + //! The brand/display tier is out of scope, per product and by decision: Young
27 + //! Serif on MNW, Reglo in GoingsOn, Departure Mono on Alloy, audiofiles' logo
28 + //! face. No renderer emits them and no described screen resolves a token to
29 + //! one, so they keep their own `font-family` until the app-override layer
30 + //! lands and gives them a place to be declared.
31 +
32 + use crate::FontSlot;
33 +
34 + /// The mono slot: code, data, identifiers, cell grids, anything monospaced.
35 + pub const FONT_MONO: &str = "\"Quasi Mono\", monospace";
36 +
37 + /// The body / UI slot. Everything that is not the mono slot or brand tier.
38 + pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif";
39 +
40 + /// The family name inside [`FONT_MONO`], on its own, for a consumer that needs
41 + /// the name rather than the stack. A test asserts the two agree.
42 + pub const HOUSE_MONO_FAMILY: &str = "Quasi Mono";
43 +
44 + /// The family name inside [`FONT_SANS`]. See [`HOUSE_MONO_FAMILY`].
45 + pub const HOUSE_SANS_FAMILY: &str = "Quasi Body";
46 +
47 + /// The weight range both house faces carry.
48 + ///
49 + /// They are variable, `wght` 200-800, and a declaration that omits the range
50 + /// makes every weight resolve to the file's default instance — which is
51 + /// ExtraLight, because a cut keeps its base's default.
52 + pub const HOUSE_WEIGHT_RANGE: &str = "200 800";
53 +
54 + /// Filename a consumer writes the cut mono face to, under its own font URL.
55 + ///
56 + /// `quasi-type` writes `QuasiMono[wght].woff2`, naming the variable axis the
57 + /// way a font tool expects. Those brackets have to be percent-encoded to
58 + /// survive a URL and are a bug waiting to be written, so the web copy takes a
59 + /// plain name and the two places that have to agree — the build script that
60 + /// writes the file and the `@font-face` that fetches it — agree through this
61 + /// constant rather than by both spelling it out.
62 + pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2";
63 +
64 + /// Filename a consumer writes the cut body face to. See [`WEBFONT_MONO_FILE`].
65 + pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2";
66 +
67 + /// The house font tokens as CSS declarations (no selector), for a caller that
68 + /// is composing its own block.
69 + pub fn typography_css_declarations() -> String {
70 + format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n")
71 + }
72 +
73 + /// The house font tokens as a `:root { … }` block.
74 + ///
75 + /// Inlined by surfaces that cannot link a stylesheet — the MNW embeds are the
76 + /// live case — and written to a file by everything else, through
77 + /// `makeover_build::typography_css`.
78 + pub fn typography_css_vars() -> String {
79 + format!(":root {{\n{}}}\n", typography_css_declarations())
80 + }
81 +
82 + /// The `@font-face` rules for both slots, fetching from `base_url`.
83 + ///
84 + /// `base_url` is the directory the consumer serves its fonts from, without a
85 + /// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri
86 + /// frontend loading relative to its index.
87 + ///
88 + /// # `font-weight: 200 800`, which is the part that bites
89 + ///
90 + /// Both faces are variable over `wght` 200-800 in one file, and the mono
91 + /// face's **default instance is ExtraLight** — that is upstream Atkinson's
92 + /// default and the cut keeps the axis rather than pinning a master, so a
93 + /// consumer that loads the file and takes what it opens at draws its whole UI
94 + /// at 200. Declaring the range here is what makes the browser resolve `normal`
95 + /// to 400 and `bold` to 700 instead. shop hit the same trap from the other
96 + /// side and names `wght` 400 explicitly in its shaper; this is the web's
97 + /// version of that fix, stated once for every consumer.
98 + ///
99 + /// `font-display: swap` on both: the faces are 31KB and 50KB, they are cached
100 + /// hard after the first paint, and a flash of the fallback beats invisible
101 + /// text either way.
102 + pub fn font_face_css(base_url: &str) -> String {
103 + // Rendered from the same `FontFace` a product override uses, rather than
104 + // written out here a second time. It used to be a format string, which is
105 + // why the house tier could be emitted and not read.
106 + let base = base_url.trim_end_matches('/');
107 + FontSlot::ALL
108 + .iter()
109 + .filter_map(|slot| slot.house_face())
110 + .map(|face| face.css(base))
111 + .collect()
112 + }
113 +
114 + #[cfg(test)]
115 + mod tests {
116 + use super::*;
117 +
118 + // ---- typography ----
119 +
120 + #[test]
121 + fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() {
122 + let css = typography_css_vars();
123 + assert!(css.starts_with(":root {\n"));
124 + assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n"));
125 + assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n"));
126 +
127 + // Layer 2 is one hop and no further. A third entry in either stack is
128 + // the shape the standard exists to delete: a chain nobody can predict
129 + // the metrics of, which is what `--font-sans: -apple-system,
130 + // BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps.
131 + for stack in [FONT_MONO, FONT_SANS] {
132 + assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop");
133 + }
134 +
135 + // Two tokens, and no others. `--font-body`, `--font-heading` and
136 + // `--font-display` are gone or out of scope; a token appearing here
137 + // is a fifth answer to a question that has two.
138 + assert_eq!(css.matches("--font-").count(), 2);
139 + }
140 +
141 + #[test]
142 + fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() {
143 + let css = font_face_css("/static/fonts");
144 +
145 + assert_eq!(css.matches("@font-face").count(), 2);
146 + assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");"));
147 + assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");"));
148 +
149 + // The trap. Atkinson Hyperlegible Mono's default instance is
150 + // ExtraLight and the cut keeps the axis, so a `@font-face` that omits
151 + // the range draws the whole UI at 200.
152 + assert_eq!(css.matches("font-weight: 200 800;").count(), 2);
153 +
154 + // The families have to be exactly what the tokens ask for, or the
155 + // stack falls through to the generic and the face is dead weight.
156 + for family in [FONT_MONO, FONT_SANS] {
157 + let quoted = family.split(',').next().unwrap();
158 + assert!(css.contains(&format!("font-family: {quoted};")));
159 + }
160 + }
161 +
162 + #[test]
163 + fn a_trailing_slash_on_the_base_url_does_not_double_it() {
164 + assert_eq!(font_face_css("fonts/"), font_face_css("fonts"));
165 + assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")"));
166 + }
167 + }