Skip to main content

max / makeover

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