Skip to main content

max / makeover

166.3 KB · 4327 lines History Blame Raw
1 //! Shared theme loading + intent resolution for TOML-based theme files.
2 //!
3 //! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the
4 //! MNW web server. Themes are authored by **intent** ("human design"): colors are
5 //! declared by role (surface / content / action / status / line / category), not
6 //! by hue. This crate is the single place that resolves an authored theme into a
7 //! full set of intent tokens, including the derived interactive states
8 //! (hover/active/selection/row-stripe/contrast), and emits them as CSS
9 //! variables or RGB tuples. No app recomputes them itself.
10 //!
11 //! Theme file shape:
12 //! ```text
13 //! [meta]
14 //! name = "Nord"
15 //! variant = "dark" # or "light"
16 //!
17 //! [surface] # container backgrounds by role/elevation
18 //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252"
19 //!
20 //! [content] # the ink. Its emphasis steps are derived, not authored:
21 //! primary = "#d8dee9" # `content-secondary` and `content-muted` are tonal
22 //! # steps of this toward `surface.page`. See `Emphasis`.
23 //!
24 //! [action] # interactive / brand color
25 //! primary = "#81a1c1"
26 //!
27 //! [status] # state semantics
28 //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0"
29 //!
30 //! [line]
31 //! border = "#4c566a"
32 //!
33 //! [category] # distinct decorative colors for tags/badges/charts
34 //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1"
35 //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0"
36 //! ```
37
38 // Color-space math: single-letter channel names (r/g/b/l/m/s) and the published
39 // high-precision OKLab/sRGB matrix constants are the domain vocabulary here.
40 #![allow(clippy::many_single_char_names, clippy::unreadable_literal)]
41
42 use serde::Serialize;
43 use std::collections::{BTreeMap, HashMap};
44 use std::path::{Path, PathBuf};
45
46 /// The color sections an authored theme may declare.
47 pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
48
49 /// Theme metadata parsed from the `[meta]` section.
50 #[derive(Debug, Clone, Serialize)]
51 #[serde(rename_all = "camelCase")]
52 pub struct ThemeMeta {
53 pub id: String,
54 pub name: String,
55 pub variant: String,
56 pub is_custom: bool,
57 }
58
59 /// A loaded theme: metadata plus the authored colors, flattened to dotted keys
60 /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`).
61 #[derive(Debug, Serialize)]
62 #[serde(rename_all = "camelCase")]
63 pub struct ThemeColors {
64 pub meta: ThemeMeta,
65 pub colors: HashMap<String, String>,
66 }
67
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.
515 ///
516 /// Anything that is not `dark`, including `high-contrast`, follows the light
517 /// anchors.
518 fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
519 let dark = variant == "dark";
520 Some(match (index, dark) {
521 (0, false) => "content.primary", // darkest text tone
522 (0, true) => "surface.sunken", // darkest surface
523 (7, false) => "surface.raised", // the login card
524 (7, true) => "content.secondary", // a readable light tone
525 (8, _) => "content.muted", // muted chrome, either way
526 (15, false) => "surface.overlay", // lightest surface
527 (15, true) => "content.primary", // lightest text tone
528 _ => return None,
529 })
530 }
531
532 /// The authored intent painting ANSI slot `index` under a theme of `variant`,
533 /// as a dotted key into [`ThemeColors::colors`].
534 ///
535 /// `None` for an index outside 0-15. Every slot in range resolves, so a caller
536 /// that has the intent can fill all sixteen.
537 ///
538 /// This is what makes a bare console, a terminal emulator and a generated
539 /// config agree on what red means. They disagreed for as long as each kept its
540 /// own table.
541 #[must_use]
542 pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
543 achromatic_slot(index, variant).or_else(|| {
544 CHROMATIC
545 .iter()
546 .find(|(slot, _)| *slot == index)
547 .map(|(_, intent)| *intent)
548 })
549 }
550
551 const fn build_ansi_256() -> [Rgb; 256] {
552 let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
553
554 let mut i = 0;
555 while i < 16 {
556 table[i] = ANSI_16[i];
557 i += 1;
558 }
559
560 // The cube's six levels are not evenly spaced. The step from black to the
561 // first is more than twice any later one, which is xterm's arrangement
562 // rather than a choice available here, and it is why the darkest tones a
563 // theme can reach on 256 colors come from the gray ramp instead.
564 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
565 let mut r = 0;
566 while r < 6 {
567 let mut g = 0;
568 while g < 6 {
569 let mut b = 0;
570 while b < 6 {
571 table[16 + 36 * r + 6 * g + b] = Rgb {
572 r: LEVELS[r],
573 g: LEVELS[g],
574 b: LEVELS[b],
575 };
576 b += 1;
577 }
578 g += 1;
579 }
580 r += 1;
581 }
582
583 // 8 to 238 in steps of 10. Neither end is black or white; both of those are
584 // in the cube, so the ramp is 24 steps of gray between them rather than 24
585 // steps of the whole range.
586 let mut k = 0;
587 while k < 24 {
588 let v = 8 + 10 * k as u8;
589 table[232 + k as usize] = Rgb { r: v, g: v, b: v };
590 k += 1;
591 }
592
593 table
594 }
595
596 /// The contrast ratio two colors must clear to read as separate areas.
597 ///
598 /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
599 /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
600 /// text can ask for more by checking [`wcag_contrast`] itself.
601 pub const DISTINCT: f32 = 3.0;
602
603 /// Perceptual distance between two colors, for choosing the closest of a set.
604 fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
605 let (x, y) = (a.to_oklab(), b.to_oklab());
606 ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
607 }
608
609 /// Index of the entry in `palette` that looks most like `c`.
610 ///
611 /// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
612 /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
613 /// nearest match computed in it picks visibly wrong entries in the mid tones.
614 ///
615 /// # Panics
616 ///
617 /// If `palette` is empty.
618 pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
619 assert!(!palette.is_empty(), "a palette needs at least one color");
620 let mut best = 0;
621 let mut best_distance = f32::INFINITY;
622 for (index, entry) in palette.iter().enumerate() {
623 let distance = oklab_distance(c, *entry);
624 if distance < best_distance {
625 best = index;
626 best_distance = distance;
627 }
628 }
629 best
630 }
631
632 /// Index of the entry in `palette` closest to `fg` that still reads against
633 /// `bg`.
634 ///
635 /// [`quantize`] answers about one color at a time, and two colors that differ
636 /// can quantize to the same entry: a themed page and a border drawn on it are
637 /// often a few steps apart in a 24-bit theme and land together on a 16-color
638 /// terminal, leaving one flat area where there was a frame. Alloy's console
639 /// showed exactly this, and it is not a contrived pairing: a light page and the
640 /// mid-tone border derived from it both land on index 7.
641 ///
642 /// So the background is quantized first, because what the border must be
643 /// distinguished from is the entry the terminal will actually paint, not the
644 /// color the theme asked for. Then the nearest entry to `fg` clearing
645 /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
646 /// furthest does: at that point the palette cannot honor the design, and the
647 /// most legible approximation beats the closest invisible one.
648 ///
649 /// Only for colors whose whole job is to be told apart from their background.
650 /// Applied to every token it would push a deliberately quiet one until it
651 /// shouted.
652 ///
653 /// # Panics
654 ///
655 /// If `palette` is empty.
656 pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
657 assert!(!palette.is_empty(), "a palette needs at least one color");
658 let shown = palette[quantize(bg, palette)];
659
660 let mut order: Vec<usize> = (0..palette.len()).collect();
661 order.sort_by(|a, b| {
662 oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b]))
663 });
664
665 order
666 .iter()
667 .copied()
668 .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
669 .unwrap_or_else(|| {
670 order
671 .iter()
672 .copied()
673 .max_by(|a, b| {
674 wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown))
675 })
676 .expect("the palette is not empty")
677 })
678 }
679
680 // ============================================================================
681 // Intent resolution
682 // ============================================================================
683
684 /// Base intents: (TOML dotted source key, canonical token key). The token key
685 /// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key.
686 ///
687 /// Read straight from the loaded theme, which is not quite the same as read
688 /// from the file: `content.secondary` and `content.muted` are tonal steps of
689 /// `content.primary` and are filled in at load by [`derive_tonal_steps`], so
690 /// they arrive here already computed and take this path like any other.
691 pub const BASE_INTENTS: &[(&str, &str)] = &[
692 ("surface.page", "surface-page"),
693 ("surface.raised", "surface-raised"),
694 ("surface.sunken", "surface-sunken"),
695 ("surface.overlay", "surface-overlay"),
696 ("content.primary", "content"),
697 ("content.secondary", "content-secondary"),
698 ("content.muted", "content-muted"),
699 ("action.primary", "action"),
700 ("status.danger", "danger"),
701 ("status.success", "success"),
702 ("status.warning", "warning"),
703 ("status.info", "info"),
704 ("line.border", "border"),
705 ("category.one", "category-one"),
706 ("category.two", "category-two"),
707 ("category.three", "category-three"),
708 ("category.four", "category-four"),
709 ("category.five", "category-five"),
710 ("category.six", "category-six"),
711 ];
712
713 /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
714 /// Includes both authored base intents and the computed derived intents.
715 #[derive(Debug, Clone, Serialize)]
716 #[serde(rename_all = "camelCase")]
717 pub struct SemanticTokens {
718 pub meta: ThemeMeta,
719 /// token-key → resolved hex. Stable, deterministic ordering.
720 pub intents: BTreeMap<String, String>,
721 }
722
723 impl SemanticTokens {
724 /// Resolved hex for a token key, if present.
725 pub fn hex(&self, key: &str) -> Option<&str> {
726 self.intents.get(key).map(String::as_str)
727 }
728
729 /// Resolved RGB tuple for a token key (for egui / native consumers).
730 ///
731 /// `None` for a translucent token. Two intents are emitted as `rgba(...)`
732 /// rather than hex, `overlay` and `elevation`, and dropping the alpha would
733 /// hand a native consumer an opaque near-black where it asked for a scrim.
734 /// Those want [`rgba`](Self::rgba).
735 pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
736 self.intents
737 .get(key)
738 .and_then(|h| Rgb::from_hex(h))
739 .map(Rgb::tuple)
740 }
741
742 /// Resolved RGBA tuple for a token key, alpha as 0-255.
743 ///
744 /// Reads both spellings, so a caller that does not care whether an intent
745 /// happens to be translucent can use this for everything: an opaque token
746 /// comes back at 255.
747 ///
748 /// It exists because a CSS consumer can take `rgba(...)` as a string
749 /// straight out of [`hex`](Self::hex) and a native one cannot. Without it
750 /// the two translucent intents are reachable from a stylesheet and from
751 /// nowhere else, which is the coupling deriving in the crate was meant to
752 /// avoid.
753 pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> {
754 let value = self.intents.get(key)?;
755 if let Some(rgb) = Rgb::from_hex(value) {
756 let (r, g, b) = rgb.tuple();
757 return Some((r, g, b, 255));
758 }
759 let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?;
760 let mut parts = inner.split(',').map(str::trim);
761 let r = parts.next()?.parse().ok()?;
762 let g = parts.next()?.parse().ok()?;
763 let b = parts.next()?.parse().ok()?;
764 let alpha: f32 = parts.next()?.parse().ok()?;
765 if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) {
766 return None;
767 }
768 Some((r, g, b, (alpha * 255.0).round() as u8))
769 }
770 }
771
772 /// Resolve an authored theme into the full intent token set.
773 ///
774 /// 1. Copy each present base intent from the authored colors.
775 /// 2. Compute the derived interactive states from the base intents, so every
776 /// consumer gets identical output.
777 ///
778 /// Each derived token is emitted only when its source intents exist, mirroring
779 /// the skip-missing behavior of the rest of the crate.
780 pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
781 let mut intents: BTreeMap<String, String> = BTreeMap::new();
782
783 // 1. Base intents (authored). Copy only values that parse as a hex color and
784 // re-emit them in canonical `#rrggbb` form, so an authored value can never
785 // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
786 // raw into a `<style>` block by the web server). A malformed value is skipped,
787 // mirroring the skip-missing behavior for absent intents.
788 for (src, token) in BASE_INTENTS {
789 if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
790 intents.insert((*token).to_string(), rgb.to_hex());
791 }
792 }
793
794 // Helper: parse an already-resolved token to Rgb.
795 let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
796
797 // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
798 // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
799 let mut derived: Vec<(String, Rgb)> = Vec::new();
800 if let Some(action) = get(&intents, "action") {
801 derived.push(("action-hover".into(), lighten(action, 0.05)));
802 derived.push(("content-on-action".into(), readable_on(action)));
803 // The focus ring is the action colour itself, not a tint of it: a ring
804 // is a statement that the keyboard is here, and a faded one reads as a
805 // disabled control rather than an emphatic one.
806 //
807 // One ring, not one per primitive. Where the ring sits is a depth
808 // question and not a per-component choice: a well takes it inside its
809 // own edge and a raised surface takes it outside. That is one decision
810 // with two renderings rather than one decision per component, which is
811 // how the three apps ended up with three rings. This token is the one
812 // shared artifact; which thing wears it, and how it is drawn, is each
813 // renderer's own (see `makeover_layout`'s crate header, "reach, focus
814 // and the focus ring").
815 derived.push(("focus-ring".into(), action));
816 }
817 if let Some(page) = get(&intents, "surface-page") {
818 // Modal scrim: a near-black tone carrying a faint hint of the theme's
819 // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
820 // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
821 // it is inserted directly rather than through the hex loop below.
822 let mut o = page.to_oklab();
823 o.l = 0.08;
824 let s = Rgb::from_oklab(o);
825 intents.insert(
826 "overlay".into(),
827 format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
828 );
829
830 // What a surface that FLOATS OVER the page is cast onto it with.
831 //
832 // The one intent here about a surface's relationship to the page rather
833 // than about the surface itself, which is why it is derived from `page`
834 // and not from `surface-raised`. A shadow is not the thing, it is the
835 // absence of light on what is behind the thing.
836 //
837 // SCOPE, and it is the whole point of this intent existing rather than
838 // a general "shadow": a surface that overlays the page takes this, a
839 // surface IN the page takes a bevel. Menus, toasts, popovers and
840 // dropdowns overlay. A card, a plate and a framed image do not, and
841 // reaching for this on one of those is how a pre-Platinum look survives
842 // a conversion wearing a token's name. `.raised` is the answer there.
843 //
844 // Same anchor as the scrim above and for the same reason: a tone read
845 // off the theme's hue but pinned very dark, so it reads as absence of
846 // light on a light theme and on a dark one alike. A shadow tinted to a
847 // dark theme's own lightness would not be a shadow.
848 //
849 // The alpha is the only number here that is a look decision rather than
850 // a derivation. 0.18 sits between the two literal scales it replaces:
851 // the MNW server's --shadow-2 (0.10) reads as nothing under a menu, and
852 // its --shadow-3 (0.15) was measured invisible at plate size. Geometry
853 // stays with the consumer, the way bevel thickness does.
854 intents.insert(
855 "elevation".into(),
856 format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b),
857 );
858 }
859 if let Some(raised) = get(&intents, "surface-raised") {
860 // The two edges of a bevel: a raised control is lit from the top left,
861 // so its top and left edges take `bevel-light` and its bottom and right
862 // edges `bevel-dark`. Inverting the pair gives a pressed state and an
863 // inset well, which is what makes the idiom cheap for a consumer.
864 //
865 // Derived here rather than composed per-app because the two webviews
866 // could do it in `color-mix()` and audiofiles, which is egui, could not.
867 // Geometry (thickness, radius, which side gets which) stays app-side.
868 //
869 // The deltas are asymmetric because the eye is: an equal step down reads
870 // as a smaller change than the same step up, so the shadow is cut deeper
871 // than the highlight is raised.
872 //
873 // A face already at the top of the ramp cannot hold a highlight — the
874 // lightening clamps and the control bevels on two sides without ever
875 // resolving as lit. That is a property of the theme, not of this
876 // derivation; `bevel_edges_are_distinct_from_their_face` names the
877 // shipped themes it currently bites.
878 derived.push(("bevel-light".into(), lighten(raised, 0.14)));
879 derived.push(("bevel-dark".into(), darken(raised, 0.18)));
880
881 // An inset well: the content surface inside a raised container, so a
882 // list reads as content in a container rather than as bands on a panel.
883 // `surface-sunken` cannot serve, because a theme is free to author it
884 // darker than raised (goingson does) and a well has to go the other way.
885 //
886 // Which way is "the other way" depends on the theme, and this is the one
887 // derivation here that inverts. A well is lighter than its face on a
888 // light theme and darker on a dark one, where the bevel pair sidesteps
889 // the question by emitting both directions at once.
890 //
891 // Read the direction off `content` rather than off `Variant`. A theme
892 // whose text is dark is a theme whose surfaces are light, whatever its
893 // `variant` field claims, so this resolves correctly even when that
894 // field is wrong and it keeps the branch on measured color rather than
895 // on metadata.
896 //
897 // Deltas are asymmetric for the same reason the bevel's are, and smaller
898 // than the bevel's because a well is an area rather than an edge. The
899 // step up is the specimen's, measured: #D9DDF4 to #F3F5FD is 0.069.
900 //
901 // A face at the top of its ramp cannot hold a lighter well, the same
902 // clamp `bevel-light` hits; `well_is_visible_against_its_face` names the
903 // shipped themes where it bites.
904 if let Some(content) = get(&intents, "content") {
905 let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
906 let well = if content_is_darker {
907 lighten(raised, 0.07)
908 } else {
909 darken(raised, 0.09)
910 };
911 derived.push(("surface-well".into(), well));
912 }
913 }
914 if let Some(sunken) = get(&intents, "surface-sunken") {
915 derived.push(("hover-surface".into(), sunken));
916 }
917 if let Some(border) = get(&intents, "border") {
918 derived.push(("border-strong".into(), darken(border, 0.05)));
919 }
920
921 for (token, rgb) in derived {
922 intents.insert(token, rgb.to_hex());
923 }
924
925 SemanticTokens {
926 meta: theme.meta.clone(),
927 intents,
928 }
929 }
930
931 /// Emit the resolved intent layer as CSS declarations (no selector), one
932 /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
933 pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
934 let mut out = String::new();
935 for (token, hex) in &tokens.intents {
936 out.push_str(" --");
937 out.push_str(token);
938 out.push_str(": ");
939 out.push_str(hex);
940 out.push_str(";\n");
941 }
942 out
943 }
944
945 /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
946 /// CSS mapping every web surface injects.
947 pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
948 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
949 }
950
951 // ============================================================================
952 // Every theme in one sheet, keyed by a root attribute.
953 //
954 // The block above serves one theme: a consumer resolves the chosen id, renders
955 // `:root`, and links the result. Changing the pin then means rendering a new
956 // sheet and getting the document to re-link it, which an htmx navigation does
957 // not do -- so a pinned change landed at the next launch and the screen had to
958 // apologise for it in a hint.
959 //
960 // The fix is to stop encoding the choice in *which* sheet is linked. One sheet
961 // carries every theme, each behind `:root[data-theme="<id>"]`, and choosing is
962 // setting an attribute. No reload, no second request, and the picker can
963 // preview a theme by writing the attribute and undo by writing the old one.
964 //
965 // It is a separate emitter rather than a wider `intent_css_vars` because the
966 // bundle is not free: 31 themes of custom properties, against the one block a
967 // server-rendered page injects per response. MNW ships a single theme and must
968 // keep paying for a single theme, so this is opt-in by being its own call.
969 // ============================================================================
970
971 /// The root attribute [`all_themes_css`] keys its blocks on.
972 ///
973 /// Stated here so a consumer's frontend and its stylesheet cannot disagree
974 /// about the spelling; a picker writes this attribute on `document
975 /// .documentElement` and nothing else has to change.
976 pub const THEME_ATTRIBUTE: &str = "data-theme";
977
978 /// Emit one theme's intent layer keyed by [`THEME_ATTRIBUTE`], as
979 /// `:root[data-theme="<id>"] { … }`.
980 ///
981 /// The attribute selector outranks the bare `:root` of [`intent_css_vars`],
982 /// including one inside a media query, so a sheet may carry an
983 /// ambient-following default and let a pin override it without `!important`
984 /// and without ordering games.
985 pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
986 format!(
987 ":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n",
988 intent_css_declarations(tokens)
989 )
990 }
991
992 /// Every theme in `dirs` as one stylesheet: an ambient-following default, then
993 /// a keyed block per theme.
994 ///
995 /// The sheet a consumer links once and never re-links. Setting
996 /// [`THEME_ATTRIBUTE`] on the root element pins a theme; removing it, or
997 /// setting it to anything that names no theme (`"system"`, say), falls back to
998 /// the default blocks, which follow the OS through `prefers-color-scheme` and
999 /// `prefers-contrast`. Those are the same three ambient modes
1000 /// [`ThemeSelection::resolve`] answers, so a sheet and a Rust-side resolution
1001 /// of the same selection agree.
1002 ///
1003 /// `defaults` names the app's own fallbacks. A high-contrast default is only
1004 /// emitted when [`ThemeDefaults::high_contrast`] named one: falling back to the
1005 /// dark theme is right for a resolution and wrong for a media query, where it
1006 /// would answer `prefers-contrast: more` with a theme that is not one.
1007 ///
1008 /// Themes that fail to load are skipped rather than failing the sheet: a
1009 /// consumer's custom directory is user-writable, and one unparseable file
1010 /// there should cost that file's block and nothing else.
1011 ///
1012 /// Blocks are ordered by id so the output is byte-stable, which is what lets a
1013 /// caller cache it or compare two builds.
1014 pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String {
1015 let available = list_themes_from_dirs(dirs);
1016 let mut out = String::new();
1017
1018 let mut default_block = |variant: Variant, query: Option<&str>| {
1019 let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
1020 let Ok(tokens) = load_semantic(dirs, &id) else {
1021 return;
1022 };
1023 match query {
1024 None => out.push_str(&intent_css_vars(&tokens)),
1025 Some(query) => {
1026 out.push_str("\n@media (");
1027 out.push_str(query);
1028 out.push_str(") {\n");
1029 out.push_str(&intent_css_vars(&tokens));
1030 out.push_str("}\n");
1031 }
1032 }
1033 };
1034
1035 default_block(Variant::Light, None);
1036 default_block(Variant::Dark, Some("prefers-color-scheme: dark"));
1037 if defaults.names_high_contrast() {
1038 default_block(Variant::HighContrast, Some("prefers-contrast: more"));
1039 }
1040
1041 let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
1042 ids.sort_unstable();
1043 for id in ids {
1044 if let Ok(tokens) = load_semantic(dirs, id) {
1045 out.push('\n');
1046 out.push_str(&keyed_intent_css_vars(id, &tokens));
1047 }
1048 }
1049
1050 out
1051 }
1052
1053 // ============================================================================
1054 // Typography — layer 1 of the house font model.
1055 //
1056 // Wiki `typography-standard`. The model is three layers: an app override, the
1057 // house default, then a system generic, and this is the middle one. Two needs,
1058 // two names, and no others in the suite:
1059 //
1060 // --font-mono Quasi Mono -> monospace
1061 // --font-sans Quasi Body -> sans-serif
1062 //
1063 // Both are cut by `quasi-type` from the Atkinson Hyperlegible superfamily plus
1064 // the house glyph set. This crate does not cut them and cannot: quasi-type is
1065 // `publish = false` and makeover is on crates.io, so the cut lives in each
1066 // consumer's own build script (`quasi_type::cut`, taken as a git dependency,
1067 // the way `shop-font` does it). What lives here is the vocabulary, which is
1068 // the half that was scattered.
1069 //
1070 // Font is not a theme's business and none of this is themeable. A theme
1071 // declares colour by role; nothing in a theme file names a face, and the two
1072 // tokens below are the same in every theme. That is why they are constants
1073 // rather than another section of `SemanticTokens`, and why they belong in a
1074 // stylesheet generated once at build time rather than in the block that gets
1075 // re-injected on a theme switch.
1076 //
1077 // The brand/display tier is out of scope, per product and by decision: Young
1078 // Serif on MNW, Reglo in GoingsOn, Departure Mono on Alloy, audiofiles' logo
1079 // face. No renderer emits them and no described screen resolves a token to
1080 // one, so they keep their own `font-family` until the app-override layer
1081 // lands and gives them a place to be declared.
1082 // ============================================================================
1083
1084 /// The mono slot: code, data, identifiers, cell grids, anything monospaced.
1085 pub const FONT_MONO: &str = "\"Quasi Mono\", monospace";
1086
1087 /// The body / UI slot. Everything that is not the mono slot or brand tier.
1088 pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif";
1089
1090 /// The family name inside [`FONT_MONO`], on its own, for a consumer that needs
1091 /// the name rather than the stack. A test asserts the two agree.
1092 pub const HOUSE_MONO_FAMILY: &str = "Quasi Mono";
1093
1094 /// The family name inside [`FONT_SANS`]. See [`HOUSE_MONO_FAMILY`].
1095 pub const HOUSE_SANS_FAMILY: &str = "Quasi Body";
1096
1097 /// The weight range both house faces carry.
1098 ///
1099 /// They are variable, `wght` 200-800, and a declaration that omits the range
1100 /// makes every weight resolve to the file's default instance — which is
1101 /// ExtraLight, because a cut keeps its base's default.
1102 pub const HOUSE_WEIGHT_RANGE: &str = "200 800";
1103
1104 /// Filename a consumer writes the cut mono face to, under its own font URL.
1105 ///
1106 /// `quasi-type` writes `QuasiMono[wght].woff2`, naming the variable axis the
1107 /// way a font tool expects. Those brackets have to be percent-encoded to
1108 /// survive a URL and are a bug waiting to be written, so the web copy takes a
1109 /// plain name and the two places that have to agree — the build script that
1110 /// writes the file and the `@font-face` that fetches it — agree through this
1111 /// constant rather than by both spelling it out.
1112 pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2";
1113
1114 /// Filename a consumer writes the cut body face to. See [`WEBFONT_MONO_FILE`].
1115 pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2";
1116
1117 /// The house font tokens as CSS declarations (no selector), for a caller that
1118 /// is composing its own block.
1119 pub fn typography_css_declarations() -> String {
1120 format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n")
1121 }
1122
1123 /// The house font tokens as a `:root { … }` block.
1124 ///
1125 /// Inlined by surfaces that cannot link a stylesheet — the MNW embeds are the
1126 /// live case — and written to a file by everything else, through
1127 /// `makeover_build::typography_css`.
1128 pub fn typography_css_vars() -> String {
1129 format!(":root {{\n{}}}\n", typography_css_declarations())
1130 }
1131
1132 /// The `@font-face` rules for both slots, fetching from `base_url`.
1133 ///
1134 /// `base_url` is the directory the consumer serves its fonts from, without a
1135 /// trailing slash: `/static/fonts` on the MNW server, `fonts` for a Tauri
1136 /// frontend loading relative to its index.
1137 ///
1138 /// # `font-weight: 200 800`, which is the part that bites
1139 ///
1140 /// Both faces are variable over `wght` 200-800 in one file, and the mono
1141 /// face's **default instance is ExtraLight** — that is upstream Atkinson's
1142 /// default and the cut keeps the axis rather than pinning a master, so a
1143 /// consumer that loads the file and takes what it opens at draws its whole UI
1144 /// at 200. Declaring the range here is what makes the browser resolve `normal`
1145 /// to 400 and `bold` to 700 instead. shop hit the same trap from the other
1146 /// side and names `wght` 400 explicitly in its shaper; this is the web's
1147 /// version of that fix, stated once for every consumer.
1148 ///
1149 /// `font-display: swap` on both: the faces are 31KB and 50KB, they are cached
1150 /// hard after the first paint, and a flash of the fallback beats invisible
1151 /// text either way.
1152 pub fn font_face_css(base_url: &str) -> String {
1153 // Rendered from the same `FontFace` a product override uses, rather than
1154 // written out here a second time. It used to be a format string, which is
1155 // why the house tier could be emitted and not read.
1156 let base = base_url.trim_end_matches('/');
1157 FontSlot::ALL
1158 .iter()
1159 .filter_map(|slot| slot.house_face())
1160 .map(|face| face.css(base))
1161 .collect()
1162 }
1163
1164 // ============================================================================
1165 // Typography — layer 0, the app override.
1166 //
1167 // Wiki `typography-standard`, GO makeover `174ab3c1`. Layer 1 above is what
1168 // every product shares; this is the one declaration a product is allowed to
1169 // make for itself:
1170 //
1171 // layer 0 app override per product, optional MNW display -> Young Serif
1172 // layer 1 house default the quasi-* slot font quasi-mono -> Quasi Mono
1173 // layer 2 system generic one hop, no further monospace / sans-serif
1174 //
1175 // The brand tier was already exempt by decision (`cdf8ac09`), and the exemption
1176 // was enforced by those faces simply not being in the vocabulary — so each
1177 // product reached its own face through a hardcoded `font-family` and an
1178 // `@font-face` block it maintained by hand, which is the exact shape the
1179 // unification is deleting everywhere else. This turns the carve-out into a
1180 // mechanism: the per-product face is declared once, in the build script that
1181 // already writes the typography layer, and is readable as an override rather
1182 // than as a stylesheet nobody unified.
1183 //
1184 // It permits overriding `mono` and `sans` too. No product wants that today,
1185 // and a layer that only allows overriding the slot nobody describes is not a
1186 // layer, it is the exemption restated.
1187 //
1188 // **One declaration per product per slot.** [`Typography::with_override`]
1189 // panics on a second override of the same slot rather than letting the last
1190 // one win: a product with two answers for a slot has the vocabulary wrong, and
1191 // that is the thing to fix.
1192 //
1193 // # What a renderer does when it cannot honour one
1194 //
1195 // Declare once, renderers honour what they can. Today only the webview surface
1196 // has a face to honour at all — neither `makeover-tui` nor `makeover-immediate`
1197 // emits a `font-family` from anywhere, because the terminal owns the face in
1198 // one and the app loads its own font stack in the other. So an override is
1199 // honoured by the generated stylesheet and ignored, silently and correctly, by
1200 // the other two. That last clause was too strong and 2.10.0 corrected it: egui
1201 // can reach a face perfectly well, it just needs the file rather than a stack.
1202 // audiofiles honours its override with no stylesheet anywhere in the path. A renderer that gains font control later reads
1203 // [`Typography::resolve`] rather than the CSS, which is why the resolution is
1204 // a method on the data and not a string-building detail. Loading a file needs
1205 // one thing more than the stack — the family name and the source to load it
1206 // from — so [`Typography::faces`] is the same data read the other way, and
1207 // between them an egui or TUI surface can honour an override without a
1208 // stylesheet anywhere in the path. audiofiles is the first to do it.
1209 // ============================================================================
1210
1211 /// A slot in the house font vocabulary — the unit an override replaces.
1212 ///
1213 /// Three, and the third is deliberately empty by default: `display` is the
1214 /// brand tier, it has no house answer, and a product that does not override it
1215 /// leaves the token undefined so whatever the consumer wrote as a fallback
1216 /// renders. The MNW embeds rely on exactly that.
1217 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1218 pub enum FontSlot {
1219 /// Code, data, identifiers, cell grids. [`FONT_MONO`] by default.
1220 Mono,
1221 /// Body and UI text: everything that is not mono or brand. [`FONT_SANS`].
1222 Sans,
1223 /// The brand / display tier. No house default.
1224 Display,
1225 }
1226
1227 impl FontSlot {
1228 /// Every slot, in the order they are emitted.
1229 pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display];
1230
1231 /// The custom property this slot is read through.
1232 pub fn token(self) -> &'static str {
1233 match self {
1234 FontSlot::Mono => "--font-mono",
1235 FontSlot::Sans => "--font-sans",
1236 FontSlot::Display => "--font-display",
1237 }
1238 }
1239
1240 /// The house stack, or `None` for the brand tier.
1241 pub fn house_default(self) -> Option<&'static str> {
1242 match self {
1243 FontSlot::Mono => Some(FONT_MONO),
1244 FontSlot::Sans => Some(FONT_SANS),
1245 FontSlot::Display => None,
1246 }
1247 }
1248
1249 /// The house face behind that stack, or `None` for the brand tier.
1250 ///
1251 /// The counterpart of [`house_default`](Self::house_default), and the same
1252 /// split as [`Typography::resolve`] against [`Typography::faces`]: one
1253 /// names the family that wins, the other names the file behind it. The
1254 /// house tier was a format string until this existed, so it could be
1255 /// emitted and not read — which made [`Typography::faces`] answer for the
1256 /// brand tier and stay silent about the other two.
1257 ///
1258 /// The sources are the **web** copies. A native loader wants a `ttf` and
1259 /// cuts its own through `quasi-type`, whose `cut_native` writes the file
1260 /// and hands back the family, style and default weight to register it
1261 /// under; there is no house `ttf` named here, and there should not be. This
1262 /// crate is on crates.io and `quasi-type` is `publish = false`, so naming a
1263 /// native file here would assert a path the web build does not write and
1264 /// save the consumer nothing, since it still has to run the pipeline.
1265 ///
1266 /// One hazard travels with that arrangement and is not solved: a native
1267 /// consumer takes `quasi-type` as a git dep and pins a rev, so a stale pin
1268 /// ships an older glyph set silently. Advance it deliberately.
1269 pub fn house_face(self) -> Option<FontFace> {
1270 let (family, file) = match self {
1271 FontSlot::Mono => (HOUSE_MONO_FAMILY, WEBFONT_MONO_FILE),
1272 FontSlot::Sans => (HOUSE_SANS_FAMILY, WEBFONT_SANS_FILE),
1273 FontSlot::Display => return None,
1274 };
1275 Some(
1276 FontFace::new(family, [file])
1277 .with_weight(HOUSE_WEIGHT_RANGE)
1278 .with_style("normal"),
1279 )
1280 }
1281 }
1282
1283 /// One `@font-face` an override brings with it.
1284 ///
1285 /// A product overriding a slot usually has to ship the face too, and the two
1286 /// halves have to agree on a family name. Declaring them together is what
1287 /// makes that agreement structural rather than a string typed twice.
1288 #[derive(Debug, Clone)]
1289 pub struct FontFace {
1290 family: String,
1291 sources: Vec<String>,
1292 weight: Option<String>,
1293 style: Option<String>,
1294 }
1295
1296 impl FontFace {
1297 /// A face named `family`, fetched from `sources`.
1298 ///
1299 /// Each source is either a bare filename, resolved against the
1300 /// [`Typography`] base URL, or an absolute one (`/…` or `https://…`) taken
1301 /// as written. The `format()` hint is inferred from the extension —
1302 /// `woff2`, `woff`, `ttf`, `otf` — and omitted for anything else rather
1303 /// than guessed, since a wrong hint is worse than none.
1304 pub fn new<S: Into<String>>(
1305 family: impl Into<String>,
1306 sources: impl IntoIterator<Item = S>,
1307 ) -> Self {
1308 Self {
1309 family: family.into(),
1310 sources: sources.into_iter().map(Into::into).collect(),
1311 weight: None,
1312 style: None,
1313 }
1314 }
1315
1316 /// `font-weight`, as CSS writes it: `"700"`, or `"200 800"` for a variable
1317 /// axis. Omitted when unset, which means `normal`.
1318 ///
1319 /// A variable face MUST name its range here for the same reason the house
1320 /// faces do: a `@font-face` with no range makes the browser resolve every
1321 /// weight to the file's default instance.
1322 #[must_use]
1323 pub fn with_weight(mut self, weight: impl Into<String>) -> Self {
1324 self.weight = Some(weight.into());
1325 self
1326 }
1327
1328 /// `font-style`. Omitted when unset, which means `normal`.
1329 #[must_use]
1330 pub fn with_style(mut self, style: impl Into<String>) -> Self {
1331 self.style = Some(style.into());
1332 self
1333 }
1334
1335 /// The declared `font-weight`, or `None` when the face never named one.
1336 ///
1337 /// A renderer loading a variable face directly has to name a weight — the
1338 /// file's own default instance is whatever the base shipped, which for the
1339 /// house faces is ExtraLight — so this is the half of the declaration that
1340 /// stops the load from being a guess.
1341 pub fn weight(&self) -> Option<&str> {
1342 self.weight.as_deref()
1343 }
1344
1345 /// The declared `font-style`, or `None`, which means `normal`.
1346 pub fn style(&self) -> Option<&str> {
1347 self.style.as_deref()
1348 }
1349
1350 /// The family name, as the stack has to spell it.
1351 ///
1352 /// For a renderer that loads faces rather than emitting CSS this is the
1353 /// name it registers the file under, and reading it here is what keeps
1354 /// that name from being typed a second time.
1355 pub fn family(&self) -> &str {
1356 &self.family
1357 }
1358
1359 /// The sources, unresolved — bare filenames as they were declared, not
1360 /// joined to any base URL. A renderer loading from disk or from an
1361 /// `include_bytes!` wants the filename; only the CSS wants the URL.
1362 pub fn sources(&self) -> &[String] {
1363 &self.sources
1364 }
1365
1366 fn css(&self, base: &str) -> String {
1367 use std::fmt::Write as _;
1368
1369 let src = self
1370 .sources
1371 .iter()
1372 .map(|s| {
1373 let url = if s.starts_with('/') || s.contains("://") {
1374 s.clone()
1375 } else {
1376 format!("{base}/{s}")
1377 };
1378 match font_format(s) {
1379 Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"),
1380 None => format!("url(\"{url}\")"),
1381 }
1382 })
1383 .collect::<Vec<_>>()
1384 .join(",\n ");
1385
1386 let mut out = format!(
1387 "@font-face {{\n font-family: \"{}\";\n src: {src};\n",
1388 self.family
1389 );
1390 if let Some(w) = &self.weight {
1391 let _ = writeln!(out, " font-weight: {w};");
1392 }
1393 if let Some(s) = &self.style {
1394 let _ = writeln!(out, " font-style: {s};");
1395 }
1396 out.push_str(" font-display: swap;\n}\n\n");
1397 out
1398 }
1399 }
1400
1401 /// The `format()` hint for a source, by extension. `None` when unrecognised.
1402 fn font_format(source: &str) -> Option<&'static str> {
1403 match source.rsplit('.').next()?.to_ascii_lowercase().as_str() {
1404 "woff2" => Some("woff2"),
1405 "woff" => Some("woff"),
1406 "ttf" => Some("truetype"),
1407 "otf" => Some("opentype"),
1408 _ => None,
1409 }
1410 }
1411
1412 /// One product's answer for one slot: the stack, and any faces it ships.
1413 #[derive(Debug, Clone)]
1414 pub struct FontOverride {
1415 slot: FontSlot,
1416 stack: String,
1417 faces: Vec<FontFace>,
1418 }
1419
1420 impl FontOverride {
1421 /// Point `slot` at `stack`.
1422 ///
1423 /// `stack` is the CSS value the token takes, written the way the house
1424 /// stacks are: the family, then one hop to a system generic. Layer 2 is
1425 /// still one hop and no further — an override is a different answer to the
1426 /// slot, not a licence to write the fallback chain the standard deleted.
1427 pub fn new(slot: FontSlot, stack: impl Into<String>) -> Self {
1428 Self {
1429 slot,
1430 stack: stack.into(),
1431 faces: Vec::new(),
1432 }
1433 }
1434
1435 /// Ship a face with the override.
1436 #[must_use]
1437 pub fn with_face(mut self, face: FontFace) -> Self {
1438 self.faces.push(face);
1439 self
1440 }
1441
1442 /// The slot this answers.
1443 pub fn slot(&self) -> FontSlot {
1444 self.slot
1445 }
1446
1447 /// The stack it resolves to.
1448 pub fn stack(&self) -> &str {
1449 &self.stack
1450 }
1451
1452 /// The faces it ships, in declaration order.
1453 pub fn faces(&self) -> &[FontFace] {
1454 &self.faces
1455 }
1456 }
1457
1458 /// The whole typography layer for one product: the house defaults, plus
1459 /// whatever it overrides.
1460 ///
1461 /// This is what a build script composes and what
1462 /// `makeover_build::typography_css_from` writes. [`typography_css_vars`] and
1463 /// [`font_face_css`] are the no-override case of it and stay for callers that
1464 /// have nothing to declare.
1465 #[derive(Debug, Clone)]
1466 pub struct Typography {
1467 base_url: String,
1468 overrides: Vec<FontOverride>,
1469 }
1470
1471 impl Typography {
1472 /// The house layer alone, fetching faces from `base_url` — the directory
1473 /// the consumer serves fonts from, with or without a trailing slash.
1474 pub fn house(base_url: impl Into<String>) -> Self {
1475 Self {
1476 base_url: base_url.into(),
1477 overrides: Vec::new(),
1478 }
1479 }
1480
1481 /// Add one product override.
1482 ///
1483 /// # Panics
1484 ///
1485 /// If the slot is already overridden. One declaration per product per
1486 /// slot: a second is not a merge to resolve, it is two answers to a
1487 /// question that has one, and the vocabulary is what wants fixing.
1488 #[must_use]
1489 pub fn with_override(mut self, ov: FontOverride) -> Self {
1490 assert!(
1491 !self.overrides.iter().any(|o| o.slot == ov.slot),
1492 "{} is overridden twice; one declaration per product per slot",
1493 ov.slot.token()
1494 );
1495 self.overrides.push(ov);
1496 self
1497 }
1498
1499 /// What `slot` resolves to under this layer, or `None` for a brand slot
1500 /// nobody overrode.
1501 ///
1502 /// The resolution, for a renderer that has a face to choose rather than a
1503 /// stylesheet to emit.
1504 pub fn resolve(&self, slot: FontSlot) -> Option<&str> {
1505 self.overrides
1506 .iter()
1507 .find(|o| o.slot == slot)
1508 .map(|o| o.stack.as_str())
1509 .or_else(|| slot.house_default())
1510 }
1511
1512 /// The faces a product ships for `slot`, in declaration order, or an
1513 /// empty slice for a slot it did not override.
1514 ///
1515 /// The other half of [`resolve`](Self::resolve), for a renderer that has
1516 /// to load a file rather than name a stack: `resolve` says which family
1517 /// wins, this says where the bytes come from, what to call them, and at
1518 /// what weight. The
1519 /// house faces are not here — they belong to the slot rather than to any
1520 /// one product, and [`FontSlot::house_face`] is where they answer.
1521 pub fn faces(&self, slot: FontSlot) -> &[FontFace] {
1522 self.overrides
1523 .iter()
1524 .find(|o| o.slot == slot)
1525 .map_or(&[], |o| o.faces())
1526 }
1527
1528 /// The `@font-face` rules: the two house faces, then each override's.
1529 pub fn font_face_css(&self) -> String {
1530 let base = self.base_url.trim_end_matches('/');
1531 let mut out = font_face_css(base);
1532 for ov in &self.overrides {
1533 for face in &ov.faces {
1534 out.push_str(&face.css(base));
1535 }
1536 }
1537 out
1538 }
1539
1540 /// The resolved tokens as CSS declarations, no selector.
1541 pub fn css_declarations(&self) -> String {
1542 use std::fmt::Write as _;
1543
1544 let mut out = String::new();
1545 for slot in FontSlot::ALL {
1546 if let Some(stack) = self.resolve(slot) {
1547 let _ = writeln!(out, " {}: {stack};", slot.token());
1548 }
1549 }
1550 out
1551 }
1552
1553 /// The resolved tokens as a `:root { … }` block.
1554 pub fn css_vars(&self) -> String {
1555 format!(":root {{\n{}}}\n", self.css_declarations())
1556 }
1557
1558 /// Faces then tokens, in the order a stylesheet wants them.
1559 pub fn css(&self) -> String {
1560 format!("{}{}", self.font_face_css(), self.css_vars())
1561 }
1562 }
1563
1564 // ============================================================================
1565 // Loading / parsing
1566 // ============================================================================
1567
1568 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
1569 pub fn validate_theme_id(id: &str) -> Result<(), String> {
1570 if !id
1571 .chars()
1572 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
1573 {
1574 return Err(format!("Invalid theme ID: {id}"));
1575 }
1576 Ok(())
1577 }
1578
1579 /// Parse the `[meta]` section into `ThemeMeta`.
1580 ///
1581 /// Falls back to the file ID as the name and `"dark"` as the variant.
1582 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
1583 let meta = table.get("meta").and_then(|m| m.as_table());
1584 let name = meta
1585 .and_then(|m| m.get("name"))
1586 .and_then(|v| v.as_str())
1587 .unwrap_or(id)
1588 .to_string();
1589 let variant = meta
1590 .and_then(|m| m.get("variant"))
1591 .and_then(|v| v.as_str())
1592 .unwrap_or("dark")
1593 .to_string();
1594
1595 ThemeMeta {
1596 id: id.to_string(),
1597 name,
1598 variant,
1599 is_custom,
1600 }
1601 }
1602
1603 // ============================================================================
1604 // Choosing a theme.
1605 //
1606 // The file half of this crate was always shared; the *selection* half was not,
1607 // and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
1608 // localStorage, Balanced Breakfast treats an absent value as follow-the-system
1609 // and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
1610 // in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
1611 // disagreed about what a variant string means: this crate defaults a missing
1612 // one to "dark" while alloy_tui parsed an unrecognized one as light.
1613 //
1614 // What cannot be shared is the store — localStorage, a synced config table and
1615 // a TOML file are genuinely different places. What can be shared, and is here,
1616 // is the *meaning*: one vocabulary for variants, one encoding for "what did the
1617 // user choose", and one rule for turning that into an id that exists.
1618 // ============================================================================
1619
1620 /// A theme's kind, as declared by `meta.variant`.
1621 ///
1622 /// Three, not two: one shipped theme is `high-contrast`, and an app that
1623 /// matched on light-or-dark alone would quietly file it under the wrong one.
1624 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1625 #[serde(rename_all = "kebab-case")]
1626 pub enum Variant {
1627 Light,
1628 Dark,
1629 HighContrast,
1630 }
1631
1632 impl Variant {
1633 /// The spelling used in a theme file and in [`ThemeMeta::variant`].
1634 #[must_use]
1635 pub const fn as_str(self) -> &'static str {
1636 match self {
1637 Variant::Light => "light",
1638 Variant::Dark => "dark",
1639 Variant::HighContrast => "high-contrast",
1640 }
1641 }
1642
1643 /// Read a variant string, or `None` if it names none of them.
1644 #[must_use]
1645 pub fn parse(raw: &str) -> Option<Self> {
1646 match raw {
1647 "light" => Some(Variant::Light),
1648 "dark" => Some(Variant::Dark),
1649 "high-contrast" => Some(Variant::HighContrast),
1650 _ => None,
1651 }
1652 }
1653 }
1654
1655 impl std::fmt::Display for Variant {
1656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1657 f.write_str(self.as_str())
1658 }
1659 }
1660
1661 /// Anything unrecognized reads as dark, which is what [`parse_meta`] already
1662 /// does with a missing one. Consumers that guessed light for an unknown string
1663 /// were disagreeing with the crate that produced it.
1664 impl From<&str> for Variant {
1665 fn from(raw: &str) -> Self {
1666 Variant::parse(raw).unwrap_or(Variant::Dark)
1667 }
1668 }
1669
1670 impl ThemeMeta {
1671 /// This theme's variant as a value rather than a string.
1672 #[must_use]
1673 pub fn kind(&self) -> Variant {
1674 Variant::from(self.variant.as_str())
1675 }
1676 }
1677
1678 /// The spelling of "follow whatever the system is doing", in every store.
1679 pub const FOLLOW: &str = "system";
1680
1681 /// What the user chose, as opposed to what is being rendered.
1682 ///
1683 /// The distinction is the whole point: `Follow` is a standing instruction that
1684 /// resolves differently as the ambient mode changes, and a `Fixed` id is an
1685 /// answer that does not. An app that stored only the rendered id could not tell
1686 /// the two apart the next time the system flipped to dark.
1687 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1688 pub enum ThemeSelection {
1689 /// Track the ambient light/dark mode.
1690 #[default]
1691 Follow,
1692 /// Always this theme.
1693 Fixed(String),
1694 }
1695
1696 impl ThemeSelection {
1697 /// Read a stored selection. An empty or absent value is [`Follow`], which
1698 /// is what an app with nothing saved yet should do.
1699 ///
1700 /// [`Follow`]: ThemeSelection::Follow
1701 #[must_use]
1702 pub fn parse(raw: Option<&str>) -> Self {
1703 match raw.map(str::trim) {
1704 None | Some("" | FOLLOW) => ThemeSelection::Follow,
1705 Some(id) => ThemeSelection::Fixed(id.to_string()),
1706 }
1707 }
1708
1709 /// The string to persist, whatever the store is.
1710 #[must_use]
1711 pub fn as_str(&self) -> &str {
1712 match self {
1713 ThemeSelection::Follow => FOLLOW,
1714 ThemeSelection::Fixed(id) => id,
1715 }
1716 }
1717
1718 /// Turn a selection into a theme id that exists.
1719 ///
1720 /// `ambient` is the light/dark mode the app learned however it can: a
1721 /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
1722 /// from a terminal. `available` is what [`list_themes_from_dirs`] found.
1723 ///
1724 /// A `Fixed` id that is no longer on disk falls through to the same path as
1725 /// `Follow` rather than being returned anyway. Themes are deletable in
1726 /// three of the four apps, and handing back an id that will fail to load
1727 /// only moves the error somewhere less helpful.
1728 ///
1729 /// The fallback chain is: the app's own default for the ambient mode if it
1730 /// is installed, then any installed theme of that variant, then the app's
1731 /// default regardless. The last step means this always returns something,
1732 /// and an app with no theme directory at all gets the id it ships with and
1733 /// the load error it would have had anyway.
1734 #[must_use]
1735 pub fn resolve(
1736 &self,
1737 ambient: Variant,
1738 defaults: &ThemeDefaults,
1739 available: &[ThemeMeta],
1740 ) -> String {
1741 let installed = |id: &str| available.iter().any(|meta| meta.id == id);
1742
1743 if let ThemeSelection::Fixed(id) = self
1744 && installed(id)
1745 {
1746 return id.clone();
1747 }
1748
1749 let preferred = defaults.for_variant(ambient);
1750 if installed(preferred) {
1751 return preferred.to_string();
1752 }
1753 available
1754 .iter()
1755 .find(|meta| meta.kind() == ambient)
1756 .map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
1757 }
1758 }
1759
1760 impl std::fmt::Display for ThemeSelection {
1761 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1762 f.write_str(self.as_str())
1763 }
1764 }
1765
1766 /// The themes an app falls back to, one per ambient mode.
1767 ///
1768 /// App-specific on purpose: which theme is "the app's own" is the app's
1769 /// identity, not this crate's business. What is shared is everything around it.
1770 #[derive(Debug, Clone)]
1771 pub struct ThemeDefaults {
1772 light: String,
1773 dark: String,
1774 high_contrast: Option<String>,
1775 }
1776
1777 impl ThemeDefaults {
1778 pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
1779 Self {
1780 light: light.into(),
1781 dark: dark.into(),
1782 high_contrast: None,
1783 }
1784 }
1785
1786 /// Name a theme for a high-contrast ambient mode. Without one, that mode
1787 /// falls back to the dark default, which is the safer of the two to read.
1788 #[must_use]
1789 pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
1790 self.high_contrast = Some(id.into());
1791 self
1792 }
1793
1794 /// Whether a high-contrast default was named.
1795 ///
1796 /// [`for_variant`] answers for every mode by falling back to the dark
1797 /// theme, which is right for resolving a selection and wrong for emitting
1798 /// a `prefers-contrast: more` block: that block would then answer the
1799 /// preference with a theme that does not honour it. A caller that renders
1800 /// per ambient mode asks this first.
1801 ///
1802 /// [`for_variant`]: ThemeDefaults::for_variant
1803 #[must_use]
1804 pub const fn names_high_contrast(&self) -> bool {
1805 self.high_contrast.is_some()
1806 }
1807
1808 #[must_use]
1809 pub fn for_variant(&self, variant: Variant) -> &str {
1810 match variant {
1811 Variant::Light => &self.light,
1812 Variant::Dark => &self.dark,
1813 Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
1814 }
1815 }
1816 }
1817
1818 // ============================================================================
1819 // Where themes are looked for.
1820 //
1821 // Four apps built this vector by hand, two of them byte-for-byte identically,
1822 // and one of them built it backwards: the Alloy console pushed the user's own
1823 // directory first, under a comment saying "highest precedence first", when both
1824 // consumers of the vector resolve *last* wins. A user's custom theme lost to
1825 // the packaged one of the same id.
1826 //
1827 // Hence a builder that names the tiers rather than a function taking a vector.
1828 // The precedence is stated once, here, and a caller cannot express it backwards
1829 // because the order is not theirs to choose.
1830 // ============================================================================
1831
1832 /// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take.
1833 ///
1834 /// Tiers are added in whatever order is convenient and always end up in
1835 /// precedence order: the user's own themes win, then whatever the system
1836 /// ships, then whatever the app bundles.
1837 ///
1838 /// A directory that does not exist is dropped rather than carried, so callers
1839 /// can offer every tier they might have without checking each one.
1840 #[derive(Debug, Default, Clone)]
1841 pub struct ThemeDirs {
1842 bundled: Vec<PathBuf>,
1843 system: Vec<PathBuf>,
1844 custom: Option<PathBuf>,
1845 }
1846
1847 impl ThemeDirs {
1848 #[must_use]
1849 pub fn new() -> Self {
1850 Self::default()
1851 }
1852
1853 /// Themes the app ships with. Lowest precedence.
1854 ///
1855 /// Takes more than one because a Tauri app has two: the bundled resource
1856 /// directory in production, and the tree `build.rs` materialized for a
1857 /// `cargo run` that has no resource directory at all.
1858 #[must_use]
1859 pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
1860 self.bundled.extend(dir);
1861 self
1862 }
1863
1864 /// Themes the machine ships, from an image or a package. Overrides bundled.
1865 #[must_use]
1866 pub fn system(mut self, dir: Option<PathBuf>) -> Self {
1867 self.system.extend(dir);
1868 self
1869 }
1870
1871 /// The user's own themes. Highest precedence, and the only tier flagged
1872 /// custom, which is what makes them exportable and deletable.
1873 #[must_use]
1874 pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
1875 self.custom = dir;
1876 self
1877 }
1878
1879 /// The search path, lowest precedence first.
1880 #[must_use]
1881 pub fn build(self) -> Vec<(PathBuf, bool)> {
1882 let mut dirs = Vec::new();
1883 for dir in self.bundled.into_iter().chain(self.system) {
1884 if dir.is_dir() {
1885 dirs.push((dir, false));
1886 }
1887 }
1888 if let Some(dir) = self.custom
1889 && dir.is_dir()
1890 {
1891 dirs.push((dir, true));
1892 }
1893 dirs
1894 }
1895 }
1896
1897 /// Extract the intent color sections into a flat `HashMap` with dotted keys
1898 /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
1899 ///
1900 /// The tonal steps of `content.primary` are filled in here rather than read, by
1901 /// [`derive_tonal_steps`]. Anything a theme authored under those keys is
1902 /// replaced.
1903 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
1904 let mut colors = HashMap::new();
1905 for section in COLOR_SECTIONS {
1906 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
1907 for (key, val) in sect {
1908 if let Some(color) = val.as_str() {
1909 colors.insert(format!("{section}.{key}"), color.to_string());
1910 }
1911 }
1912 }
1913 }
1914 derive_tonal_steps(&mut colors);
1915 colors
1916 }
1917
1918 /// Fill in the tonal steps of `content.primary`, overwriting whatever the theme
1919 /// authored under those keys.
1920 ///
1921 /// # Why they are not authored
1922 ///
1923 /// `content.secondary` and `content.muted` are not independent colours. They are
1924 /// the ink, one step and two steps back, and a theme that names them separately
1925 /// is stating three times something it stated once — which is how three of the
1926 /// bundled themes came to author a `secondary` *lighter* than their own
1927 /// `primary` (nord, solarized-dark) or identical to it (dracula), inverting the
1928 /// emphasis ramp the whole vocabulary rests on. Deriving them makes
1929 /// `content` > `content-secondary` > `content-muted` true by construction in
1930 /// every theme, including one a user writes.
1931 ///
1932 /// Applied at load rather than in [`resolve`] so that there is one answer: the
1933 /// resolved token layer, the ANSI table ([`ansi_intent`] reads authored keys),
1934 /// and every consumer holding a [`ThemeColors`] all see the same value. A
1935 /// derivation visible from only one of those is how a terminal and a webview
1936 /// come to disagree about what muted means.
1937 ///
1938 /// Both keys need `content.primary` and `surface.page` to exist and parse. When
1939 /// either is missing the step is skipped and anything authored is left where it
1940 /// is, mirroring the skip-missing behaviour of the rest of the crate — a
1941 /// half-written theme keeps whatever it has rather than losing it.
1942 ///
1943 /// # The ratio is a starting point, not the answer
1944 ///
1945 /// Each step is pushed further toward the page until it clears [`STEP_FLOOR`]
1946 /// against the ink, so what the theme gets is a step that can be seen rather
1947 /// than a step of the agreed size. The two are the same number in every bundled
1948 /// theme but the two with a pure-black ink, where the ratio has no range to
1949 /// travel in and the nominal step lands 3/255 from where it started.
1950 pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
1951 let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
1952 let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
1953 let (Some(ink), Some(page)) = (ink, page) else {
1954 return;
1955 };
1956 // Each step starts no nearer than the one before it landed, so pushing
1957 // secondary out cannot carry it past muted and invert the ramp.
1958 let mut reached = 0.0;
1959 for (key, step) in [
1960 ("content.secondary", Emphasis::Secondary),
1961 ("content.muted", Emphasis::Muted),
1962 ] {
1963 let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached));
1964 reached = ratio;
1965 colors.insert(key.to_string(), color.to_hex());
1966 }
1967 }
1968
1969 /// The step `from` of the way from `ink` to `page`, pushed toward `page` until
1970 /// it clears [`STEP_FLOOR`] against the ink it is a step of. Returns the colour
1971 /// and the ratio it was found at.
1972 ///
1973 /// A forward scan rather than a solve, because it wants the *first* ratio that
1974 /// clears: contrast against the base rises with the distance travelled, but it
1975 /// rises through sRGB's transfer curve and OKLab's chroma path, and a bisection
1976 /// would trust a monotonicity nothing here guarantees.
1977 ///
1978 /// Travel stops at the ground. A theme whose ink and page are the same colour
1979 /// has no step to take, and the ground is the honest answer — nothing past it
1980 /// is a step of the ink any more.
1981 fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) {
1982 // Finer than 8-bit sRGB can resolve on the shortest ramp in the corpus, so
1983 // the scan never steps over the first colour that clears.
1984 const PROBE: f32 = 0.005;
1985 let mut ratio = from.clamp(0.0, 1.0);
1986 loop {
1987 let color = tonal(ink, page, ratio);
1988 if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 {
1989 return (color, ratio);
1990 }
1991 ratio = (ratio + PROBE).min(1.0);
1992 }
1993 }
1994
1995 /// Scan directories for `.toml` theme files and return metadata for each.
1996 ///
1997 /// Directories are checked in order; later entries override earlier ones by ID.
1998 /// Each entry in `dirs` is `(path, is_custom)`.
1999 pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
2000 let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
2001
2002 for (dir, is_custom) in dirs {
2003 let Ok(entries) = std::fs::read_dir(dir) else {
2004 continue;
2005 };
2006
2007 for entry in entries {
2008 let Ok(entry) = entry else {
2009 continue;
2010 };
2011 let path = entry.path();
2012 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2013 continue;
2014 }
2015
2016 let id = path
2017 .file_stem()
2018 .and_then(|s| s.to_str())
2019 .unwrap_or_default()
2020 .to_string();
2021
2022 let Ok(content) = std::fs::read_to_string(&path) else {
2023 continue;
2024 };
2025 let table: toml::Table = match content.parse() {
2026 Ok(t) => t,
2027 Err(_) => continue,
2028 };
2029
2030 seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
2031 }
2032 }
2033
2034 let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
2035 themes.sort_by(|a, b| a.name.cmp(&b.name));
2036 themes
2037 }
2038
2039 /// How legible a theme's muted text is, measured rather than declared.
2040 ///
2041 /// The worst WCAG contrast ratio of `content.muted` against the two panel
2042 /// grounds a reader actually meets it on, `surface.page` and `surface.sunken`,
2043 /// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average,
2044 /// because a theme that is legible on one panel and not the other is a theme
2045 /// with an illegible panel.
2046 ///
2047 /// It is measured here rather than authored in the theme file for the reason
2048 /// the whole crate exists: a curated palette keeps its identity and the reader
2049 /// still gets told what it costs them. An author cannot mis-declare it, and a
2050 /// theme edited on disk re-measures on the next scan.
2051 ///
2052 /// Ordered worst-first, so `sort` puts the most legible theme last and
2053 /// [`theme_options`] reverses it into what a picker wants at the top.
2054 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
2055 #[serde(rename_all = "kebab-case")]
2056 pub enum ContrastTier {
2057 /// Muted text below the 3:1 floor WCAG sets for large text and UI parts.
2058 Low,
2059 /// Muted text meets 3:1 but not the 4.5:1 bar for normal text.
2060 Standard,
2061 /// Muted text meets WCAG AA on every panel ground, 4.5:1 or better.
2062 High,
2063 }
2064
2065 impl ContrastTier {
2066 /// The machine spelling, for a data attribute or a stored value.
2067 #[must_use]
2068 pub const fn as_str(self) -> &'static str {
2069 match self {
2070 ContrastTier::Low => "low",
2071 ContrastTier::Standard => "standard",
2072 ContrastTier::High => "high",
2073 }
2074 }
2075
2076 /// Measure a loaded theme.
2077 ///
2078 /// A theme missing either ground or the muted content colour reads as
2079 /// [`Standard`](Self::Standard): the measurement did not happen, and
2080 /// claiming `Low` would badge a theme for the scan's failure rather than
2081 /// its own.
2082 #[must_use]
2083 pub fn of(theme: &ThemeColors) -> Self {
2084 let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v));
2085 let (Some(muted), Some(page), Some(sunken)) = (
2086 colour("content.muted"),
2087 colour("surface.page"),
2088 colour("surface.sunken"),
2089 ) else {
2090 return ContrastTier::Standard;
2091 };
2092
2093 let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken));
2094 if worst >= 4.5 {
2095 ContrastTier::High
2096 } else if worst >= 3.0 {
2097 ContrastTier::Standard
2098 } else {
2099 ContrastTier::Low
2100 }
2101 }
2102 }
2103
2104 /// One theme, as a picker offers it.
2105 ///
2106 /// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies:
2107 /// the variant as a value rather than a string, and the measured contrast tier.
2108 /// Owned, because it outlives the directory scan that produced it and is held
2109 /// by an app across the frames or requests that draw the control.
2110 ///
2111 /// It carries no `is_custom`. A picker that sorted the user's own themes apart
2112 /// from the shipped ones would be answering a different question, and
2113 /// [`ThemeMeta`] is still there for a screen that wants it.
2114 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2115 #[serde(rename_all = "camelCase")]
2116 pub struct ThemeOption {
2117 /// The id stored, and the value the picker submits.
2118 pub id: String,
2119 /// What the picker reads.
2120 pub name: String,
2121 /// Which group it belongs to.
2122 pub variant: Variant,
2123 /// How legible its muted text measured.
2124 pub contrast: ContrastTier,
2125 }
2126
2127 /// Every installed theme, in the order a picker should offer them.
2128 ///
2129 /// This is the half of a theme picker that is not the control: which themes
2130 /// exist, which group each is in, how legible each one is, and what order that
2131 /// puts them in. Three apps derived it three ways and two of them lost it
2132 /// entirely when their pickers were described, which is what makes it the
2133 /// crate's job rather than each app's.
2134 ///
2135 /// # The order
2136 ///
2137 /// By variant in [`Variant`]'s own order — light, dark, high contrast — then
2138 /// by measured contrast **best first**, then by name. The middle key is the one
2139 /// no app can supply without redoing the work this crate has already done: the
2140 /// tier comes off the resolved colours, and an app sorting a `Vec<ThemeMeta>`
2141 /// has only the names.
2142 ///
2143 /// Grouping is left implicit in the order rather than returned as groups. A
2144 /// renderer that draws headings walks the run of one variant; one that cannot
2145 /// draw headings still gets the useful order. Handing back
2146 /// `Vec<(Variant, Vec<ThemeOption>)>` would force the second renderer to
2147 /// flatten what the first wanted, and neither shape is more true.
2148 ///
2149 /// # What it costs
2150 ///
2151 /// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its
2152 /// metadata, once here for the colours the tier is measured from. Measured
2153 /// rather than assumed to be cheap: a picker is drawn on a settings screen, the
2154 /// shipped set is around twenty files, and the alternative is caching a
2155 /// derived value that a theme edited on disk would then be wrong about.
2156 /// A theme whose colours will not load keeps its metadata and reads as
2157 /// [`ContrastTier::Standard`], on the same footing as one missing a ground.
2158 ///
2159 /// A host whose themes are not all on disk builds its own [`ThemeOption`]s and
2160 /// calls [`order_theme_options`], which is this function's second half.
2161 #[must_use]
2162 pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
2163 let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs)
2164 .into_iter()
2165 .map(|meta| {
2166 let contrast = load_theme(dirs, &meta.id)
2167 .map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme));
2168 ThemeOption {
2169 variant: meta.kind(),
2170 contrast,
2171 id: meta.id,
2172 name: meta.name,
2173 }
2174 })
2175 .collect();
2176
2177 order_theme_options(&mut options);
2178 options
2179 }
2180
2181 /// Put an already-collected set into the order a picker offers them in.
2182 ///
2183 /// [`theme_options`]' second half, reachable on its own because not every host
2184 /// resolves its themes by scanning a directory. audiofiles embeds its shipped
2185 /// set at compile time and reads only its custom themes off disk, so a
2186 /// directory scan cannot see most of what it offers, and the alternative to
2187 /// this being public was that app re-deriving the sort — which is exactly the
2188 /// three-apps-three-orders state the picker was described to end.
2189 ///
2190 /// The order is by variant in [`Variant`]'s own order, then by measured
2191 /// contrast **best first**, then by name.
2192 pub fn order_theme_options(options: &mut [ThemeOption]) {
2193 options.sort_by(|a, b| {
2194 variant_order(a.variant)
2195 .cmp(&variant_order(b.variant))
2196 .then(b.contrast.cmp(&a.contrast))
2197 .then_with(|| a.name.cmp(&b.name))
2198 });
2199 }
2200
2201 /// Where a variant sits in a picker, light first.
2202 ///
2203 /// Not `Variant as usize`: the declaration order of an enum is not a promise
2204 /// about how it reads, and a member inserted for a fourth variant would
2205 /// silently reorder every picker in the tree.
2206 const fn variant_order(variant: Variant) -> u8 {
2207 match variant {
2208 Variant::Light => 0,
2209 Variant::Dark => 1,
2210 Variant::HighContrast => 2,
2211 }
2212 }
2213
2214 /// Find a theme file by ID in the given directories.
2215 ///
2216 /// Checks directories in reverse order so the highest-priority directory wins.
2217 /// Returns `(path, is_custom)` or `None` if not found.
2218 pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
2219 let filename = format!("{id}.toml");
2220
2221 for (dir, is_custom) in dirs.iter().rev() {
2222 let path = dir.join(&filename);
2223 if path.is_file() {
2224 return Some((path, *is_custom));
2225 }
2226 }
2227
2228 None
2229 }
2230
2231 /// Parse a complete theme (metadata + colors) from raw TOML content, with no
2232 /// filesystem access. For callers that embed themes at compile time.
2233 pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
2234 validate_theme_id(id)?;
2235 let table: toml::Table = content
2236 .parse()
2237 .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
2238 let meta = parse_meta(id, &table, is_custom);
2239 let colors = extract_colors(&table);
2240 Ok(ThemeColors { meta, colors })
2241 }
2242
2243 /// Load a complete theme (metadata + colors) by ID from the given directories.
2244 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
2245 validate_theme_id(id)?;
2246
2247 let (path, is_custom) =
2248 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
2249
2250 let content = std::fs::read_to_string(&path)
2251 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2252
2253 let table: toml::Table = content
2254 .parse()
2255 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
2256
2257 let meta = parse_meta(id, &table, is_custom);
2258 let colors = extract_colors(&table);
2259
2260 Ok(ThemeColors { meta, colors })
2261 }
2262
2263 /// Load a theme and resolve it to the full intent token set in one step.
2264 pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
2265 Ok(resolve(&load_theme(dirs, id)?))
2266 }
2267
2268 /// Import a theme TOML file into the custom themes directory.
2269 ///
2270 /// Validates that the file is parseable TOML with at least one intent color
2271 /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
2272 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
2273 let content = std::fs::read_to_string(source_path)
2274 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
2275
2276 let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
2277
2278 let has_colors = COLOR_SECTIONS
2279 .iter()
2280 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
2281 if !has_colors {
2282 return Err(format!(
2283 "Theme file must have at least one color section ({})",
2284 COLOR_SECTIONS.join(", ")
2285 ));
2286 }
2287
2288 let id = source_path
2289 .file_stem()
2290 .and_then(|s| s.to_str())
2291 .ok_or("Invalid file name")?
2292 .to_string();
2293 validate_theme_id(&id)?;
2294
2295 std::fs::create_dir_all(custom_dir)
2296 .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
2297
2298 let dest = custom_dir.join(format!("{id}.toml"));
2299 std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
2300
2301 Ok(parse_meta(&id, &table, true))
2302 }
2303
2304 /// Delete a custom theme by ID.
2305 ///
2306 /// Only operates on `custom_dir` — bundled themes are not deletable through
2307 /// this entry point.
2308 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
2309 validate_theme_id(id)?;
2310
2311 let path = custom_dir.join(format!("{id}.toml"));
2312 if !path.is_file() {
2313 return Err(format!("Custom theme '{id}' not found"));
2314 }
2315
2316 std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
2317 }
2318
2319 /// A four-color preview for theme thumbnails: the representative swatch from
2320 /// each of the principal roles.
2321 #[derive(Debug, Clone, Serialize)]
2322 #[serde(rename_all = "camelCase")]
2323 pub struct ThemePreview {
2324 pub meta: ThemeMeta,
2325 /// Page background (`surface.page`).
2326 pub background: Option<String>,
2327 /// Body text (`content.primary`).
2328 pub foreground: Option<String>,
2329 /// Brand/interactive color (`action.primary`).
2330 pub accent: Option<String>,
2331 /// Divider/outline color (`line.border`).
2332 pub border: Option<String>,
2333 }
2334
2335 fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
2336 table
2337 .get(section)
2338 .and_then(|s| s.as_table())
2339 .and_then(|s| s.get(key))
2340 .and_then(|v| v.as_str())
2341 .map(std::string::ToString::to_string)
2342 }
2343
2344 /// Load just the preview swatches for a theme — for UI thumbnails.
2345 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
2346 validate_theme_id(id)?;
2347
2348 let (path, is_custom) =
2349 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
2350
2351 let content = std::fs::read_to_string(&path)
2352 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2353
2354 let table: toml::Table = content
2355 .parse()
2356 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
2357
2358 Ok(ThemePreview {
2359 meta: parse_meta(id, &table, is_custom),
2360 background: color_at(&table, "surface", "page"),
2361 foreground: color_at(&table, "content", "primary"),
2362 accent: color_at(&table, "action", "primary"),
2363 border: color_at(&table, "line", "border"),
2364 })
2365 }
2366
2367 /// Export a theme to a user-chosen path.
2368 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
2369 validate_theme_id(id)?;
2370
2371 let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
2372
2373 std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
2374
2375 Ok(())
2376 }
2377
2378 /// The themes this crate ships, embedded at compile time.
2379 ///
2380 /// `include_dir` is an implementation detail: the public API hands back plain
2381 /// `(id, toml_source)` pairs, so how the data is embedded can change without
2382 /// a breaking release.
2383 static EMBEDDED: include_dir::Dir<'static> =
2384 include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
2385
2386 /// The themes this crate ships, as `(id, toml_source)` pairs.
2387 ///
2388 /// This is the path-free way to reach the bundled set, for consumers that
2389 /// cannot rely on a directory existing at runtime: a crate pulled from
2390 /// crates.io lives in a registry checkout whose location is not knowable at
2391 /// compile time, so `include_dir!` and asset-bundling globs in the depending
2392 /// crate have nothing stable to point at. Embedding here and re-exporting the
2393 /// contents gives them one source of truth without a path.
2394 ///
2395 /// Ordering follows the embedded directory and is not guaranteed; collect and
2396 /// sort by id where a stable order matters (a theme picker, say).
2397 pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
2398 EMBEDDED.files().filter_map(|file| {
2399 let path = file.path();
2400 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
2401 return None;
2402 }
2403 let id = path.file_stem()?.to_str()?;
2404 Some((id, file.contents_utf8()?))
2405 })
2406 }
2407
2408 /// The theme directory this crate ships, for use as a build-from-source
2409 /// fallback.
2410 ///
2411 /// Resolves against `makeover`'s own manifest directory, fixed at compile
2412 /// time, so it works from a path dependency and from a cargo git checkout
2413 /// alike. Installed systems should put their packaged theme directory ahead
2414 /// of this in the search path; this is the entry that keeps `cargo run` in a
2415 /// fresh clone from coming up with no themes at all.
2416 ///
2417 /// Returns `None` when the directory is absent — a cargo cache that has been
2418 /// cleaned, or a vendored copy that dropped the data — so callers degrade to
2419 /// their remaining search path rather than failing.
2420 pub fn bundled_themes_dir() -> Option<PathBuf> {
2421 let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
2422 if themes.is_dir() { Some(themes) } else { None }
2423 }
2424
2425 #[cfg(test)]
2426 mod tests {
2427 use super::*;
2428 use std::fs;
2429
2430 // ---- id validation ----
2431
2432 #[test]
2433 fn validate_theme_id_alphanumeric() {
2434 assert!(validate_theme_id("darkmode").is_ok());
2435 assert!(validate_theme_id("Theme123").is_ok());
2436 }
2437
2438 #[test]
2439 fn validate_theme_id_hyphens_underscores() {
2440 assert!(validate_theme_id("dark-mode").is_ok());
2441 assert!(validate_theme_id("my_theme_v2").is_ok());
2442 }
2443
2444 #[test]
2445 fn validate_theme_id_rejects_path_traversal() {
2446 assert!(validate_theme_id("../etc/passwd").is_err());
2447 assert!(validate_theme_id("foo/bar").is_err());
2448 assert!(validate_theme_id("theme.toml").is_err());
2449 }
2450
2451 // ---- low-color terminals ----
2452
2453 #[test]
2454 fn the_ansi_palette_is_sixteen_distinct_colors() {
2455 let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
2456 seen.sort_unstable();
2457 seen.dedup();
2458 assert_eq!(seen.len(), 16);
2459 }
2460
2461 // ---- the intent-to-slot table ----
2462
2463 // Sixteen slots, every one of them answered. A caller filling a terminal
2464 // palette has no fallback for a hole: the slot would keep whatever the
2465 // emulator started with, and one raw ANSI colour in a themed table is more
2466 // obviously wrong than all sixteen would be.
2467 #[test]
2468 fn every_ansi_slot_names_an_intent_on_either_polarity() {
2469 for variant in ["light", "dark", "high-contrast"] {
2470 for index in 0..16 {
2471 assert!(
2472 ansi_intent(index, variant).is_some(),
2473 "slot {index} unanswered on {variant}"
2474 );
2475 }
2476 assert_eq!(ansi_intent(16, variant), None);
2477 }
2478 }
2479
2480 // The property the four achromatic slots exist to hold: 0 is the darkest
2481 // tone the theme offers and 15 the lightest, in either polarity. A table
2482 // that pins slot 0 to `content.primary` passes this on a light theme and
2483 // inverts on a dark one, which is the bug the polarity split fixes.
2484 #[test]
2485 fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
2486 for id in ["akari-dawn", "akari-night"] {
2487 let theme = bundled(id);
2488 let slot = |i: usize| -> Rgb {
2489 let key = ansi_intent(i, &theme.meta.variant).expect("in range");
2490 Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
2491 };
2492 assert!(
2493 rel_luminance(slot(0)) < rel_luminance(slot(15)),
2494 "{id}: ANSI 0 {} should be darker than ANSI 15 {}",
2495 slot(0).to_hex(),
2496 slot(15).to_hex(),
2497 );
2498 }
2499 }
2500
2501 // The pair a greeter draws with: its container on 7, its text on 0. If
2502 // those collapse the login screen is one flat block, and slot 7 being a
2503 // surface rather than a text tone is what keeps them apart.
2504 #[test]
2505 fn the_container_slot_and_the_text_slot_stay_legible() {
2506 for id in ["akari-dawn", "akari-night"] {
2507 let theme = bundled(id);
2508 let slot = |i: usize| -> Rgb {
2509 let key = ansi_intent(i, &theme.meta.variant).expect("in range");
2510 Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
2511 };
2512 let contrast = wcag_contrast(slot(0), slot(7));
2513 assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
2514 }
2515 }
2516
2517 // The hues do not move with polarity. Red is the theme's danger tone on a
2518 // light theme and on a dark one, which is why only four slots are in the
2519 // polarity table at all.
2520 #[test]
2521 fn the_chromatic_slots_do_not_vary_with_polarity() {
2522 for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
2523 assert_eq!(
2524 ansi_intent(index, "light"),
2525 ansi_intent(index, "dark"),
2526 "slot {index} moved with polarity"
2527 );
2528 }
2529 }
2530
2531 fn bundled(id: &str) -> ThemeColors {
2532 let dir = bundled_themes_dir().expect("makeover ships its themes");
2533 load_theme(&[(dir, false)], id).expect("the akari pair ships")
2534 }
2535
2536 #[test]
2537 fn quantize_picks_the_obvious_entry() {
2538 let black = Rgb { r: 0, g: 0, b: 0 };
2539 let white = Rgb {
2540 r: 255,
2541 g: 255,
2542 b: 255,
2543 };
2544 assert_eq!(quantize(black, &ANSI_16), 0);
2545 assert_eq!(quantize(white, &ANSI_16), 15);
2546 }
2547
2548 // Nearest-entry quantization is per-color, so two colors a theme keeps
2549 // apart can arrive as one. These two are both closest to the palette's
2550 // light gray, and a border drawn in one on a page painted the other is not
2551 // drawn at all.
2552 #[test]
2553 fn two_colors_can_quantize_to_one_entry() {
2554 let page = Rgb::from_hex("#a8a8a8").unwrap();
2555 let border = Rgb::from_hex("#b4b4b4").unwrap();
2556
2557 assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
2558 assert_ne!(
2559 quantize_against(border, page, &ANSI_16),
2560 quantize(page, &ANSI_16)
2561 );
2562 }
2563
2564 #[test]
2565 fn quantize_against_keeps_the_border_off_the_page() {
2566 let page = Rgb::from_hex("#e4ded6").unwrap();
2567 let border = Rgb::from_hex("#7f786d").unwrap();
2568
2569 let shown_page = ANSI_16[quantize(page, &ANSI_16)];
2570 let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
2571
2572 assert!(
2573 wcag_contrast(shown_border, shown_page) >= DISTINCT,
2574 "border {} on page {} is {:.2}:1",
2575 shown_border.to_hex(),
2576 shown_page.to_hex(),
2577 wcag_contrast(shown_border, shown_page)
2578 );
2579 }
2580
2581 // A color that already reads against its background is left where it is,
2582 // so this can be applied without redesigning what already worked.
2583 #[test]
2584 fn quantize_against_leaves_a_readable_color_alone() {
2585 let page = Rgb::from_hex("#e4ded6").unwrap();
2586 let text = Rgb::from_hex("#1a1816").unwrap();
2587
2588 assert_eq!(
2589 quantize_against(text, page, &ANSI_16),
2590 quantize(text, &ANSI_16)
2591 );
2592 }
2593
2594 // With nothing in the palette to satisfy the request, the most legible
2595 // entry is the answer. Returning the nearest one would return the
2596 // background itself, which is the failure this function exists to avoid.
2597 #[test]
2598 fn an_impossible_palette_gets_the_most_legible_entry() {
2599 let page = Rgb::from_hex("#ffffff").unwrap();
2600 let border = Rgb::from_hex("#fefefe").unwrap();
2601 let palette = [
2602 Rgb::from_hex("#ffffff").unwrap(),
2603 Rgb::from_hex("#fdfdfd").unwrap(),
2604 ];
2605
2606 let chosen = palette[quantize_against(border, page, &palette)];
2607 assert_eq!(chosen.to_hex(), "#fdfdfd");
2608 }
2609
2610 // ---- meta ----
2611
2612 #[test]
2613 fn parse_meta_with_name_and_variant() {
2614 let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
2615 .parse()
2616 .unwrap();
2617 let meta = parse_meta("nord", &table, false);
2618 assert_eq!(meta.id, "nord");
2619 assert_eq!(meta.name, "Nord");
2620 assert_eq!(meta.variant, "light");
2621 assert!(!meta.is_custom);
2622 }
2623
2624 #[test]
2625 fn parse_meta_defaults_to_id_and_dark() {
2626 let table: toml::Table = "".parse().unwrap();
2627 let meta = parse_meta("fallback", &table, true);
2628 assert_eq!(meta.name, "fallback");
2629 assert_eq!(meta.variant, "dark");
2630 assert!(meta.is_custom);
2631 }
2632
2633 // ---- color math (formulas must match the apps they came from) ----
2634
2635 #[test]
2636 fn rgb_hex_roundtrip() {
2637 assert_eq!(
2638 Rgb::from_hex("#6196FF").unwrap(),
2639 Rgb {
2640 r: 0x61,
2641 g: 0x96,
2642 b: 0xff
2643 }
2644 );
2645 assert_eq!(
2646 Rgb::from_hex("#abc").unwrap(),
2647 Rgb {
2648 r: 0xaa,
2649 g: 0xbb,
2650 b: 0xcc
2651 }
2652 );
2653 assert_eq!(
2654 Rgb {
2655 r: 0x61,
2656 g: 0x96,
2657 b: 0xff
2658 }
2659 .to_hex(),
2660 "#6196ff"
2661 );
2662 assert!(Rgb::from_hex("not-a-color").is_none());
2663 }
2664
2665 #[test]
2666 fn oklab_roundtrips_within_tolerance() {
2667 for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
2668 let c = Rgb::from_hex(hex).unwrap();
2669 let back = Rgb::from_oklab(c.to_oklab());
2670 // Gamut round-trip is near-exact (±1 per channel from rounding).
2671 assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
2672 assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
2673 assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
2674 }
2675 }
2676
2677 #[test]
2678 fn wcag_contrast_known_pairs() {
2679 let white = Rgb {
2680 r: 255,
2681 g: 255,
2682 b: 255,
2683 };
2684 let black = Rgb { r: 0, g: 0, b: 0 };
2685 assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
2686 assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
2687 }
2688
2689 #[test]
2690 fn readable_on_picks_by_wcag() {
2691 assert_eq!(
2692 readable_on(Rgb {
2693 r: 255,
2694 g: 255,
2695 b: 255
2696 }),
2697 Rgb { r: 0, g: 0, b: 0 }
2698 );
2699 assert_eq!(
2700 readable_on(Rgb { r: 0, g: 0, b: 0 }),
2701 Rgb {
2702 r: 255,
2703 g: 255,
2704 b: 255
2705 }
2706 );
2707 // A light blue action -> black text reads better.
2708 let action = Rgb::from_hex("#6196ff").unwrap();
2709 assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
2710 }
2711
2712 #[test]
2713 fn lighten_darken_move_oklab_lightness() {
2714 let c = Rgb::from_hex("#6196ff").unwrap();
2715 let l0 = c.to_oklab().l;
2716 assert!(lighten(c, 0.05).to_oklab().l > l0);
2717 assert!(darken(c, 0.05).to_oklab().l < l0);
2718 }
2719
2720 #[test]
2721 fn mix_endpoints_and_midpoint() {
2722 let a = Rgb::from_hex("#000000").unwrap();
2723 let b = Rgb::from_hex("#6196ff").unwrap();
2724 assert_eq!(mix(a, b, 0.0), a);
2725 assert_eq!(mix(a, b, 1.0), b);
2726 // Midpoint sits between the endpoints in OKLab lightness.
2727 let mid = mix(a, b, 0.5).to_oklab().l;
2728 assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
2729 }
2730
2731 // ---- extract + resolve ----
2732
2733 fn nord_toml() -> &'static str {
2734 r##"
2735 [meta]
2736 name = "Nord"
2737 variant = "dark"
2738
2739 [surface]
2740 page = "#2e3440"
2741 raised = "#3b4252"
2742 sunken = "#434c5e"
2743 overlay = "#3b4252"
2744
2745 [content]
2746 primary = "#d8dee9"
2747 secondary = "#e5e9f0"
2748 muted = "#616e88"
2749
2750 [action]
2751 primary = "#81a1c1"
2752
2753 [status]
2754 danger = "#bf616a"
2755 success = "#a3be8c"
2756 warning = "#ebcb8b"
2757 info = "#88c0d0"
2758
2759 [line]
2760 border = "#4c566a"
2761
2762 [category]
2763 one = "#bf616a"
2764 two = "#a3be8c"
2765 three = "#81a1c1"
2766 four = "#ebcb8b"
2767 five = "#b48ead"
2768 six = "#88c0d0"
2769 "##
2770 }
2771
2772 #[test]
2773 fn extract_colors_reads_intent_sections() {
2774 let table: toml::Table = nord_toml().parse().unwrap();
2775 let colors = extract_colors(&table);
2776 assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
2777 assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
2778 assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
2779 assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
2780 assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
2781 assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
2782 assert_eq!(colors.len(), 19);
2783 }
2784
2785 #[test]
2786 fn resolve_base_intents_passthrough() {
2787 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2788 let t = resolve(&theme);
2789 assert_eq!(t.hex("surface-page"), Some("#2e3440"));
2790 assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
2791 // Not a passthrough: a tonal step of the ink, whatever the file said.
2792 assert_eq!(
2793 t.hex("content-muted").unwrap(),
2794 emphasized(
2795 Rgb::from_hex("#d8dee9").unwrap(),
2796 Rgb::from_hex("#2e3440").unwrap(),
2797 Emphasis::Muted
2798 )
2799 .to_hex()
2800 );
2801 assert_eq!(t.hex("action"), Some("#81a1c1"));
2802 assert_eq!(t.hex("danger"), Some("#bf616a"));
2803 assert_eq!(t.hex("border"), Some("#4c566a"));
2804 assert_eq!(t.hex("category-five"), Some("#b48ead"));
2805 }
2806
2807 #[test]
2808 fn a_tonal_step_lands_between_its_base_and_its_ground() {
2809 let ink = Rgb::from_hex("#d8dee9").unwrap();
2810 let page = Rgb::from_hex("#2e3440").unwrap();
2811 for step in [Emphasis::Full, Emphasis::Secondary, Emphasis::Muted] {
2812 let out = emphasized(ink, page, step).to_oklab().l;
2813 assert!(
2814 out <= ink.to_oklab().l && out >= page.to_oklab().l,
2815 "{step:?} left the interval between the ink and the page"
2816 );
2817 }
2818 assert_eq!(emphasized(ink, page, Emphasis::Full).to_hex(), ink.to_hex());
2819 }
2820
2821 #[test]
2822 fn tonal_steps_compose_rather_than_compound() {
2823 // Two steps toward one ground are one step toward it, which is what
2824 // makes deriving a family recursively well-defined. Within a rounding
2825 // step, since each hop lands back in 8-bit sRGB.
2826 let ink = Rgb::from_hex("#d8dee9").unwrap();
2827 let page = Rgb::from_hex("#2e3440").unwrap();
2828 let (a, b) = (0.12f32, 0.42f32);
2829 let twice = tonal(tonal(ink, page, a), page, b);
2830 let once = tonal(ink, page, a + b - a * b);
2831 let (x, y) = (twice.tuple(), once.tuple());
2832 for (l, r) in [(x.0, y.0), (x.1, y.1), (x.2, y.2)] {
2833 assert!(l.abs_diff(r) <= 1, "{twice:?} is not {once:?}");
2834 }
2835 }
2836
2837 #[test]
2838 fn a_ratio_outside_the_interval_is_clamped_rather_than_extrapolated() {
2839 let ink = Rgb::from_hex("#d8dee9").unwrap();
2840 let page = Rgb::from_hex("#2e3440").unwrap();
2841 assert_eq!(tonal(ink, page, -1.0).to_hex(), ink.to_hex());
2842 assert_eq!(tonal(ink, page, 2.0).to_hex(), page.to_hex());
2843 }
2844
2845 #[test]
2846 fn a_derived_token_key_is_the_family_plus_the_step() {
2847 assert_eq!(Emphasis::Muted.token("content"), "content-muted");
2848 assert_eq!(Emphasis::Secondary.token("content"), "content-secondary");
2849 assert_eq!(Emphasis::Full.token("content"), "content");
2850 // The point of the suffix being a property of the step: any family can
2851 // be grouped the same way without a second table saying what it means.
2852 assert_eq!(Emphasis::Muted.token("danger"), "danger-muted");
2853 }
2854
2855 #[test]
2856 fn every_shipped_theme_ramps_one_way() {
2857 // The property authoring the steps separately could not hold: three
2858 // themes had shipped a secondary lighter than their own primary, so a
2859 // renderer reading the emphasis order got the reverse of it.
2860 for (id, toml) in embedded_themes() {
2861 let theme = parse_theme_str(id, toml, false).unwrap();
2862 let t = resolve(&theme);
2863 let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
2864 let steps = ["content", "content-secondary", "content-muted"]
2865 .map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
2866 assert!(
2867 steps[0] > steps[1] && steps[1] > steps[2],
2868 "{id}: emphasis does not fall monotonically: {steps:?}"
2869 );
2870 }
2871 }
2872
2873 #[test]
2874 fn every_shipped_theme_takes_a_visible_first_step() {
2875 // The property that was missing when 2.6.0 derived these, and the
2876 // reason a pure-black ink shipped a secondary 3/255 away from it: the
2877 // ramp falling monotonically says nothing about how far it falls, and
2878 // a step nobody can see is not a step.
2879 for (id, toml) in embedded_themes() {
2880 let theme = parse_theme_str(id, toml, false).unwrap();
2881 let t = resolve(&theme);
2882 let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap();
2883 let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap();
2884 let step = wcag_contrast(ink, secondary);
2885 assert!(
2886 step >= STEP_FLOOR,
2887 "{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor"
2888 );
2889 }
2890 }
2891
2892 #[test]
2893 fn an_authored_emphasis_step_does_not_survive_loading() {
2894 // `nord_toml` still authors both, because a user's theme file might and
2895 // the answer has to be the same one.
2896 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2897 assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
2898 assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
2899 }
2900
2901 #[test]
2902 fn a_theme_with_no_page_keeps_what_it_authored() {
2903 // Skip-missing: there is nothing to read the step against, so the step
2904 // is not taken and a half-written theme does not lose a colour.
2905 let mut colors = HashMap::new();
2906 colors.insert("content.primary".to_string(), "#d8dee9".to_string());
2907 colors.insert("content.muted".to_string(), "#616e88".to_string());
2908 derive_tonal_steps(&mut colors);
2909 assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
2910 }
2911
2912 #[test]
2913 fn resolve_derived_intents() {
2914 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2915 let t = resolve(&theme);
2916 let action = Rgb::from_hex("#81a1c1").unwrap();
2917 let page = Rgb::from_hex("#2e3440").unwrap();
2918 let _ = page;
2919 assert_eq!(
2920 t.hex("action-hover").unwrap(),
2921 lighten(action, 0.05).to_hex()
2922 );
2923 assert_eq!(
2924 t.hex("content-on-action").unwrap(),
2925 readable_on(action).to_hex()
2926 );
2927 assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
2928 assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
2929 // Pruned by the usage audit (0 consumers): action-active, the *-surface
2930 // tints, selection, row-stripe. Apps that need them derive inline via
2931 // the shared mix().
2932 assert!(t.hex("action-active").is_none());
2933 assert!(t.hex("danger-surface").is_none());
2934 assert!(t.hex("selection").is_none());
2935 assert!(t.hex("row-stripe").is_none());
2936 }
2937
2938 #[test]
2939 fn resolve_bevel_intents() {
2940 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2941 let t = resolve(&theme);
2942 let raised = Rgb::from_hex("#3b4252").unwrap();
2943 assert_eq!(
2944 t.hex("bevel-light").unwrap(),
2945 lighten(raised, 0.14).to_hex()
2946 );
2947 assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
2948 }
2949
2950 // A bevel is two edges around one face, so both edges have to be visibly off
2951 // that face or the control never resolves as lit. The lightening clamps at
2952 // the top of the ramp, which means a theme authoring a white raised surface
2953 // gets a highlight identical to the surface it is meant to sit on.
2954 //
2955 // The list is asserted rather than merely reported so that changing a theme
2956 // has to come here and say so. Shrinking it is the fix; growing it is a
2957 // regression in the theme, not in this derivation.
2958 #[test]
2959 fn bevel_edges_are_distinct_from_their_face() {
2960 const CANNOT_BEVEL: &[&str] = &["neobrute", "oxocarbon-light"];
2961
2962 let mut degenerate: Vec<String> = Vec::new();
2963 for (id, source) in embedded_themes() {
2964 let theme = parse_theme_str(id, source, false).unwrap();
2965 let t = resolve(&theme);
2966 let Some(raised) = t.hex("surface-raised") else {
2967 continue;
2968 };
2969 let light = t.hex("bevel-light").expect("raised implies bevel-light");
2970 let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
2971 if light == raised || dark == raised {
2972 degenerate.push(id.to_string());
2973 }
2974 }
2975 degenerate.sort();
2976
2977 assert_eq!(
2978 degenerate, CANNOT_BEVEL,
2979 "themes whose raised surface cannot hold both bevel edges"
2980 );
2981 }
2982
2983 // The well inverts by theme, so assert both directions explicitly rather
2984 // than only the one the light themes happen to take.
2985 #[test]
2986 fn resolve_well_intent_follows_the_content_direction() {
2987 // nord is dark: light text on a dark raised surface, so the well goes
2988 // down and away from the text.
2989 let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
2990 let dark_raised = Rgb::from_hex("#3b4252").unwrap();
2991 assert_eq!(
2992 dark.hex("surface-well").unwrap(),
2993 darken(dark_raised, 0.09).to_hex()
2994 );
2995
2996 // The shipped light themes take the other branch.
2997 let goingson = embedded_themes()
2998 .into_iter()
2999 .find(|(id, _)| *id == "goingson")
3000 .expect("goingson is embedded")
3001 .1;
3002 let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
3003 let light_raised = light
3004 .hex("surface-raised")
3005 .and_then(Rgb::from_hex)
3006 .expect("goingson authors a raised surface");
3007 assert_eq!(
3008 light.hex("surface-well").unwrap(),
3009 lighten(light_raised, 0.07).to_hex()
3010 );
3011 }
3012
3013 // A well is a fill, not an edge, so the only thing that makes it read is
3014 // being a different color from the surface it is cut into.
3015 //
3016 // Same shape and the same asserted-list discipline as
3017 // `bevel_edges_are_distinct_from_their_face`, and it bites the same two
3018 // themes for the same reason: a raised surface already at the top of the
3019 // ramp has nothing lighter to go to.
3020 #[test]
3021 fn well_is_distinct_from_its_face() {
3022 const CANNOT_WELL: &[&str] = &["neobrute", "oxocarbon-light"];
3023
3024 let mut degenerate: Vec<String> = Vec::new();
3025 for (id, source) in embedded_themes() {
3026 let theme = parse_theme_str(id, source, false).unwrap();
3027 let t = resolve(&theme);
3028 let Some(raised) = t.hex("surface-raised") else {
3029 continue;
3030 };
3031 let well = t.hex("surface-well").expect("raised implies surface-well");
3032 if well == raised {
3033 degenerate.push(id.to_string());
3034 }
3035 }
3036 degenerate.sort();
3037
3038 assert_eq!(
3039 degenerate, CANNOT_WELL,
3040 "themes whose raised surface cannot hold a well"
3041 );
3042 }
3043
3044 // Distinct is not the same as visible. A face near the top of the ramp
3045 // clamps partway rather than exactly, which yields a well that differs from
3046 // its face by a hex digit and by nothing the eye can find. `rosepine-dawn`
3047 // authors raised at L=0.987 and gets 0.009 of the 0.07 it asked for.
3048 //
3049 // Worth a separate test from the one above because the fix differs: an
3050 // exactly-degenerate theme needs its raised surface off the ramp end, while
3051 // these need it merely lowered. Both fixes are the theme's, not this
3052 // derivation's, which is why the list is asserted rather than warned about.
3053 #[test]
3054 fn well_is_visible_against_its_face() {
3055 // Below this, the well and its face are the same surface to a reader.
3056 const MIN_DELTA_L: f32 = 0.02;
3057 const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] =
3058 &["neobrute", "oxocarbon-light", "rosepine-dawn"];
3059
3060 let mut invisible: Vec<String> = Vec::new();
3061 for (id, source) in embedded_themes() {
3062 let theme = parse_theme_str(id, source, false).unwrap();
3063 let t = resolve(&theme);
3064 let (Some(raised), Some(well)) = (
3065 t.hex("surface-raised").and_then(Rgb::from_hex),
3066 t.hex("surface-well").and_then(Rgb::from_hex),
3067 ) else {
3068 continue;
3069 };
3070 if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_DELTA_L {
3071 invisible.push(id.to_string());
3072 }
3073 }
3074 invisible.sort();
3075
3076 assert_eq!(
3077 invisible, CANNOT_HOLD_A_VISIBLE_WELL,
3078 "themes whose well is too close to its face to read as one"
3079 );
3080 }
3081
3082 // The three tests above each measure a derived color against the face it was
3083 // derived from, so a theme can pass all of them and still have nothing lift
3084 // off anything: the face itself sits on the page, and that relationship is
3085 // the one a bevel needs in order to read as an object rather than as a
3086 // rectangle with decorated edges. makenot.work passed all three and could
3087 // not hold a bevel, which is what this covers.
3088 //
3089 // The threshold is picked against the ramps already ruled on rather than
3090 // against a round number. makenot.work shipped at 0.024 and was invisible,
3091 // was tried at 0.036 and rejected as marginal on badges and chips, and was
3092 // accepted at 0.058; goingson and audiofiles sit at 0.119 and 0.065. Every
3093 // ramp judged inadequate is below 0.036 and every one judged adequate is
3094 // above 0.058, so the line goes in the gap between them. Note the unit: this
3095 // is oklab L on 0 to 1, not the CIE L* on 0 to 100 that the theme files quote
3096 // in their comments, and the two are not interchangeable.
3097 //
3098 // Most of the list is imported palettes, which were authored for syntax
3099 // highlighting and owe our depth model nothing. Failing here says a theme
3100 // cannot hold a bevel, not that it is wrong. Shrinking the list is the fix;
3101 // growing it is a regression in the theme, not in this derivation.
3102 //
3103 // An entry leaves this list only when the fix is upstream's own, never a
3104 // color we picked. The remaining entries are shallow ramps in published
3105 // palettes, deferred until every app is migrated and eyeballed.
3106 #[test]
3107 fn raised_is_distinct_from_page() {
3108 // Below this, a raised surface and the page under it are one surface to
3109 // a reader, whichever direction the theme ramps in.
3110 const MIN_DELTA_L: f32 = 0.05;
3111 const CANNOT_LIFT_OFF_THE_PAGE: &[&str] = &[
3112 "akari-dawn",
3113 "akari-night",
3114 "ayu-light",
3115 "ayu-mirage",
3116 "catppuccin-latte",
3117 "catppuccin-mocha",
3118 "dawnfox",
3119 "dracula",
3120 "everforest",
3121 "flatwhite",
3122 "gruvbox-light",
3123 "neobrute",
3124 "one-dark",
3125 "oxocarbon-dark",
3126 "oxocarbon-light",
3127 "poimandres",
3128 "rosepine",
3129 "rosepine-dawn",
3130 "solarized-dark",
3131 ];
3132
3133 let mut flat: Vec<String> = Vec::new();
3134 for (id, source) in embedded_themes() {
3135 let theme = parse_theme_str(id, source, false).unwrap();
3136 let t = resolve(&theme);
3137 let (Some(page), Some(raised)) = (
3138 t.hex("surface-page").and_then(Rgb::from_hex),
3139 t.hex("surface-raised").and_then(Rgb::from_hex),
3140 ) else {
3141 continue;
3142 };
3143 if (raised.to_oklab().l - page.to_oklab().l).abs() < MIN_DELTA_L {
3144 flat.push(id.to_string());
3145 }
3146 }
3147 flat.sort();
3148
3149 assert_eq!(
3150 flat, CANNOT_LIFT_OFF_THE_PAGE,
3151 "themes whose raised surface is too close to the page to lift off it"
3152 );
3153 }
3154
3155 // What the bevel pair does on a sixteen-color terminal, measured across the
3156 // shipped set rather than assumed. Two results, both load-bearing for a
3157 // consumer that has to render one there.
3158 //
3159 // Exactly one edge survives, never both. A raised face quantizes onto one of
3160 // the palette's three grays, and the palette is too coarse to hold anything
3161 // between that entry and its neighbour, so whichever edge is pushed toward
3162 // the end of the ramp the face already sits on lands back on the face. Light
3163 // themes and most dark ones keep the shadow and lose the highlight; a face
3164 // that quantizes to black keeps the highlight and loses the shadow.
3165 //
3166 // So a low-color consumer draws the single edge it can render, on the side
3167 // the palette left it, rather than a bevel that resolves on two sides.
3168 //
3169 // And `quantize_against` is the wrong function for this pair, though it is
3170 // the right one for a border. It answers "nearest entry that clears DISTINCT
3171 // against the background", which has no notion of direction, so both edges
3172 // are pushed onto the same contrasting entry and the bevel inverts on one
3173 // side. Plain `quantize` keeps them apart and in the right order.
3174 #[test]
3175 fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
3176 for (id, source) in embedded_themes() {
3177 let theme = parse_theme_str(id, source, false).unwrap();
3178 let t = resolve(&theme);
3179 let (Some(face), Some(light), Some(dark)) = (
3180 t.hex("surface-raised").and_then(Rgb::from_hex),
3181 t.hex("bevel-light").and_then(Rgb::from_hex),
3182 t.hex("bevel-dark").and_then(Rgb::from_hex),
3183 ) else {
3184 continue;
3185 };
3186
3187 let face_index = quantize(face, &ANSI_16);
3188 let light_survives = quantize(light, &ANSI_16) != face_index;
3189 let dark_survives = quantize(dark, &ANSI_16) != face_index;
3190 assert!(
3191 light_survives != dark_survives,
3192 "{id}: expected exactly one bevel edge to survive 16 colors, \
3193 highlight {light_survives} shadow {dark_survives}"
3194 );
3195
3196 // Direction-blind, so it collapses the pair it is asked to separate.
3197 assert_eq!(
3198 quantize_against(light, face, &ANSI_16),
3199 quantize_against(dark, face, &ANSI_16),
3200 "{id}: quantize_against is expected to be unusable for a bevel pair"
3201 );
3202 }
3203 }
3204
3205 // 256 colors is where the bevel starts working. At 16 every shipped theme
3206 // loses an edge; here all but the five whose raised surface sits at the very
3207 // top of the ramp keep both, and those five fail for the reason they fail in
3208 // truecolor rather than for a palette reason.
3209 //
3210 // Three of them cannot bevel at any depth, so they are the
3211 // `bevel_edges_are_distinct_from_their_face` set. The other two are new here:
3212 // they hold a highlight in 24-bit, but not one wide enough to survive
3213 // rounding onto the cube.
3214 #[test]
3215 fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
3216 const LOSES_AN_EDGE: &[&str] = &[
3217 "gruvbox-light",
3218 "neobrute",
3219 "oxocarbon-light",
3220 "rosepine-dawn",
3221 ];
3222
3223 let mut lost: Vec<String> = Vec::new();
3224 for (id, source) in embedded_themes() {
3225 let theme = parse_theme_str(id, source, false).unwrap();
3226 let t = resolve(&theme);
3227 let (Some(face), Some(light), Some(dark)) = (
3228 t.hex("surface-raised").and_then(Rgb::from_hex),
3229 t.hex("bevel-light").and_then(Rgb::from_hex),
3230 t.hex("bevel-dark").and_then(Rgb::from_hex),
3231 ) else {
3232 continue;
3233 };
3234
3235 // Against the fixed region, which is what a consumer should use: a
3236 // match in the low sixteen is a match against a repaintable color.
3237 let f = quantize(face, ANSI_240);
3238 let l = quantize(light, ANSI_240);
3239 let d = quantize(dark, ANSI_240);
3240 if l == f || d == f || l == d {
3241 lost.push(id.to_string());
3242 }
3243 }
3244 lost.sort();
3245
3246 assert_eq!(
3247 lost, LOSES_AN_EDGE,
3248 "themes that cannot hold a two-tone bevel on a 256-color terminal"
3249 );
3250 }
3251
3252 #[test]
3253 fn the_256_table_has_its_three_regions() {
3254 // Index is the escape-sequence index, so the low sixteen must match.
3255 assert_eq!(ANSI_256[..16], ANSI_16);
3256 // The cube's corners, at both ends and one interior level.
3257 assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
3258 assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
3259 assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
3260 // The gray ramp runs 8 to 238 and contains neither black nor white.
3261 assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
3262 assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
3263 // The fixed region is the table minus the repaintable colors.
3264 assert_eq!(ANSI_240.len(), 240);
3265 assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
3266 }
3267
3268 #[test]
3269 fn resolve_overlay_is_dark_translucent_scrim() {
3270 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
3271 let t = resolve(&theme);
3272 let overlay = t.hex("overlay").unwrap();
3273 assert!(
3274 overlay.starts_with("rgba("),
3275 "overlay is translucent: {overlay}"
3276 );
3277 assert!(overlay.ends_with(", 0.5)"));
3278 // The scrim tone is anchored very dark regardless of theme.
3279 let inner = overlay
3280 .trim_start_matches("rgba(")
3281 .trim_end_matches(", 0.5)");
3282 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
3283 let scrim = Rgb {
3284 r: parts[0],
3285 g: parts[1],
3286 b: parts[2],
3287 };
3288 assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
3289 }
3290
3291 /// Every shipped theme derives it, on both polarities, and it is always a
3292 /// near-black translucent tone. A shadow tinted to a dark theme's own
3293 /// lightness would not read as one.
3294 #[test]
3295 fn elevation_is_a_near_black_cast_on_every_theme() {
3296 for (id, source) in embedded_themes() {
3297 let theme = parse_theme_str(id, source, false).unwrap();
3298 let t = resolve(&theme);
3299 let Some(elevation) = t.hex("elevation") else {
3300 panic!("{id} derives no elevation");
3301 };
3302 assert!(
3303 elevation.starts_with("rgba(") && elevation.ends_with(", 0.18)"),
3304 "{id}: elevation is translucent: {elevation}"
3305 );
3306 let inner = elevation
3307 .trim_start_matches("rgba(")
3308 .trim_end_matches(", 0.18)");
3309 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
3310 let cast = Rgb {
3311 r: parts[0],
3312 g: parts[1],
3313 b: parts[2],
3314 };
3315 assert!(
3316 cast.to_oklab().l < 0.2,
3317 "{id}: a cast shadow must be near-black, got {elevation}"
3318 );
3319 }
3320 }
3321
3322 /// The scrim and the cast share an anchor and differ only in weight. Stated
3323 /// as a test because the two are easy to drift apart, and a scrim that
3324 /// stopped matching the shadow under the thing it dims would show.
3325 #[test]
3326 fn elevation_and_the_scrim_are_the_same_tone() {
3327 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
3328 let t = resolve(&theme);
3329 let scrim = t.hex("overlay").unwrap();
3330 let cast = t.hex("elevation").unwrap();
3331 assert_eq!(
3332 scrim.trim_end_matches(", 0.5)"),
3333 cast.trim_end_matches(", 0.18)"),
3334 );
3335 }
3336
3337 /// The accessor that makes a translucent intent reachable from something
3338 /// that is not a stylesheet. Both spellings, and an opaque token answers
3339 /// 255 so a caller need not know which kind it asked for.
3340 #[test]
3341 fn rgba_reads_both_spellings() {
3342 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
3343 let t = resolve(&theme);
3344
3345 let (_, _, _, opaque) = t.rgba("surface-page").expect("page is a hex token");
3346 assert_eq!(opaque, 255);
3347
3348 let (r, g, b, alpha) = t.rgba("elevation").expect("elevation is translucent");
3349 assert_eq!(alpha, 46, "0.18 of 255");
3350 assert_eq!(t.rgb("elevation"), None, "rgb declines to drop the alpha");
3351
3352 let (sr, sg, sb, scrim) = t.rgba("overlay").expect("overlay is translucent");
3353 assert_eq!((sr, sg, sb), (r, g, b), "one tone, two weights");
3354 assert_eq!(scrim, 128);
3355 }
3356
3357 #[test]
3358 fn resolve_drops_non_hex_base_intent() {
3359 // A base intent that isn't a hex color must never reach the resolved
3360 // token set (it would otherwise be inlined verbatim into a <style>
3361 // block). Skipped like a missing intent; valid siblings survive.
3362 let theme = parse_theme_str(
3363 "x",
3364 "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
3365 false,
3366 )
3367 .unwrap();
3368 let t = resolve(&theme);
3369 assert!(
3370 t.hex("surface-page").is_none(),
3371 "non-hex base intent leaked"
3372 );
3373 assert_eq!(t.hex("content").unwrap(), "#111111");
3374 // The injected markup appears in no resolved value.
3375 assert!(!t.intents.values().any(|v| v.contains('<')));
3376 }
3377
3378 #[test]
3379 fn resolve_skips_derived_when_source_missing() {
3380 // No [action] => no action-derived tokens.
3381 let theme = parse_theme_str(
3382 "x",
3383 "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
3384 false,
3385 )
3386 .unwrap();
3387 let t = resolve(&theme);
3388 assert!(t.hex("action").is_none());
3389 assert!(t.hex("action-hover").is_none());
3390 assert!(t.hex("selection").is_none());
3391 assert_eq!(
3392 t.hex("border-strong").unwrap(),
3393 darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()
3394 );
3395 }
3396
3397 #[test]
3398 fn rgb_accessor_for_native_consumers() {
3399 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
3400 let t = resolve(&theme);
3401 assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1)));
3402 assert_eq!(t.rgb("nonexistent"), None);
3403 }
3404
3405 // ---- css emit ----
3406
3407 #[test]
3408 fn intent_css_vars_wraps_root_and_includes_tokens() {
3409 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
3410 let css = intent_css_vars(&resolve(&theme));
3411 assert!(css.starts_with(":root {\n"));
3412 assert!(css.contains(" --surface-page: #2e3440;\n"));
3413 assert!(css.contains(" --danger: #bf616a;\n"));
3414 assert!(css.contains(" --action-hover: "));
3415 assert!(css.trim_end().ends_with('}'));
3416 }
3417
3418 // ---- every theme in one sheet ----
3419
3420 /// The shipped themes, as the search path a consumer hands the emitter.
3421 fn shipped() -> Vec<(PathBuf, bool)> {
3422 vec![(
3423 bundled_themes_dir().expect("makeover ships its themes"),
3424 false,
3425 )]
3426 }
3427
3428 #[test]
3429 fn a_keyed_block_carries_the_same_declarations_as_a_root_one() {
3430 let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
3431 let keyed = keyed_intent_css_vars("nord", &tokens);
3432 assert!(
3433 keyed.starts_with(":root[data-theme=\"nord\"] {\n"),
3434 "{keyed}"
3435 );
3436 assert_eq!(
3437 keyed.replace(":root[data-theme=\"nord\"]", ":root"),
3438 intent_css_vars(&tokens),
3439 "the two emitters differ only in the selector"
3440 );
3441 }
3442
3443 #[test]
3444 fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
3445 let dirs = shipped();
3446 let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
3447
3448 let keys: Vec<&str> = css
3449 .match_indices(":root[data-theme=\"")
3450 .map(|(at, prefix)| {
3451 let rest = &css[at + prefix.len()..];
3452 &rest[..rest.find('"').unwrap()]
3453 })
3454 .collect();
3455
3456 let mut expected: Vec<String> = list_themes_from_dirs(&dirs)
3457 .into_iter()
3458 .map(|meta| meta.id)
3459 .collect();
3460 expected.sort();
3461 assert_eq!(keys, expected, "one block per theme, ordered by id");
3462 assert!(
3463 keys.len() > 20,
3464 "the shipped set is the whole picker: {keys:?}"
3465 );
3466 }
3467
3468 /// The property the whole sheet exists for: a pin is an attribute, and it
3469 /// beats the ambient default without `!important` or ordering games.
3470 #[test]
3471 fn the_default_follows_the_system_and_a_pin_outranks_it() {
3472 let css = all_themes_css(
3473 &shipped(),
3474 &ThemeDefaults::new("goingson", "catppuccin-mocha"),
3475 );
3476
3477 assert!(css.starts_with(":root {\n"), "the light default is first");
3478 assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n"));
3479
3480 // Specificity, not order: (0,1,0) for the default against (0,2,0) for
3481 // a keyed block. Asserted as the fact that the keyed blocks follow the
3482 // defaults, which is the ordering that would matter if they tied.
3483 let dark = css.find("prefers-color-scheme").unwrap();
3484 let first_key = css.find(":root[data-theme=").unwrap();
3485 assert!(dark < first_key, "defaults, then the keyed blocks");
3486 }
3487
3488 /// `for_variant` answers every mode by falling back to dark, so emitting a
3489 /// `prefers-contrast` block unconditionally would answer the preference
3490 /// with a theme that does not honour it.
3491 #[test]
3492 fn a_high_contrast_block_appears_only_when_one_was_named() {
3493 let dirs = shipped();
3494 let plain = ThemeDefaults::new("goingson", "catppuccin-mocha");
3495 assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
3496
3497 let named = plain.clone().high_contrast("high-contrast");
3498 let css = all_themes_css(&dirs, &named);
3499 assert!(
3500 css.contains("@media (prefers-contrast: more) {\n:root {\n"),
3501 "{css}"
3502 );
3503 }
3504
3505 /// A consumer's custom directory is user-writable, so one bad file there
3506 /// costs its own block and nothing else.
3507 #[test]
3508 fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() {
3509 let custom = tempfile::tempdir().unwrap();
3510 fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap();
3511 fs::write(
3512 custom.path().join("mine.toml"),
3513 "[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n",
3514 )
3515 .unwrap();
3516
3517 let mut dirs = shipped();
3518 dirs.push((custom.path().to_path_buf(), true));
3519 let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
3520
3521 assert!(
3522 css.contains(":root[data-theme=\"mine\"] {"),
3523 "a custom theme is switchable too"
3524 );
3525 assert!(!css.contains("data-theme=\"broken\""), "{css}");
3526 assert!(
3527 css.contains(":root[data-theme=\"nord\"] {"),
3528 "the rest of the sheet survives"
3529 );
3530 }
3531
3532 // ---- typography ----
3533
3534 #[test]
3535 fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() {
3536 let css = typography_css_vars();
3537 assert!(css.starts_with(":root {\n"));
3538 assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n"));
3539 assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n"));
3540
3541 // Layer 2 is one hop and no further. A third entry in either stack is
3542 // the shape the standard exists to delete: a chain nobody can predict
3543 // the metrics of, which is what `--font-sans: -apple-system,
3544 // BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps.
3545 for stack in [FONT_MONO, FONT_SANS] {
3546 assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop");
3547 }
3548
3549 // Two tokens, and no others. `--font-body`, `--font-heading` and
3550 // `--font-display` are gone or out of scope; a token appearing here
3551 // is a fifth answer to a question that has two.
3552 assert_eq!(css.matches("--font-").count(), 2);
3553 }
3554
3555 #[test]
3556 fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() {
3557 let css = font_face_css("/static/fonts");
3558
3559 assert_eq!(css.matches("@font-face").count(), 2);
3560 assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");"));
3561 assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");"));
3562
3563 // The trap. Atkinson Hyperlegible Mono's default instance is
3564 // ExtraLight and the cut keeps the axis, so a `@font-face` that omits
3565 // the range draws the whole UI at 200.
3566 assert_eq!(css.matches("font-weight: 200 800;").count(), 2);
3567
3568 // The families have to be exactly what the tokens ask for, or the
3569 // stack falls through to the generic and the face is dead weight.
3570 for family in [FONT_MONO, FONT_SANS] {
3571 let quoted = family.split(',').next().unwrap();
3572 assert!(css.contains(&format!("font-family: {quoted};")));
3573 }
3574 }
3575
3576 #[test]
3577 fn a_trailing_slash_on_the_base_url_does_not_double_it() {
3578 assert_eq!(font_face_css("fonts/"), font_face_css("fonts"));
3579 assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")"));
3580 }
3581
3582 // ---- typography, layer 0 ----
3583
3584 /// The live case: MNW's Young Serif, which reached the page through a
3585 /// hand-maintained `@font-face` and a `--font-heading` nothing else knew
3586 /// about.
3587 fn young_serif() -> FontOverride {
3588 FontOverride::new(FontSlot::Display, "\"Young Serif\", serif")
3589 .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"]))
3590 }
3591
3592 #[test]
3593 fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() {
3594 let t = Typography::house("/static/fonts");
3595 assert_eq!(t.font_face_css(), font_face_css("/static/fonts"));
3596 assert_eq!(t.css_vars(), typography_css_vars());
3597 }
3598
3599 #[test]
3600 fn an_unoverridden_display_slot_defines_no_token_at_all() {
3601 // Not "defined empty": undefined, so the consumer's own fallback in
3602 // `var(--font-display, …)` renders. The MNW embeds depend on it.
3603 let t = Typography::house("fonts");
3604 assert!(!t.css_vars().contains("--font-display"));
3605 assert_eq!(t.resolve(FontSlot::Display), None);
3606 assert_eq!(t.css_vars().matches("--font-").count(), 2);
3607 }
3608
3609 #[test]
3610 fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() {
3611 let t = Typography::house("/static/fonts").with_override(young_serif());
3612
3613 assert!(
3614 t.css_vars()
3615 .contains(" --font-display: \"Young Serif\", serif;\n")
3616 );
3617 assert!(
3618 t.css_vars()
3619 .contains(" --font-mono: \"Quasi Mono\", monospace;\n")
3620 );
3621 assert!(
3622 t.css_vars()
3623 .contains(" --font-sans: \"Quasi Body\", sans-serif;\n")
3624 );
3625 assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif"));
3626
3627 let faces = t.font_face_css();
3628 assert_eq!(faces.matches("@font-face").count(), 3);
3629 assert!(faces.contains("font-family: \"Young Serif\";"));
3630 assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")"));
3631 assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")"));
3632
3633 // The house faces still come first, so a product face never shadows a
3634 // slot it did not claim.
3635 assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap());
3636 }
3637
3638 #[test]
3639 fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() {
3640 // Nobody wants this today. A layer that only permits overriding the
3641 // slot nobody describes is the exemption restated, not a layer.
3642 let t = Typography::house("fonts").with_override(FontOverride::new(
3643 FontSlot::Mono,
3644 "\"Departure Mono\", monospace",
3645 ));
3646
3647 assert!(
3648 t.css_vars()
3649 .contains(" --font-mono: \"Departure Mono\", monospace;\n")
3650 );
3651 assert!(!t.css_vars().contains("Quasi Mono"));
3652 assert_eq!(t.css_vars().matches("--font-").count(), 2);
3653 }
3654
3655 #[test]
3656 #[should_panic(expected = "--font-display is overridden twice")]
3657 fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() {
3658 let _ = Typography::house("fonts")
3659 .with_override(young_serif())
3660 .with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif"));
3661 }
3662
3663 #[test]
3664 fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() {
3665 let t = Typography::house("/static/fonts").with_override(
3666 FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face(
3667 FontFace::new(
3668 "Reglo",
3669 ["Reglo-Bold.woff2", "https://cdn.example/reglo.woff2"],
3670 )
3671 .with_weight("700"),
3672 ),
3673 );
3674 let faces = t.font_face_css();
3675 assert!(faces.contains("url(\"/static/fonts/Reglo-Bold.woff2\")"));
3676 assert!(faces.contains("url(\"https://cdn.example/reglo.woff2\")"));
3677 assert!(faces.contains(" font-weight: 700;\n"));
3678 }
3679
3680 #[test]
3681 fn the_house_tier_renders_byte_for_byte_what_the_format_string_wrote() {
3682 // The house faces became `FontFace` values so they could be read as
3683 // well as emitted. Nothing about the sheet was meant to move, and this
3684 // is the whole of that claim: the literal the format string produced.
3685 let expected = concat!(
3686 "@font-face {\n",
3687 " font-family: \"Quasi Mono\";\n",
3688 " src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");\n",
3689 " font-weight: 200 800;\n",
3690 " font-style: normal;\n",
3691 " font-display: swap;\n",
3692 "}\n\n",
3693 "@font-face {\n",
3694 " font-family: \"Quasi Body\";\n",
3695 " src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");\n",
3696 " font-weight: 200 800;\n",
3697 " font-style: normal;\n",
3698 " font-display: swap;\n",
3699 "}\n\n",
3700 );
3701 assert_eq!(font_face_css("/static/fonts"), expected);
3702 }
3703
3704 #[test]
3705 fn a_house_slot_names_the_same_family_in_its_stack_and_in_its_face() {
3706 // The family is spelled once as a bare name and once inside a CSS
3707 // stack, because a stack cannot be built from a const at compile time.
3708 // A face whose family is not the one the stack names loads and is
3709 // never asked for.
3710 for (slot, family) in [
3711 (FontSlot::Mono, HOUSE_MONO_FAMILY),
3712 (FontSlot::Sans, HOUSE_SANS_FAMILY),
3713 ] {
3714 let face = slot.house_face().expect("a house slot has a house face");
3715 assert_eq!(face.family(), family);
3716 assert!(
3717 slot.house_default()
3718 .unwrap()
3719 .starts_with(&format!("\"{family}\""))
3720 );
3721 }
3722 }
3723
3724 #[test]
3725 fn the_brand_tier_has_no_house_face_the_way_it_has_no_house_stack() {
3726 assert!(FontSlot::Display.house_face().is_none());
3727 assert!(FontSlot::Display.house_default().is_none());
3728 }
3729
3730 #[test]
3731 fn a_face_loading_renderer_reads_the_family_and_the_source_off_the_layer() {
3732 // The egui case, which has no stylesheet in the path at all: the
3733 // renderer registers the file under a name, and the name has to be
3734 // the one the stack spells or the two halves drift.
3735 let t = Typography::house("fonts").with_override(
3736 FontOverride::new(FontSlot::Display, "\"RecursiveMono\", monospace").with_face(
3737 FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).with_weight("700"),
3738 ),
3739 );
3740
3741 let [face] = t.faces(FontSlot::Display) else {
3742 panic!("the display slot ships exactly one face");
3743 };
3744 assert_eq!(face.family(), "RecursiveMono");
3745 assert_eq!(face.sources(), ["RecursiveMonoLnrSt-Bold.ttf"]);
3746 assert!(
3747 t.resolve(FontSlot::Display)
3748 .unwrap()
3749 .contains(face.family())
3750 );
3751 }
3752
3753 #[test]
3754 fn a_weight_and_style_are_readable_now_that_the_builders_are_not_using_the_names() {
3755 let bold = FontFace::new("Reglo", ["Reglo-Bold.woff2"]).with_weight("700");
3756 assert_eq!(bold.weight(), Some("700"));
3757 assert_eq!(
3758 bold.style(),
3759 None,
3760 "unset means normal, not a stated normal"
3761 );
3762
3763 let italic = FontFace::new("Odd", ["odd.woff2"]).with_style("italic");
3764 assert_eq!(italic.weight(), None);
3765 assert_eq!(italic.style(), Some("italic"));
3766 }
3767
3768 #[test]
3769 fn the_house_faces_state_the_variable_range_a_direct_loader_has_to_name() {
3770 // The trap this closes: a variable face's own default instance is
3771 // whatever the base shipped, which for these is ExtraLight. A loader
3772 // that does not name a weight gets that and nothing says so.
3773 for slot in [FontSlot::Mono, FontSlot::Sans] {
3774 let face = slot.house_face().unwrap();
3775 assert_eq!(face.weight(), Some(HOUSE_WEIGHT_RANGE));
3776 assert_eq!(face.style(), Some("normal"));
3777 }
3778 }
3779
3780 #[test]
3781 fn a_source_is_read_back_unresolved_because_only_the_css_wants_a_url() {
3782 let t = Typography::house("/static/fonts").with_override(young_serif());
3783 assert_eq!(
3784 t.faces(FontSlot::Display)[0].sources(),
3785 ["ysrf.woff2", "ysrf.ttf"]
3786 );
3787 // The same face, joined to the base, in the sheet.
3788 assert!(
3789 t.font_face_css()
3790 .contains("url(\"/static/fonts/ysrf.woff2\")")
3791 );
3792 }
3793
3794 #[test]
3795 fn a_slot_nobody_overrode_ships_no_faces_including_the_house_two() {
3796 let t = Typography::house("fonts").with_override(young_serif());
3797 assert!(t.faces(FontSlot::Mono).is_empty());
3798 assert!(t.faces(FontSlot::Sans).is_empty());
3799 assert_eq!(t.faces(FontSlot::Display).len(), 1);
3800 }
3801
3802 #[test]
3803 fn an_unrecognised_extension_gets_no_format_hint_rather_than_a_guessed_one() {
3804 let t = Typography::house("fonts").with_override(
3805 FontOverride::new(FontSlot::Display, "\"Odd\", serif")
3806 .with_face(FontFace::new("Odd", ["odd.eot"])),
3807 );
3808 assert!(t.font_face_css().contains("url(\"fonts/odd.eot\");"));
3809 assert!(!t.font_face_css().contains("format(\"eot\")"));
3810 }
3811
3812 #[test]
3813 fn css_puts_the_faces_before_the_tokens_that_name_them() {
3814 let t = Typography::house("fonts").with_override(young_serif());
3815 let css = t.css();
3816 assert!(css.starts_with("@font-face"));
3817 assert!(css.find("@font-face").unwrap() < css.find(":root").unwrap());
3818 }
3819
3820 // ---- loading / fs ----
3821
3822 #[test]
3823 fn load_and_resolve_round_trip() {
3824 let dir = tempfile::tempdir().unwrap();
3825 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
3826 let dirs = vec![(dir.path().to_path_buf(), false)];
3827 let t = load_semantic(&dirs, "nord").unwrap();
3828 assert_eq!(t.meta.name, "Nord");
3829 assert_eq!(t.hex("action"), Some("#81a1c1"));
3830 }
3831
3832 #[test]
3833 fn load_theme_rejects_invalid_id() {
3834 assert!(load_theme(&[], "../evil").is_err());
3835 }
3836
3837 fn meta(id: &str, variant: &str) -> ThemeMeta {
3838 ThemeMeta {
3839 id: id.to_string(),
3840 name: id.to_string(),
3841 variant: variant.to_string(),
3842 is_custom: false,
3843 }
3844 }
3845
3846 fn defaults() -> ThemeDefaults {
3847 ThemeDefaults::new("flatwhite", "nord")
3848 }
3849
3850 // The three the shipped themes actually declare.
3851 #[test]
3852 fn every_shipped_variant_parses() {
3853 assert_eq!(Variant::parse("light"), Some(Variant::Light));
3854 assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
3855 assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
3856 assert_eq!(Variant::parse("sepia"), None);
3857 }
3858
3859 // parse_meta already defaults a *missing* variant to dark, so an
3860 // unrecognized one reading as light would have the crate disagreeing with
3861 // itself. alloy_tui did exactly that before this existed.
3862 #[test]
3863 fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
3864 assert_eq!(Variant::from("sepia"), Variant::Dark);
3865 assert_eq!(Variant::from(""), Variant::Dark);
3866
3867 let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
3868 assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
3869 }
3870
3871 #[test]
3872 fn a_selection_round_trips_through_any_store() {
3873 for (stored, expect) in [
3874 (Some("system"), ThemeSelection::Follow),
3875 (None, ThemeSelection::Follow),
3876 (Some(""), ThemeSelection::Follow),
3877 (Some(" "), ThemeSelection::Follow),
3878 (Some("nord"), ThemeSelection::Fixed("nord".into())),
3879 ] {
3880 let parsed = ThemeSelection::parse(stored);
3881 assert_eq!(parsed, expect, "{stored:?}");
3882 assert_eq!(
3883 ThemeSelection::parse(Some(parsed.as_str())),
3884 expect,
3885 "what is written reads back as what was meant",
3886 );
3887 }
3888 }
3889
3890 // Nothing saved is follow-the-system, which is what Balanced Breakfast
3891 // expressed as an absent value and GoingsOn as a sentinel. Both are now the
3892 // same thing.
3893 #[test]
3894 fn nothing_chosen_yet_is_follow() {
3895 assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
3896 }
3897
3898 #[test]
3899 fn a_fixed_selection_wins_when_its_theme_is_installed() {
3900 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
3901 let fixed = ThemeSelection::Fixed("nord".into());
3902 assert_eq!(
3903 fixed.resolve(Variant::Light, &defaults(), &available),
3904 "nord",
3905 "a chosen theme is not overridden by the ambient mode",
3906 );
3907 }
3908
3909 // Themes are deletable in three of the four apps. Handing back an id that
3910 // will fail to load only moves the error somewhere less helpful.
3911 #[test]
3912 fn a_fixed_selection_whose_theme_is_gone_falls_back() {
3913 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
3914 let fixed = ThemeSelection::Fixed("deleted".into());
3915 assert_eq!(
3916 fixed.resolve(Variant::Light, &defaults(), &available),
3917 "flatwhite",
3918 );
3919 }
3920
3921 #[test]
3922 fn follow_picks_the_apps_default_for_the_ambient_mode() {
3923 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
3924 let follow = ThemeSelection::Follow;
3925 assert_eq!(
3926 follow.resolve(Variant::Dark, &defaults(), &available),
3927 "nord",
3928 );
3929 assert_eq!(
3930 follow.resolve(Variant::Light, &defaults(), &available),
3931 "flatwhite",
3932 );
3933 }
3934
3935 // The behaviour Balanced Breakfast could not have: following the system
3936 // into a theme the user installed, when the app's own default is absent.
3937 #[test]
3938 fn follow_uses_any_installed_theme_of_the_right_variant() {
3939 let available = [meta("solarized-light", "light"), meta("mine", "dark")];
3940 assert_eq!(
3941 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
3942 "mine",
3943 "the app's `nord` is not installed, but a dark theme is",
3944 );
3945 }
3946
3947 // Always returns something: an app with no theme directory gets the id it
3948 // ships with, and the load error it would have had anyway.
3949 #[test]
3950 fn an_empty_catalog_still_names_the_apps_default() {
3951 assert_eq!(
3952 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]),
3953 "nord",
3954 );
3955 }
3956
3957 #[test]
3958 fn high_contrast_falls_back_to_dark_unless_named() {
3959 let plain = defaults();
3960 assert_eq!(plain.for_variant(Variant::HighContrast), "nord");
3961
3962 let named = defaults().high_contrast("sharp");
3963 assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
3964 }
3965
3966 // The bug this builder exists to prevent: the Alloy console pushed the
3967 // user's directory first under a comment reading "highest precedence
3968 // first", when both consumers of this vector resolve last-wins. A custom
3969 // theme lost to the packaged one of the same id.
3970 #[test]
3971 fn the_users_own_themes_outrank_everything() {
3972 let root = tempfile::tempdir().unwrap();
3973 let make = |name: &str| {
3974 let dir = root.path().join(name);
3975 std::fs::create_dir_all(&dir).unwrap();
3976 dir
3977 };
3978 let (bundled, system, custom) = (make("bundled"), make("system"), make("custom"));
3979
3980 let dirs = ThemeDirs::new()
3981 .custom(Some(custom.clone()))
3982 .bundled(Some(bundled.clone()))
3983 .system(Some(system.clone()))
3984 .build();
3985
3986 assert_eq!(
3987 dirs,
3988 vec![(bundled, false), (system, false), (custom.clone(), true)],
3989 "lowest precedence first, whatever order the tiers were added in",
3990 );
3991 assert!(dirs.last().unwrap().1, "only the user's tier is custom");
3992
3993 // And the ordering means what the consumers think it means.
3994 for dir in dirs.iter().map(|(dir, _)| dir) {
3995 std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
3996 }
3997 assert_eq!(
3998 find_theme_path(&dirs, "shared").unwrap().0,
3999 custom.join("shared.toml"),
4000 "the user's copy is the one that loads",
4001 );
4002 }
4003
4004 #[test]
4005 fn a_directory_that_does_not_exist_is_dropped() {
4006 let root = tempfile::tempdir().unwrap();
4007 let real = root.path().join("real");
4008 std::fs::create_dir_all(&real).unwrap();
4009
4010 let dirs = ThemeDirs::new()
4011 .bundled(Some(root.path().join("nope")))
4012 .system(None)
4013 .custom(Some(real.clone()))
4014 .build();
4015
4016 assert_eq!(dirs, vec![(real, true)]);
4017 }
4018
4019 // A Tauri app has two bundled tiers: the resource dir in production and the
4020 // tree build.rs materialized for a dev run with no resource dir.
4021 #[test]
4022 fn more_than_one_bundled_tier_is_allowed() {
4023 let root = tempfile::tempdir().unwrap();
4024 let (first, second) = (root.path().join("a"), root.path().join("b"));
4025 std::fs::create_dir_all(&first).unwrap();
4026 std::fs::create_dir_all(&second).unwrap();
4027
4028 let dirs = ThemeDirs::new()
4029 .bundled(Some(first.clone()))
4030 .bundled(Some(second.clone()))
4031 .build();
4032 assert_eq!(dirs, vec![(first, false), (second, false)]);
4033 }
4034
4035 #[test]
4036 fn list_themes_from_dirs_finds_toml_files() {
4037 let dir = tempfile::tempdir().unwrap();
4038 fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
4039 fs::write(dir.path().join("x.txt"), "ignored").unwrap();
4040 let dirs = vec![(dir.path().to_path_buf(), false)];
4041 let themes = list_themes_from_dirs(&dirs);
4042 assert_eq!(themes.len(), 1);
4043 assert_eq!(themes[0].id, "t");
4044 }
4045
4046 #[test]
4047 fn find_theme_path_reverse_priority() {
4048 let d1 = tempfile::tempdir().unwrap();
4049 let d2 = tempfile::tempdir().unwrap();
4050 fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap();
4051 fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap();
4052 let dirs = vec![
4053 (d1.path().to_path_buf(), false),
4054 (d2.path().to_path_buf(), true),
4055 ];
4056 let (path, is_custom) = find_theme_path(&dirs, "s").unwrap();
4057 assert!(is_custom);
4058 assert_eq!(path, d2.path().join("s.toml"));
4059 }
4060
4061 #[test]
4062 fn import_theme_valid_and_rejects_empty() {
4063 let src_dir = tempfile::tempdir().unwrap();
4064 let custom_dir = tempfile::tempdir().unwrap();
4065
4066 let good = src_dir.path().join("my-theme.toml");
4067 fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
4068 let meta = import_theme(&good, custom_dir.path()).unwrap();
4069 assert_eq!(meta.id, "my-theme");
4070 assert!(custom_dir.path().join("my-theme.toml").exists());
4071
4072 let empty = src_dir.path().join("empty.toml");
4073 fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
4074 assert!(import_theme(&empty, custom_dir.path()).is_err());
4075 }
4076
4077 #[test]
4078 fn import_theme_rejects_invalid_toml() {
4079 let src_dir = tempfile::tempdir().unwrap();
4080 let custom_dir = tempfile::tempdir().unwrap();
4081 let src = src_dir.path().join("bad.toml");
4082 fs::write(&src, "this is not [valid toml [[[").unwrap();
4083 assert!(import_theme(&src, custom_dir.path()).is_err());
4084 }
4085
4086 #[test]
4087 fn delete_theme_removes_and_guards() {
4088 let custom = tempfile::tempdir().unwrap();
4089 let path = custom.path().join("doomed.toml");
4090 fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
4091 delete_theme(custom.path(), "doomed").unwrap();
4092 assert!(!path.exists());
4093 assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
4094 assert!(delete_theme(custom.path(), "ghost").is_err());
4095 }
4096
4097 #[test]
4098 fn export_theme_copies_file() {
4099 let src_dir = tempfile::tempdir().unwrap();
4100 let dest_dir = tempfile::tempdir().unwrap();
4101 let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
4102 fs::write(src_dir.path().join("e.toml"), content).unwrap();
4103 let dirs = vec![(src_dir.path().to_path_buf(), false)];
4104 let dest = dest_dir.path().join("out.toml");
4105 export_theme(&dirs, "e", &dest).unwrap();
4106 assert_eq!(fs::read_to_string(&dest).unwrap(), content);
4107 assert!(export_theme(&dirs, "missing", &dest).is_err());
4108 }
4109
4110 #[test]
4111 fn load_theme_preview_returns_role_swatches() {
4112 let dir = tempfile::tempdir().unwrap();
4113 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
4114 let dirs = vec![(dir.path().to_path_buf(), false)];
4115 let p = load_theme_preview(&dirs, "nord").unwrap();
4116 assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page
4117 assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary
4118 assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary
4119 assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border
4120 }
4121
4122 #[test]
4123 fn bundled_themes_dir_resolves_to_shipped_themes() {
4124 // The crate ships its themes, so this must resolve in-tree and the
4125 // Akari defaults the console falls back to must be present.
4126 let dir = bundled_themes_dir().expect("makeover ships a themes/ directory");
4127 assert!(dir.join("akari-dawn.toml").is_file());
4128 assert!(dir.join("akari-night.toml").is_file());
4129 }
4130
4131 #[test]
4132 fn every_theme_is_accounted_for_in_third_party_notices() {
4133 // Attribution is a redistribution obligation, not a nicety: adding a
4134 // theme without a notice entry silently ships someone's work
4135 // uncredited. Fail here instead.
4136 let notices = std::fs::read_to_string(
4137 Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"),
4138 )
4139 .expect("THIRD-PARTY-NOTICES.md must exist");
4140 let missing: Vec<&str> = embedded_themes()
4141 .map(|(id, _)| id)
4142 .filter(|id| !notices.contains(*id))
4143 .collect();
4144 assert!(
4145 missing.is_empty(),
4146 "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}"
4147 );
4148 }
4149
4150 #[test]
4151 fn adapted_themes_carry_inline_attribution() {
4152 // Each adapted file must name its upstream in-file, so the credit
4153 // survives someone copying a single .toml out of the crate.
4154 const ORIGINALS: [&str; 5] = [
4155 "makenotwork",
4156 "goingson",
4157 "audiofiles",
4158 "high-contrast",
4159 "neobrute",
4160 ];
4161 for (id, source) in embedded_themes() {
4162 if ORIGINALS.contains(&id) {
4163 continue;
4164 }
4165 assert!(
4166 source.contains("adapted from"),
4167 "adapted theme `{id}` is missing its inline attribution header"
4168 );
4169 }
4170 }
4171
4172 #[test]
4173 fn embedded_themes_match_the_directory() {
4174 // The embedded copy and themes/ are two views of one source. If they
4175 // ever disagree, path-based and path-free consumers render different
4176 // theme sets, which is exactly the drift shipping the data was meant
4177 // to prevent.
4178 let dir = bundled_themes_dir().unwrap();
4179 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
4180 .unwrap()
4181 .filter_map(|e| {
4182 let path = e.ok()?.path();
4183 if path.extension()? != "toml" {
4184 return None;
4185 }
4186 Some(path.file_stem()?.to_str()?.to_string())
4187 })
4188 .collect();
4189 let mut embedded: Vec<String> = embedded_themes().map(|(id, _)| id.to_string()).collect();
4190 on_disk.sort();
4191 embedded.sort();
4192 assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/");
4193 }
4194
4195 #[test]
4196 fn every_embedded_theme_parses() {
4197 // Guards the path-free consumers (MNW server, the Tauri build steps)
4198 // the same way every_shipped_theme_loads guards the path-based ones.
4199 let mut count = 0;
4200 for (id, source) in embedded_themes() {
4201 parse_theme_str(id, source, false)
4202 .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}"));
4203 count += 1;
4204 }
4205 assert!(count >= 30, "expected the full theme set, got {count}");
4206 }
4207
4208 #[test]
4209 fn every_shipped_theme_loads() {
4210 // Guards the data, not just the loader: a malformed or truncated
4211 // .toml in themes/ is a shipping bug, and it should fail here rather
4212 // than at a user's first launch.
4213 let dir = bundled_themes_dir().unwrap();
4214 let dirs = vec![(dir.clone(), false)];
4215 let themes = list_themes_from_dirs(&dirs);
4216 assert!(
4217 themes.len() >= 30,
4218 "expected the full theme set, got {}",
4219 themes.len()
4220 );
4221 for meta in &themes {
4222 load_theme(&dirs, &meta.id)
4223 .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
4224 }
4225 }
4226
4227 #[test]
4228 fn theme_options_groups_by_variant_light_first() {
4229 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4230 let options = theme_options(&dirs);
4231 assert!(!options.is_empty(), "the shipped set is not empty");
4232
4233 let order: Vec<u8> = options.iter().map(|o| variant_order(o.variant)).collect();
4234 let mut sorted = order.clone();
4235 sorted.sort_unstable();
4236 assert_eq!(
4237 order, sorted,
4238 "every variant should occupy one run, light first"
4239 );
4240 }
4241
4242 #[test]
4243 fn theme_options_puts_the_most_legible_theme_first_in_its_group() {
4244 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4245 let options = theme_options(&dirs);
4246
4247 for pair in options.windows(2) {
4248 let (a, b) = (&pair[0], &pair[1]);
4249 if a.variant != b.variant {
4250 continue;
4251 }
4252 assert!(
4253 a.contrast >= b.contrast,
4254 "within {}, {} ({:?}) should not follow {} ({:?})",
4255 a.variant,
4256 b.id,
4257 b.contrast,
4258 a.id,
4259 a.contrast
4260 );
4261 if a.contrast == b.contrast {
4262 assert!(
4263 a.name <= b.name,
4264 "ties break by name: {} then {}",
4265 a.name,
4266 b.name
4267 );
4268 }
4269 }
4270 }
4271
4272 #[test]
4273 fn theme_options_carries_every_theme_the_scan_found() {
4274 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4275 let mut scanned: Vec<String> = list_themes_from_dirs(&dirs)
4276 .into_iter()
4277 .map(|meta| meta.id)
4278 .collect();
4279 let mut offered: Vec<String> = theme_options(&dirs).into_iter().map(|o| o.id).collect();
4280 scanned.sort();
4281 offered.sort();
4282 assert_eq!(scanned, offered, "ordering must not drop a theme");
4283 }
4284
4285 #[test]
4286 fn a_theme_that_cannot_be_measured_reads_as_standard() {
4287 // Not Low: a missing ground is the scan failing, and badging the theme
4288 // for that would tell the reader something untrue about the theme.
4289 let theme = ThemeColors {
4290 meta: ThemeMeta {
4291 id: "unmeasurable".to_string(),
4292 name: "Unmeasurable".to_string(),
4293 variant: "dark".to_string(),
4294 is_custom: false,
4295 },
4296 colors: HashMap::new(),
4297 };
4298 assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
4299 }
4300
4301 #[test]
4302 fn the_house_themes_measure_high() {
4303 // The two we author. A change that drops either below AA is a
4304 // regression in a theme we control.
4305 //
4306 // `high-contrast` is deliberately not in this list. It measures
4307 // 4.89/3.53 and therefore reads as Standard: its muted text misses AA
4308 // on its own sunken panel. That is a finding about the theme file, not
4309 // about the measurement, and it is filed rather than asserted away.
4310 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
4311 for id in ["goingson", "audiofiles"] {
4312 let theme = load_theme(&dirs, id).expect("shipped");
4313 assert_eq!(
4314 ContrastTier::of(&theme),
4315 ContrastTier::High,
4316 "{id} is one of ours and should meet AA on both grounds"
4317 );
4318 }
4319 }
4320
4321 #[test]
4322 fn contrast_tiers_order_worst_first() {
4323 assert!(ContrastTier::Low < ContrastTier::Standard);
4324 assert!(ContrastTier::Standard < ContrastTier::High);
4325 }
4326 }
4327