Skip to main content

max / makeover

99.9 KB · 2704 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] # text/ink by emphasis
21 //! primary = "#d8dee9"; secondary = "#e5e9f0"; muted = "#616e88"
22 //!
23 //! [action] # interactive / brand color
24 //! primary = "#81a1c1"
25 //!
26 //! [status] # state semantics
27 //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0"
28 //!
29 //! [line]
30 //! border = "#4c566a"
31 //!
32 //! [category] # distinct decorative colors for tags/badges/charts
33 //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1"
34 //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0"
35 //! ```
36
37 // Color-space math: single-letter channel names (r/g/b/l/m/s) and the published
38 // high-precision OKLab/sRGB matrix constants are the domain vocabulary here.
39 #![allow(clippy::many_single_char_names, clippy::unreadable_literal)]
40
41 use serde::Serialize;
42 use std::collections::{BTreeMap, HashMap};
43 use std::path::{Path, PathBuf};
44
45 /// The color sections an authored theme may declare.
46 pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
47
48 /// Theme metadata parsed from the `[meta]` section.
49 #[derive(Debug, Clone, Serialize)]
50 #[serde(rename_all = "camelCase")]
51 pub struct ThemeMeta {
52 pub id: String,
53 pub name: String,
54 pub variant: String,
55 pub is_custom: bool,
56 }
57
58 /// A loaded theme: metadata plus the authored colors, flattened to dotted keys
59 /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`).
60 #[derive(Debug, Serialize)]
61 #[serde(rename_all = "camelCase")]
62 pub struct ThemeColors {
63 pub meta: ThemeMeta,
64 pub colors: HashMap<String, String>,
65 }
66
67 // ============================================================================
68 // Color math — perceptual (OKLab) derivations + WCAG contrast.
69 //
70 // Interactive states (hover/active/selection/surfaces) are derived in OKLab so
71 // equal steps look equal across every theme's hues (Ottosson 2020; the modern
72 // CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive
73 // luminance threshold, so the choice actually meets AA where achievable.
74 // This is the single source of truth shared by every product.
75 // ============================================================================
76
77 /// An sRGB color. Hex round-trips losslessly.
78 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
79 pub struct Rgb {
80 pub r: u8,
81 pub g: u8,
82 pub b: u8,
83 }
84
85 impl Rgb {
86 /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise.
87 pub fn from_hex(s: &str) -> Option<Rgb> {
88 let h = s.strip_prefix('#')?;
89 let (r, g, b) = match h.len() {
90 6 => (
91 u8::from_str_radix(&h[0..2], 16).ok()?,
92 u8::from_str_radix(&h[2..4], 16).ok()?,
93 u8::from_str_radix(&h[4..6], 16).ok()?,
94 ),
95 3 => {
96 let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17);
97 (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?)
98 }
99 _ => return None,
100 };
101 Some(Rgb { r, g, b })
102 }
103
104 /// Lowercase `#rrggbb`.
105 pub fn to_hex(self) -> String {
106 format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
107 }
108
109 pub fn tuple(self) -> (u8, u8, u8) {
110 (self.r, self.g, self.b)
111 }
112 }
113
114 /// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes.
115 #[derive(Clone, Copy, Debug)]
116 pub struct Oklab {
117 pub l: f32,
118 pub a: f32,
119 pub b: f32,
120 }
121
122 fn srgb_to_linear(c: u8) -> f32 {
123 let c = c as f32 / 255.0;
124 if c <= 0.04045 {
125 c / 12.92
126 } else {
127 ((c + 0.055) / 1.055).powf(2.4)
128 }
129 }
130
131 fn linear_to_srgb(c: f32) -> u8 {
132 let c = c.clamp(0.0, 1.0);
133 let v = if c <= 0.0031308 {
134 c * 12.92
135 } else {
136 1.055 * c.powf(1.0 / 2.4) - 0.055
137 };
138 (v * 255.0).round().clamp(0.0, 255.0) as u8
139 }
140
141 impl Rgb {
142 /// Convert to OKLab (Ottosson's sRGB matrices).
143 ///
144 /// The matrix coefficients are quoted at their published precision so they
145 /// can be diffed against the reference. `f32` rounds them at compile time;
146 /// truncating the literals would only make them harder to check.
147 #[allow(clippy::excessive_precision)]
148 pub fn to_oklab(self) -> Oklab {
149 let (r, g, b) = (
150 srgb_to_linear(self.r),
151 srgb_to_linear(self.g),
152 srgb_to_linear(self.b),
153 );
154 let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
155 let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
156 let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
157 let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt());
158 Oklab {
159 l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
160 a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
161 b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
162 }
163 }
164
165 /// Convert from OKLab back to the nearest in-gamut sRGB.
166 ///
167 /// Published precision, as in [`Rgb::to_oklab`].
168 #[allow(clippy::excessive_precision)]
169 pub fn from_oklab(c: Oklab) -> Rgb {
170 let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b;
171 let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b;
172 let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b;
173 let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
174 Rgb {
175 r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
176 g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
177 b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
178 }
179 }
180 }
181
182 /// WCAG 2.x relative luminance of an sRGB color.
183 fn rel_luminance(c: Rgb) -> f32 {
184 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b)
185 }
186
187 /// WCAG 2.x contrast ratio between two colors, in [1, 21].
188 pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 {
189 let (la, lb) = (rel_luminance(a), rel_luminance(b));
190 let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
191 (hi + 0.05) / (lo + 0.05)
192 }
193
194 /// Pick black or white for legible text on `bg`, by the higher WCAG contrast
195 /// ratio (so the choice meets AA wherever the background allows it).
196 pub fn readable_on(bg: Rgb) -> Rgb {
197 let white = Rgb {
198 r: 255,
199 g: 255,
200 b: 255,
201 };
202 let black = Rgb { r: 0, g: 0, b: 0 };
203 if wcag_contrast(white, bg) >= wcag_contrast(black, bg) {
204 white
205 } else {
206 black
207 }
208 }
209
210 /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens.
211 pub fn lighten(c: Rgb, delta: f32) -> Rgb {
212 let mut lab = c.to_oklab();
213 lab.l = (lab.l + delta).clamp(0.0, 1.0);
214 Rgb::from_oklab(lab)
215 }
216
217 /// Shift OKLab lightness down by `delta` (perceptually uniform).
218 pub fn darken(c: Rgb, delta: f32) -> Rgb {
219 lighten(c, -delta)
220 }
221
222 /// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend).
223 pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb {
224 let (x, y) = (a.to_oklab(), b.to_oklab());
225 Rgb::from_oklab(Oklab {
226 l: x.l + (y.l - x.l) * t,
227 a: x.a + (y.a - x.a) * t,
228 b: x.b + (y.b - x.b) * t,
229 })
230 }
231
232 // ============================================================================
233 // Low-color terminals
234 // ============================================================================
235
236 /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA
237 /// arrangement the Linux console and most emulators start from.
238 ///
239 /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray
240 /// rather than white, which is the entry a themed surface usually lands on, and
241 /// index 15 is the true white.
242 ///
243 /// Emulators let the user repaint all sixteen, so this is the standard
244 /// arrangement rather than a promise about any one terminal. The Linux console
245 /// keeps it, which is the case that matters: a console app cannot fall back to
246 /// 24-bit color there.
247 pub const ANSI_16: [Rgb; 16] = [
248 Rgb {
249 r: 0x00,
250 g: 0x00,
251 b: 0x00,
252 },
253 Rgb {
254 r: 0xaa,
255 g: 0x00,
256 b: 0x00,
257 },
258 Rgb {
259 r: 0x00,
260 g: 0xaa,
261 b: 0x00,
262 },
263 Rgb {
264 r: 0xaa,
265 g: 0x55,
266 b: 0x00,
267 },
268 Rgb {
269 r: 0x00,
270 g: 0x00,
271 b: 0xaa,
272 },
273 Rgb {
274 r: 0xaa,
275 g: 0x00,
276 b: 0xaa,
277 },
278 Rgb {
279 r: 0x00,
280 g: 0xaa,
281 b: 0xaa,
282 },
283 Rgb {
284 r: 0xaa,
285 g: 0xaa,
286 b: 0xaa,
287 },
288 Rgb {
289 r: 0x55,
290 g: 0x55,
291 b: 0x55,
292 },
293 Rgb {
294 r: 0xff,
295 g: 0x55,
296 b: 0x55,
297 },
298 Rgb {
299 r: 0x55,
300 g: 0xff,
301 b: 0x55,
302 },
303 Rgb {
304 r: 0xff,
305 g: 0xff,
306 b: 0x55,
307 },
308 Rgb {
309 r: 0x55,
310 g: 0x55,
311 b: 0xff,
312 },
313 Rgb {
314 r: 0xff,
315 g: 0x55,
316 b: 0xff,
317 },
318 Rgb {
319 r: 0x55,
320 g: 0xff,
321 b: 0xff,
322 },
323 Rgb {
324 r: 0xff,
325 g: 0xff,
326 b: 0xff,
327 },
328 ];
329
330 /// The 256 colors an xterm-compatible terminal addresses by index, so that
331 /// entry `i` is what the terminal paints for `38;5;i`.
332 ///
333 /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`]
334 /// system colors, which every emulator lets the user repaint. 16-231 are a
335 /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed.
336 ///
337 /// So a color whose whole job is to be told apart from another should quantize
338 /// against [`ANSI_240`] rather than against this table: a match landing in the
339 /// low sixteen is a match against a color the user may have moved.
340 pub const ANSI_256: [Rgb; 256] = build_ansi_256();
341
342 /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without
343 /// the sixteen repaintable system colors.
344 ///
345 /// Quantizing against this returns an index into *this* slice; add
346 /// [`ANSI_240_OFFSET`] to get the index the terminal wants.
347 pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
348
349 /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one.
350 pub const ANSI_240_OFFSET: usize = 16;
351
352 /// The twelve chromatic ANSI slots, as the intents that paint them.
353 ///
354 /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light
355 /// or dark, since red is the theme's danger tone either way, which is exactly
356 /// why the four achromatic slots are not in this table.
357 ///
358 /// Lifted from Alloy's `skelgen` on 2026-07-31, which had folded three
359 /// disagreeing hand-maintained copies into one and is the reason the
360 /// arrangement is trusted. It moved here so a program that paints its own
361 /// palette at runtime, rather than reading a generated config, resolves the
362 /// same slots. Slot 14 was the one the copies disagreed on and is
363 /// `category.six`, which both the Linux console table and the retired
364 /// `vtrgb.py` had.
365 const CHROMATIC: [(usize, &str); 12] = [
366 (1, "status.danger"),
367 (2, "status.success"),
368 (3, "status.warning"),
369 (4, "status.info"),
370 (5, "category.five"),
371 (6, "category.six"),
372 (9, "action.primary"), // bright red, the theme's warm accent
373 (10, "status.success"),
374 (11, "status.warning"),
375 (12, "status.info"),
376 (13, "category.five"),
377 (14, "category.six"),
378 ];
379
380 /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme.
381 ///
382 /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is
383 /// "white", but what a terminal wants there is *the darkest tone* and *the
384 /// lightest tone*, and which intent that is flips with the theme's polarity. A
385 /// light theme's darkest tone is its ink; a dark theme's is its deepest
386 /// surface. Pinning slot 0 to `content.primary` reads correctly on a light
387 /// theme and hands a dark one a pale cream as "black".
388 ///
389 /// Slot 7 is a surface and not a text tone, because it is what a program with
390 /// no way to name anything else draws its container on: a greeter's login card
391 /// is a light card on the darker field slot 0 paints.
392 ///
393 /// Anything that is not `dark`, including `high-contrast`, follows the light
394 /// anchors.
395 fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
396 let dark = variant == "dark";
397 Some(match (index, dark) {
398 (0, false) => "content.primary", // darkest text tone
399 (0, true) => "surface.sunken", // darkest surface
400 (7, false) => "surface.raised", // the login card
401 (7, true) => "content.secondary", // a readable light tone
402 (8, _) => "content.muted", // muted chrome, either way
403 (15, false) => "surface.overlay", // lightest surface
404 (15, true) => "content.primary", // lightest text tone
405 _ => return None,
406 })
407 }
408
409 /// The authored intent painting ANSI slot `index` under a theme of `variant`,
410 /// as a dotted key into [`ThemeColors::colors`].
411 ///
412 /// `None` for an index outside 0-15. Every slot in range resolves, so a caller
413 /// that has the intent can fill all sixteen.
414 ///
415 /// This is what makes a bare console, a terminal emulator and a generated
416 /// config agree on what red means. They disagreed for as long as each kept its
417 /// own table.
418 #[must_use]
419 pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> {
420 achromatic_slot(index, variant).or_else(|| {
421 CHROMATIC
422 .iter()
423 .find(|(slot, _)| *slot == index)
424 .map(|(_, intent)| *intent)
425 })
426 }
427
428 const fn build_ansi_256() -> [Rgb; 256] {
429 let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256];
430
431 let mut i = 0;
432 while i < 16 {
433 table[i] = ANSI_16[i];
434 i += 1;
435 }
436
437 // The cube's six levels are not evenly spaced. The step from black to the
438 // first is more than twice any later one, which is xterm's arrangement
439 // rather than a choice available here, and it is why the darkest tones a
440 // theme can reach on 256 colors come from the gray ramp instead.
441 const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
442 let mut r = 0;
443 while r < 6 {
444 let mut g = 0;
445 while g < 6 {
446 let mut b = 0;
447 while b < 6 {
448 table[16 + 36 * r + 6 * g + b] = Rgb {
449 r: LEVELS[r],
450 g: LEVELS[g],
451 b: LEVELS[b],
452 };
453 b += 1;
454 }
455 g += 1;
456 }
457 r += 1;
458 }
459
460 // 8 to 238 in steps of 10. Neither end is black or white; both of those are
461 // in the cube, so the ramp is 24 steps of gray between them rather than 24
462 // steps of the whole range.
463 let mut k = 0;
464 while k < 24 {
465 let v = 8 + 10 * k as u8;
466 table[232 + k as usize] = Rgb { r: v, g: v, b: v };
467 k += 1;
468 }
469
470 table
471 }
472
473 /// The contrast ratio two colors must clear to read as separate areas.
474 ///
475 /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
476 /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
477 /// text can ask for more by checking [`wcag_contrast`] itself.
478 pub const DISTINCT: f32 = 3.0;
479
480 /// Perceptual distance between two colors, for choosing the closest of a set.
481 fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
482 let (x, y) = (a.to_oklab(), b.to_oklab());
483 ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
484 }
485
486 /// Index of the entry in `palette` that looks most like `c`.
487 ///
488 /// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
489 /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
490 /// nearest match computed in it picks visibly wrong entries in the mid tones.
491 ///
492 /// # Panics
493 ///
494 /// If `palette` is empty.
495 pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
496 assert!(!palette.is_empty(), "a palette needs at least one color");
497 let mut best = 0;
498 let mut best_distance = f32::INFINITY;
499 for (index, entry) in palette.iter().enumerate() {
500 let distance = oklab_distance(c, *entry);
501 if distance < best_distance {
502 best = index;
503 best_distance = distance;
504 }
505 }
506 best
507 }
508
509 /// Index of the entry in `palette` closest to `fg` that still reads against
510 /// `bg`.
511 ///
512 /// [`quantize`] answers about one color at a time, and two colors that differ
513 /// can quantize to the same entry: a themed page and a border drawn on it are
514 /// often a few steps apart in a 24-bit theme and land together on a 16-color
515 /// terminal, leaving one flat area where there was a frame. Alloy's console
516 /// showed exactly this, and it is not a contrived pairing: a light page and the
517 /// mid-tone border derived from it both land on index 7.
518 ///
519 /// So the background is quantized first, because what the border must be
520 /// distinguished from is the entry the terminal will actually paint, not the
521 /// color the theme asked for. Then the nearest entry to `fg` clearing
522 /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
523 /// furthest does: at that point the palette cannot honor the design, and the
524 /// most legible approximation beats the closest invisible one.
525 ///
526 /// Only for colors whose whole job is to be told apart from their background.
527 /// Applied to every token it would push a deliberately quiet one until it
528 /// shouted.
529 ///
530 /// # Panics
531 ///
532 /// If `palette` is empty.
533 pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
534 assert!(!palette.is_empty(), "a palette needs at least one color");
535 let shown = palette[quantize(bg, palette)];
536
537 let mut order: Vec<usize> = (0..palette.len()).collect();
538 order.sort_by(|a, b| {
539 oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b]))
540 });
541
542 order
543 .iter()
544 .copied()
545 .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
546 .unwrap_or_else(|| {
547 order
548 .iter()
549 .copied()
550 .max_by(|a, b| {
551 wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown))
552 })
553 .expect("the palette is not empty")
554 })
555 }
556
557 // ============================================================================
558 // Intent resolution
559 // ============================================================================
560
561 /// Authored base intents: (TOML dotted source key, canonical token key).
562 /// These are read straight from the theme; the token key is the CSS-var stem
563 /// (`--{token}`) and the `rgb()` lookup key.
564 pub const BASE_INTENTS: &[(&str, &str)] = &[
565 ("surface.page", "surface-page"),
566 ("surface.raised", "surface-raised"),
567 ("surface.sunken", "surface-sunken"),
568 ("surface.overlay", "surface-overlay"),
569 ("content.primary", "content"),
570 ("content.secondary", "content-secondary"),
571 ("content.muted", "content-muted"),
572 ("action.primary", "action"),
573 ("status.danger", "danger"),
574 ("status.success", "success"),
575 ("status.warning", "warning"),
576 ("status.info", "info"),
577 ("line.border", "border"),
578 ("category.one", "category-one"),
579 ("category.two", "category-two"),
580 ("category.three", "category-three"),
581 ("category.four", "category-four"),
582 ("category.five", "category-five"),
583 ("category.six", "category-six"),
584 ];
585
586 /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
587 /// Includes both authored base intents and the computed derived intents.
588 #[derive(Debug, Clone, Serialize)]
589 #[serde(rename_all = "camelCase")]
590 pub struct SemanticTokens {
591 pub meta: ThemeMeta,
592 /// token-key → resolved hex. Stable, deterministic ordering.
593 pub intents: BTreeMap<String, String>,
594 }
595
596 impl SemanticTokens {
597 /// Resolved hex for a token key, if present.
598 pub fn hex(&self, key: &str) -> Option<&str> {
599 self.intents.get(key).map(String::as_str)
600 }
601
602 /// Resolved RGB tuple for a token key (for egui / native consumers).
603 ///
604 /// `None` for a translucent token. Two intents are emitted as `rgba(...)`
605 /// rather than hex, `overlay` and `elevation`, and dropping the alpha would
606 /// hand a native consumer an opaque near-black where it asked for a scrim.
607 /// Those want [`rgba`](Self::rgba).
608 pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
609 self.intents
610 .get(key)
611 .and_then(|h| Rgb::from_hex(h))
612 .map(Rgb::tuple)
613 }
614
615 /// Resolved RGBA tuple for a token key, alpha as 0-255.
616 ///
617 /// Reads both spellings, so a caller that does not care whether an intent
618 /// happens to be translucent can use this for everything: an opaque token
619 /// comes back at 255.
620 ///
621 /// It exists because a CSS consumer can take `rgba(...)` as a string
622 /// straight out of [`hex`](Self::hex) and a native one cannot. Without it
623 /// the two translucent intents are reachable from a stylesheet and from
624 /// nowhere else, which is the coupling deriving in the crate was meant to
625 /// avoid.
626 pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> {
627 let value = self.intents.get(key)?;
628 if let Some(rgb) = Rgb::from_hex(value) {
629 let (r, g, b) = rgb.tuple();
630 return Some((r, g, b, 255));
631 }
632 let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?;
633 let mut parts = inner.split(',').map(str::trim);
634 let r = parts.next()?.parse().ok()?;
635 let g = parts.next()?.parse().ok()?;
636 let b = parts.next()?.parse().ok()?;
637 let alpha: f32 = parts.next()?.parse().ok()?;
638 if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) {
639 return None;
640 }
641 Some((r, g, b, (alpha * 255.0).round() as u8))
642 }
643 }
644
645 /// Resolve an authored theme into the full intent token set.
646 ///
647 /// 1. Copy each present base intent from the authored colors.
648 /// 2. Compute the derived interactive states from the base intents, using the
649 /// same math the apps used to apply individually (so output is identical).
650 ///
651 /// Each derived token is emitted only when its source intents exist, mirroring
652 /// the skip-missing behavior of the rest of the crate.
653 pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
654 let mut intents: BTreeMap<String, String> = BTreeMap::new();
655
656 // 1. Base intents (authored). Copy only values that parse as a hex color and
657 // re-emit them in canonical `#rrggbb` form, so an authored value can never
658 // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
659 // raw into a `<style>` block by the web server). A malformed value is skipped,
660 // mirroring the skip-missing behavior for absent intents.
661 for (src, token) in BASE_INTENTS {
662 if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
663 intents.insert((*token).to_string(), rgb.to_hex());
664 }
665 }
666
667 // Helper: parse an already-resolved token to Rgb.
668 let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
669
670 // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
671 // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
672 let mut derived: Vec<(String, Rgb)> = Vec::new();
673 if let Some(action) = get(&intents, "action") {
674 derived.push(("action-hover".into(), lighten(action, 0.05)));
675 derived.push(("content-on-action".into(), readable_on(action)));
676 // The focus ring is the action colour itself, not a tint of it: a ring
677 // is a statement that the keyboard is here, and a faded one reads as a
678 // disabled control rather than an emphatic one.
679 //
680 // One ring, not one per primitive. Where the ring sits is a depth
681 // question and not a per-component choice: a well takes it inside its
682 // own edge and a raised surface takes it outside. That is one decision
683 // with two renderings rather than one decision per component, which is
684 // how the three apps ended up with three rings. This token is the one
685 // shared artifact; which thing wears it, and how it is drawn, is each
686 // renderer's own (see `makeover_layout`'s crate header, "reach, focus
687 // and the focus ring").
688 derived.push(("focus-ring".into(), action));
689 }
690 if let Some(page) = get(&intents, "surface-page") {
691 // Modal scrim: a near-black tone carrying a faint hint of the theme's
692 // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
693 // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
694 // it is inserted directly rather than through the hex loop below.
695 let mut o = page.to_oklab();
696 o.l = 0.08;
697 let s = Rgb::from_oklab(o);
698 intents.insert(
699 "overlay".into(),
700 format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
701 );
702
703 // What a surface that FLOATS OVER the page is cast onto it with.
704 //
705 // The one intent here about a surface's relationship to the page rather
706 // than about the surface itself, which is why it is derived from `page`
707 // and not from `surface-raised`. A shadow is not the thing, it is the
708 // absence of light on what is behind the thing.
709 //
710 // SCOPE, and it is the whole point of this intent existing rather than
711 // a general "shadow": a surface that overlays the page takes this, a
712 // surface IN the page takes a bevel. Menus, toasts, popovers and
713 // dropdowns overlay. A card, a plate and a framed image do not, and
714 // reaching for this on one of those is how a pre-Platinum look survives
715 // a conversion wearing a token's name. `.raised` is the answer there.
716 //
717 // Same anchor as the scrim above and for the same reason: a tone read
718 // off the theme's hue but pinned very dark, so it reads as absence of
719 // light on a light theme and on a dark one alike. A shadow tinted to a
720 // dark theme's own lightness would not be a shadow.
721 //
722 // The alpha is the only number here that is a look decision rather than
723 // a derivation. 0.18 sits between the two literal scales it replaces:
724 // the MNW server's --shadow-2 (0.10) reads as nothing under a menu, and
725 // its --shadow-3 (0.15) was measured invisible at plate size. Geometry
726 // stays with the consumer, the way bevel thickness does.
727 intents.insert(
728 "elevation".into(),
729 format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b),
730 );
731 }
732 if let Some(raised) = get(&intents, "surface-raised") {
733 // The two edges of a bevel: a raised control is lit from the top left,
734 // so its top and left edges take `bevel-light` and its bottom and right
735 // edges `bevel-dark`. Inverting the pair gives a pressed state and an
736 // inset well, which is what makes the idiom cheap for a consumer.
737 //
738 // Derived here rather than composed per-app because the two webviews
739 // could do it in `color-mix()` and audiofiles, which is egui, could not.
740 // Geometry (thickness, radius, which side gets which) stays app-side.
741 //
742 // The deltas are asymmetric because the eye is: an equal step down reads
743 // as a smaller change than the same step up, so the shadow is cut deeper
744 // than the highlight is raised.
745 //
746 // A face already at the top of the ramp cannot hold a highlight — the
747 // lightening clamps and the control bevels on two sides without ever
748 // resolving as lit. That is a property of the theme, not of this
749 // derivation; `bevel_edges_are_distinct_from_their_face` names the
750 // shipped themes it currently bites.
751 derived.push(("bevel-light".into(), lighten(raised, 0.14)));
752 derived.push(("bevel-dark".into(), darken(raised, 0.18)));
753
754 // An inset well: the content surface inside a raised container, so a
755 // list reads as content in a container rather than as bands on a panel.
756 // `surface-sunken` cannot serve, because a theme is free to author it
757 // darker than raised (goingson does) and a well has to go the other way.
758 //
759 // Which way is "the other way" depends on the theme, and this is the one
760 // derivation here that inverts. A well is lighter than its face on a
761 // light theme and darker on a dark one, where the bevel pair sidesteps
762 // the question by emitting both directions at once.
763 //
764 // Read the direction off `content` rather than off `Variant`. A theme
765 // whose text is dark is a theme whose surfaces are light, whatever its
766 // `variant` field claims, so this resolves correctly even when that
767 // field is wrong and it keeps the branch on measured color rather than
768 // on metadata.
769 //
770 // Deltas are asymmetric for the same reason the bevel's are, and smaller
771 // than the bevel's because a well is an area rather than an edge. The
772 // step up is the specimen's, measured: #D9DDF4 to #F3F5FD is 0.069.
773 //
774 // A face at the top of its ramp cannot hold a lighter well, the same
775 // clamp `bevel-light` hits; `well_is_visible_against_its_face` names the
776 // shipped themes where it bites.
777 if let Some(content) = get(&intents, "content") {
778 let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
779 let well = if content_is_darker {
780 lighten(raised, 0.07)
781 } else {
782 darken(raised, 0.09)
783 };
784 derived.push(("surface-well".into(), well));
785 }
786 }
787 if let Some(sunken) = get(&intents, "surface-sunken") {
788 derived.push(("hover-surface".into(), sunken));
789 }
790 if let Some(border) = get(&intents, "border") {
791 derived.push(("border-strong".into(), darken(border, 0.05)));
792 }
793
794 for (token, rgb) in derived {
795 intents.insert(token, rgb.to_hex());
796 }
797
798 SemanticTokens {
799 meta: theme.meta.clone(),
800 intents,
801 }
802 }
803
804 /// Emit the resolved intent layer as CSS declarations (no selector), one
805 /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
806 pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
807 let mut out = String::new();
808 for (token, hex) in &tokens.intents {
809 out.push_str(" --");
810 out.push_str(token);
811 out.push_str(": ");
812 out.push_str(hex);
813 out.push_str(";\n");
814 }
815 out
816 }
817
818 /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
819 /// CSS mapping every web surface injects.
820 pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
821 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
822 }
823
824 // ============================================================================
825 // Loading / parsing
826 // ============================================================================
827
828 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
829 pub fn validate_theme_id(id: &str) -> Result<(), String> {
830 if !id
831 .chars()
832 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
833 {
834 return Err(format!("Invalid theme ID: {id}"));
835 }
836 Ok(())
837 }
838
839 /// Parse the `[meta]` section into `ThemeMeta`.
840 ///
841 /// Falls back to the file ID as the name and `"dark"` as the variant.
842 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
843 let meta = table.get("meta").and_then(|m| m.as_table());
844 let name = meta
845 .and_then(|m| m.get("name"))
846 .and_then(|v| v.as_str())
847 .unwrap_or(id)
848 .to_string();
849 let variant = meta
850 .and_then(|m| m.get("variant"))
851 .and_then(|v| v.as_str())
852 .unwrap_or("dark")
853 .to_string();
854
855 ThemeMeta {
856 id: id.to_string(),
857 name,
858 variant,
859 is_custom,
860 }
861 }
862
863 // ============================================================================
864 // Choosing a theme.
865 //
866 // The file half of this crate was always shared; the *selection* half was not,
867 // and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
868 // localStorage, Balanced Breakfast treats an absent value as follow-the-system
869 // and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
870 // in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
871 // disagreed about what a variant string means: this crate defaults a missing
872 // one to "dark" while alloy_tui parsed an unrecognized one as light.
873 //
874 // What cannot be shared is the store — localStorage, a synced config table and
875 // a TOML file are genuinely different places. What can be shared, and is here,
876 // is the *meaning*: one vocabulary for variants, one encoding for "what did the
877 // user choose", and one rule for turning that into an id that exists.
878 // ============================================================================
879
880 /// A theme's kind, as declared by `meta.variant`.
881 ///
882 /// Three, not two: one shipped theme is `high-contrast`, and an app that
883 /// matched on light-or-dark alone would quietly file it under the wrong one.
884 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
885 #[serde(rename_all = "kebab-case")]
886 pub enum Variant {
887 Light,
888 Dark,
889 HighContrast,
890 }
891
892 impl Variant {
893 /// The spelling used in a theme file and in [`ThemeMeta::variant`].
894 #[must_use]
895 pub const fn as_str(self) -> &'static str {
896 match self {
897 Variant::Light => "light",
898 Variant::Dark => "dark",
899 Variant::HighContrast => "high-contrast",
900 }
901 }
902
903 /// Read a variant string, or `None` if it names none of them.
904 #[must_use]
905 pub fn parse(raw: &str) -> Option<Self> {
906 match raw {
907 "light" => Some(Variant::Light),
908 "dark" => Some(Variant::Dark),
909 "high-contrast" => Some(Variant::HighContrast),
910 _ => None,
911 }
912 }
913 }
914
915 impl std::fmt::Display for Variant {
916 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
917 f.write_str(self.as_str())
918 }
919 }
920
921 /// Anything unrecognized reads as dark, which is what [`parse_meta`] already
922 /// does with a missing one. Consumers that guessed light for an unknown string
923 /// were disagreeing with the crate that produced it.
924 impl From<&str> for Variant {
925 fn from(raw: &str) -> Self {
926 Variant::parse(raw).unwrap_or(Variant::Dark)
927 }
928 }
929
930 impl ThemeMeta {
931 /// This theme's variant as a value rather than a string.
932 #[must_use]
933 pub fn kind(&self) -> Variant {
934 Variant::from(self.variant.as_str())
935 }
936 }
937
938 /// The spelling of "follow whatever the system is doing", in every store.
939 pub const FOLLOW: &str = "system";
940
941 /// What the user chose, as opposed to what is being rendered.
942 ///
943 /// The distinction is the whole point: `Follow` is a standing instruction that
944 /// resolves differently as the ambient mode changes, and a `Fixed` id is an
945 /// answer that does not. An app that stored only the rendered id could not tell
946 /// the two apart the next time the system flipped to dark.
947 #[derive(Debug, Clone, PartialEq, Eq, Default)]
948 pub enum ThemeSelection {
949 /// Track the ambient light/dark mode.
950 #[default]
951 Follow,
952 /// Always this theme.
953 Fixed(String),
954 }
955
956 impl ThemeSelection {
957 /// Read a stored selection. An empty or absent value is [`Follow`], which
958 /// is what an app with nothing saved yet should do.
959 ///
960 /// [`Follow`]: ThemeSelection::Follow
961 #[must_use]
962 pub fn parse(raw: Option<&str>) -> Self {
963 match raw.map(str::trim) {
964 None | Some("" | FOLLOW) => ThemeSelection::Follow,
965 Some(id) => ThemeSelection::Fixed(id.to_string()),
966 }
967 }
968
969 /// The string to persist, whatever the store is.
970 #[must_use]
971 pub fn as_str(&self) -> &str {
972 match self {
973 ThemeSelection::Follow => FOLLOW,
974 ThemeSelection::Fixed(id) => id,
975 }
976 }
977
978 /// Turn a selection into a theme id that exists.
979 ///
980 /// `ambient` is the light/dark mode the app learned however it can: a
981 /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
982 /// from a terminal. `available` is what [`list_themes_from_dirs`] found.
983 ///
984 /// A `Fixed` id that is no longer on disk falls through to the same path as
985 /// `Follow` rather than being returned anyway. Themes are deletable in
986 /// three of the four apps, and handing back an id that will fail to load
987 /// only moves the error somewhere less helpful.
988 ///
989 /// The fallback chain is: the app's own default for the ambient mode if it
990 /// is installed, then any installed theme of that variant, then the app's
991 /// default regardless. The last step means this always returns something,
992 /// and an app with no theme directory at all gets the id it ships with and
993 /// the load error it would have had anyway.
994 #[must_use]
995 pub fn resolve(
996 &self,
997 ambient: Variant,
998 defaults: &ThemeDefaults,
999 available: &[ThemeMeta],
1000 ) -> String {
1001 let installed = |id: &str| available.iter().any(|meta| meta.id == id);
1002
1003 if let ThemeSelection::Fixed(id) = self
1004 && installed(id)
1005 {
1006 return id.clone();
1007 }
1008
1009 let preferred = defaults.for_variant(ambient);
1010 if installed(preferred) {
1011 return preferred.to_string();
1012 }
1013 available
1014 .iter()
1015 .find(|meta| meta.kind() == ambient)
1016 .map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
1017 }
1018 }
1019
1020 impl std::fmt::Display for ThemeSelection {
1021 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1022 f.write_str(self.as_str())
1023 }
1024 }
1025
1026 /// The themes an app falls back to, one per ambient mode.
1027 ///
1028 /// App-specific on purpose: which theme is "the app's own" is the app's
1029 /// identity, not this crate's business. What is shared is everything around it.
1030 #[derive(Debug, Clone)]
1031 pub struct ThemeDefaults {
1032 light: String,
1033 dark: String,
1034 high_contrast: Option<String>,
1035 }
1036
1037 impl ThemeDefaults {
1038 pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
1039 Self {
1040 light: light.into(),
1041 dark: dark.into(),
1042 high_contrast: None,
1043 }
1044 }
1045
1046 /// Name a theme for a high-contrast ambient mode. Without one, that mode
1047 /// falls back to the dark default, which is the safer of the two to read.
1048 #[must_use]
1049 pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
1050 self.high_contrast = Some(id.into());
1051 self
1052 }
1053
1054 #[must_use]
1055 pub fn for_variant(&self, variant: Variant) -> &str {
1056 match variant {
1057 Variant::Light => &self.light,
1058 Variant::Dark => &self.dark,
1059 Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
1060 }
1061 }
1062 }
1063
1064 // ============================================================================
1065 // Where themes are looked for.
1066 //
1067 // Four apps built this vector by hand, two of them byte-for-byte identically,
1068 // and one of them built it backwards: the Alloy console pushed the user's own
1069 // directory first, under a comment saying "highest precedence first", when both
1070 // consumers of the vector resolve *last* wins. A user's custom theme lost to
1071 // the packaged one of the same id.
1072 //
1073 // Hence a builder that names the tiers rather than a function taking a vector.
1074 // The precedence is stated once, here, and a caller cannot express it backwards
1075 // because the order is not theirs to choose.
1076 // ============================================================================
1077
1078 /// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take.
1079 ///
1080 /// Tiers are added in whatever order is convenient and always end up in
1081 /// precedence order: the user's own themes win, then whatever the system
1082 /// ships, then whatever the app bundles.
1083 ///
1084 /// A directory that does not exist is dropped rather than carried, so callers
1085 /// can offer every tier they might have without checking each one.
1086 #[derive(Debug, Default, Clone)]
1087 pub struct ThemeDirs {
1088 bundled: Vec<PathBuf>,
1089 system: Vec<PathBuf>,
1090 custom: Option<PathBuf>,
1091 }
1092
1093 impl ThemeDirs {
1094 #[must_use]
1095 pub fn new() -> Self {
1096 Self::default()
1097 }
1098
1099 /// Themes the app ships with. Lowest precedence.
1100 ///
1101 /// Takes more than one because a Tauri app has two: the bundled resource
1102 /// directory in production, and the tree `build.rs` materialized for a
1103 /// `cargo run` that has no resource directory at all.
1104 #[must_use]
1105 pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
1106 self.bundled.extend(dir);
1107 self
1108 }
1109
1110 /// Themes the machine ships, from an image or a package. Overrides bundled.
1111 #[must_use]
1112 pub fn system(mut self, dir: Option<PathBuf>) -> Self {
1113 self.system.extend(dir);
1114 self
1115 }
1116
1117 /// The user's own themes. Highest precedence, and the only tier flagged
1118 /// custom, which is what makes them exportable and deletable.
1119 #[must_use]
1120 pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
1121 self.custom = dir;
1122 self
1123 }
1124
1125 /// The search path, lowest precedence first.
1126 #[must_use]
1127 pub fn build(self) -> Vec<(PathBuf, bool)> {
1128 let mut dirs = Vec::new();
1129 for dir in self.bundled.into_iter().chain(self.system) {
1130 if dir.is_dir() {
1131 dirs.push((dir, false));
1132 }
1133 }
1134 if let Some(dir) = self.custom
1135 && dir.is_dir()
1136 {
1137 dirs.push((dir, true));
1138 }
1139 dirs
1140 }
1141 }
1142
1143 /// Extract the intent color sections into a flat `HashMap` with dotted keys
1144 /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
1145 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
1146 let mut colors = HashMap::new();
1147 for section in COLOR_SECTIONS {
1148 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
1149 for (key, val) in sect {
1150 if let Some(color) = val.as_str() {
1151 colors.insert(format!("{section}.{key}"), color.to_string());
1152 }
1153 }
1154 }
1155 }
1156 colors
1157 }
1158
1159 /// Scan directories for `.toml` theme files and return metadata for each.
1160 ///
1161 /// Directories are checked in order; later entries override earlier ones by ID.
1162 /// Each entry in `dirs` is `(path, is_custom)`.
1163 pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
1164 let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
1165
1166 for (dir, is_custom) in dirs {
1167 let Ok(entries) = std::fs::read_dir(dir) else {
1168 continue;
1169 };
1170
1171 for entry in entries {
1172 let Ok(entry) = entry else {
1173 continue;
1174 };
1175 let path = entry.path();
1176 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1177 continue;
1178 }
1179
1180 let id = path
1181 .file_stem()
1182 .and_then(|s| s.to_str())
1183 .unwrap_or_default()
1184 .to_string();
1185
1186 let Ok(content) = std::fs::read_to_string(&path) else {
1187 continue;
1188 };
1189 let table: toml::Table = match content.parse() {
1190 Ok(t) => t,
1191 Err(_) => continue,
1192 };
1193
1194 seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
1195 }
1196 }
1197
1198 let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
1199 themes.sort_by(|a, b| a.name.cmp(&b.name));
1200 themes
1201 }
1202
1203 /// Find a theme file by ID in the given directories.
1204 ///
1205 /// Checks directories in reverse order so the highest-priority directory wins.
1206 /// Returns `(path, is_custom)` or `None` if not found.
1207 pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
1208 let filename = format!("{id}.toml");
1209
1210 for (dir, is_custom) in dirs.iter().rev() {
1211 let path = dir.join(&filename);
1212 if path.is_file() {
1213 return Some((path, *is_custom));
1214 }
1215 }
1216
1217 None
1218 }
1219
1220 /// Parse a complete theme (metadata + colors) from raw TOML content, with no
1221 /// filesystem access. For callers that embed themes at compile time.
1222 pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
1223 validate_theme_id(id)?;
1224 let table: toml::Table = content
1225 .parse()
1226 .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
1227 let meta = parse_meta(id, &table, is_custom);
1228 let colors = extract_colors(&table);
1229 Ok(ThemeColors { meta, colors })
1230 }
1231
1232 /// Load a complete theme (metadata + colors) by ID from the given directories.
1233 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
1234 validate_theme_id(id)?;
1235
1236 let (path, is_custom) =
1237 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
1238
1239 let content = std::fs::read_to_string(&path)
1240 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1241
1242 let table: toml::Table = content
1243 .parse()
1244 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
1245
1246 let meta = parse_meta(id, &table, is_custom);
1247 let colors = extract_colors(&table);
1248
1249 Ok(ThemeColors { meta, colors })
1250 }
1251
1252 /// Load a theme and resolve it to the full intent token set in one step.
1253 pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
1254 Ok(resolve(&load_theme(dirs, id)?))
1255 }
1256
1257 /// Import a theme TOML file into the custom themes directory.
1258 ///
1259 /// Validates that the file is parseable TOML with at least one intent color
1260 /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
1261 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
1262 let content = std::fs::read_to_string(source_path)
1263 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
1264
1265 let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
1266
1267 let has_colors = COLOR_SECTIONS
1268 .iter()
1269 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
1270 if !has_colors {
1271 return Err(format!(
1272 "Theme file must have at least one color section ({})",
1273 COLOR_SECTIONS.join(", ")
1274 ));
1275 }
1276
1277 let id = source_path
1278 .file_stem()
1279 .and_then(|s| s.to_str())
1280 .ok_or("Invalid file name")?
1281 .to_string();
1282 validate_theme_id(&id)?;
1283
1284 std::fs::create_dir_all(custom_dir)
1285 .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
1286
1287 let dest = custom_dir.join(format!("{id}.toml"));
1288 std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
1289
1290 Ok(parse_meta(&id, &table, true))
1291 }
1292
1293 /// Delete a custom theme by ID.
1294 ///
1295 /// Only operates on `custom_dir` — bundled themes are not deletable through
1296 /// this entry point.
1297 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
1298 validate_theme_id(id)?;
1299
1300 let path = custom_dir.join(format!("{id}.toml"));
1301 if !path.is_file() {
1302 return Err(format!("Custom theme '{id}' not found"));
1303 }
1304
1305 std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
1306 }
1307
1308 /// A four-color preview for theme thumbnails: the representative swatch from
1309 /// each of the principal roles.
1310 #[derive(Debug, Clone, Serialize)]
1311 #[serde(rename_all = "camelCase")]
1312 pub struct ThemePreview {
1313 pub meta: ThemeMeta,
1314 /// Page background (`surface.page`).
1315 pub background: Option<String>,
1316 /// Body text (`content.primary`).
1317 pub foreground: Option<String>,
1318 /// Brand/interactive color (`action.primary`).
1319 pub accent: Option<String>,
1320 /// Divider/outline color (`line.border`).
1321 pub border: Option<String>,
1322 }
1323
1324 fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
1325 table
1326 .get(section)
1327 .and_then(|s| s.as_table())
1328 .and_then(|s| s.get(key))
1329 .and_then(|v| v.as_str())
1330 .map(std::string::ToString::to_string)
1331 }
1332
1333 /// Load just the preview swatches for a theme — for UI thumbnails.
1334 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
1335 validate_theme_id(id)?;
1336
1337 let (path, is_custom) =
1338 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
1339
1340 let content = std::fs::read_to_string(&path)
1341 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1342
1343 let table: toml::Table = content
1344 .parse()
1345 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
1346
1347 Ok(ThemePreview {
1348 meta: parse_meta(id, &table, is_custom),
1349 background: color_at(&table, "surface", "page"),
1350 foreground: color_at(&table, "content", "primary"),
1351 accent: color_at(&table, "action", "primary"),
1352 border: color_at(&table, "line", "border"),
1353 })
1354 }
1355
1356 /// Export a theme to a user-chosen path.
1357 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
1358 validate_theme_id(id)?;
1359
1360 let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
1361
1362 std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
1363
1364 Ok(())
1365 }
1366
1367 /// The themes this crate ships, embedded at compile time.
1368 ///
1369 /// `include_dir` is an implementation detail: the public API hands back plain
1370 /// `(id, toml_source)` pairs, so how the data is embedded can change without
1371 /// a breaking release.
1372 static EMBEDDED: include_dir::Dir<'static> =
1373 include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
1374
1375 /// The themes this crate ships, as `(id, toml_source)` pairs.
1376 ///
1377 /// This is the path-free way to reach the bundled set, for consumers that
1378 /// cannot rely on a directory existing at runtime: a crate pulled from
1379 /// crates.io lives in a registry checkout whose location is not knowable at
1380 /// compile time, so `include_dir!` and asset-bundling globs in the depending
1381 /// crate have nothing stable to point at. Embedding here and re-exporting the
1382 /// contents gives them one source of truth without a path.
1383 ///
1384 /// Ordering follows the embedded directory and is not guaranteed; collect and
1385 /// sort by id where a stable order matters (a theme picker, say).
1386 pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
1387 EMBEDDED.files().filter_map(|file| {
1388 let path = file.path();
1389 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1390 return None;
1391 }
1392 let id = path.file_stem()?.to_str()?;
1393 Some((id, file.contents_utf8()?))
1394 })
1395 }
1396
1397 /// The theme directory this crate ships, for use as a build-from-source
1398 /// fallback.
1399 ///
1400 /// Resolves against `makeover`'s own manifest directory, fixed at compile
1401 /// time, so it works from a path dependency and from a cargo git checkout
1402 /// alike. Installed systems should put their packaged theme directory ahead
1403 /// of this in the search path; this is the entry that keeps `cargo run` in a
1404 /// fresh clone from coming up with no themes at all.
1405 ///
1406 /// Returns `None` when the directory is absent — a cargo cache that has been
1407 /// cleaned, or a vendored copy that dropped the data — so callers degrade to
1408 /// their remaining search path rather than failing.
1409 pub fn bundled_themes_dir() -> Option<PathBuf> {
1410 let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
1411 if themes.is_dir() { Some(themes) } else { None }
1412 }
1413
1414 #[cfg(test)]
1415 mod tests {
1416 use super::*;
1417 use std::fs;
1418
1419 // ---- id validation ----
1420
1421 #[test]
1422 fn validate_theme_id_alphanumeric() {
1423 assert!(validate_theme_id("darkmode").is_ok());
1424 assert!(validate_theme_id("Theme123").is_ok());
1425 }
1426
1427 #[test]
1428 fn validate_theme_id_hyphens_underscores() {
1429 assert!(validate_theme_id("dark-mode").is_ok());
1430 assert!(validate_theme_id("my_theme_v2").is_ok());
1431 }
1432
1433 #[test]
1434 fn validate_theme_id_rejects_path_traversal() {
1435 assert!(validate_theme_id("../etc/passwd").is_err());
1436 assert!(validate_theme_id("foo/bar").is_err());
1437 assert!(validate_theme_id("theme.toml").is_err());
1438 }
1439
1440 // ---- low-color terminals ----
1441
1442 #[test]
1443 fn the_ansi_palette_is_sixteen_distinct_colors() {
1444 let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
1445 seen.sort_unstable();
1446 seen.dedup();
1447 assert_eq!(seen.len(), 16);
1448 }
1449
1450 // ---- the intent-to-slot table ----
1451
1452 // Sixteen slots, every one of them answered. A caller filling a terminal
1453 // palette has no fallback for a hole: the slot would keep whatever the
1454 // emulator started with, and one raw ANSI colour in a themed table is more
1455 // obviously wrong than all sixteen would be.
1456 #[test]
1457 fn every_ansi_slot_names_an_intent_on_either_polarity() {
1458 for variant in ["light", "dark", "high-contrast"] {
1459 for index in 0..16 {
1460 assert!(
1461 ansi_intent(index, variant).is_some(),
1462 "slot {index} unanswered on {variant}"
1463 );
1464 }
1465 assert_eq!(ansi_intent(16, variant), None);
1466 }
1467 }
1468
1469 // The property the four achromatic slots exist to hold: 0 is the darkest
1470 // tone the theme offers and 15 the lightest, in either polarity. A table
1471 // that pins slot 0 to `content.primary` passes this on a light theme and
1472 // inverts on a dark one, which is the bug the polarity split fixes.
1473 #[test]
1474 fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
1475 for id in ["akari-dawn", "akari-night"] {
1476 let theme = bundled(id);
1477 let slot = |i: usize| -> Rgb {
1478 let key = ansi_intent(i, &theme.meta.variant).expect("in range");
1479 Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
1480 };
1481 assert!(
1482 rel_luminance(slot(0)) < rel_luminance(slot(15)),
1483 "{id}: ANSI 0 {} should be darker than ANSI 15 {}",
1484 slot(0).to_hex(),
1485 slot(15).to_hex(),
1486 );
1487 }
1488 }
1489
1490 // The pair a greeter draws with: its container on 7, its text on 0. If
1491 // those collapse the login screen is one flat block, and slot 7 being a
1492 // surface rather than a text tone is what keeps them apart.
1493 #[test]
1494 fn the_container_slot_and_the_text_slot_stay_legible() {
1495 for id in ["akari-dawn", "akari-night"] {
1496 let theme = bundled(id);
1497 let slot = |i: usize| -> Rgb {
1498 let key = ansi_intent(i, &theme.meta.variant).expect("in range");
1499 Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
1500 };
1501 let contrast = wcag_contrast(slot(0), slot(7));
1502 assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
1503 }
1504 }
1505
1506 // The hues do not move with polarity. Red is the theme's danger tone on a
1507 // light theme and on a dark one, which is why only four slots are in the
1508 // polarity table at all.
1509 #[test]
1510 fn the_chromatic_slots_do_not_vary_with_polarity() {
1511 for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
1512 assert_eq!(
1513 ansi_intent(index, "light"),
1514 ansi_intent(index, "dark"),
1515 "slot {index} moved with polarity"
1516 );
1517 }
1518 }
1519
1520 fn bundled(id: &str) -> ThemeColors {
1521 let dir = bundled_themes_dir().expect("makeover ships its themes");
1522 load_theme(&[(dir, false)], id).expect("the akari pair ships")
1523 }
1524
1525 #[test]
1526 fn quantize_picks_the_obvious_entry() {
1527 let black = Rgb { r: 0, g: 0, b: 0 };
1528 let white = Rgb {
1529 r: 255,
1530 g: 255,
1531 b: 255,
1532 };
1533 assert_eq!(quantize(black, &ANSI_16), 0);
1534 assert_eq!(quantize(white, &ANSI_16), 15);
1535 }
1536
1537 // Nearest-entry quantization is per-color, so two colors a theme keeps
1538 // apart can arrive as one. These two are both closest to the palette's
1539 // light gray, and a border drawn in one on a page painted the other is not
1540 // drawn at all.
1541 #[test]
1542 fn two_colors_can_quantize_to_one_entry() {
1543 let page = Rgb::from_hex("#a8a8a8").unwrap();
1544 let border = Rgb::from_hex("#b4b4b4").unwrap();
1545
1546 assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
1547 assert_ne!(
1548 quantize_against(border, page, &ANSI_16),
1549 quantize(page, &ANSI_16)
1550 );
1551 }
1552
1553 #[test]
1554 fn quantize_against_keeps_the_border_off_the_page() {
1555 let page = Rgb::from_hex("#e4ded6").unwrap();
1556 let border = Rgb::from_hex("#7f786d").unwrap();
1557
1558 let shown_page = ANSI_16[quantize(page, &ANSI_16)];
1559 let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
1560
1561 assert!(
1562 wcag_contrast(shown_border, shown_page) >= DISTINCT,
1563 "border {} on page {} is {:.2}:1",
1564 shown_border.to_hex(),
1565 shown_page.to_hex(),
1566 wcag_contrast(shown_border, shown_page)
1567 );
1568 }
1569
1570 // A color that already reads against its background is left where it is,
1571 // so this can be applied without redesigning what already worked.
1572 #[test]
1573 fn quantize_against_leaves_a_readable_color_alone() {
1574 let page = Rgb::from_hex("#e4ded6").unwrap();
1575 let text = Rgb::from_hex("#1a1816").unwrap();
1576
1577 assert_eq!(
1578 quantize_against(text, page, &ANSI_16),
1579 quantize(text, &ANSI_16)
1580 );
1581 }
1582
1583 // With nothing in the palette to satisfy the request, the most legible
1584 // entry is the answer. Returning the nearest one would return the
1585 // background itself, which is the failure this function exists to avoid.
1586 #[test]
1587 fn an_impossible_palette_gets_the_most_legible_entry() {
1588 let page = Rgb::from_hex("#ffffff").unwrap();
1589 let border = Rgb::from_hex("#fefefe").unwrap();
1590 let palette = [
1591 Rgb::from_hex("#ffffff").unwrap(),
1592 Rgb::from_hex("#fdfdfd").unwrap(),
1593 ];
1594
1595 let chosen = palette[quantize_against(border, page, &palette)];
1596 assert_eq!(chosen.to_hex(), "#fdfdfd");
1597 }
1598
1599 // ---- meta ----
1600
1601 #[test]
1602 fn parse_meta_with_name_and_variant() {
1603 let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
1604 .parse()
1605 .unwrap();
1606 let meta = parse_meta("nord", &table, false);
1607 assert_eq!(meta.id, "nord");
1608 assert_eq!(meta.name, "Nord");
1609 assert_eq!(meta.variant, "light");
1610 assert!(!meta.is_custom);
1611 }
1612
1613 #[test]
1614 fn parse_meta_defaults_to_id_and_dark() {
1615 let table: toml::Table = "".parse().unwrap();
1616 let meta = parse_meta("fallback", &table, true);
1617 assert_eq!(meta.name, "fallback");
1618 assert_eq!(meta.variant, "dark");
1619 assert!(meta.is_custom);
1620 }
1621
1622 // ---- color math (formulas must match the apps they came from) ----
1623
1624 #[test]
1625 fn rgb_hex_roundtrip() {
1626 assert_eq!(
1627 Rgb::from_hex("#6196FF").unwrap(),
1628 Rgb {
1629 r: 0x61,
1630 g: 0x96,
1631 b: 0xff
1632 }
1633 );
1634 assert_eq!(
1635 Rgb::from_hex("#abc").unwrap(),
1636 Rgb {
1637 r: 0xaa,
1638 g: 0xbb,
1639 b: 0xcc
1640 }
1641 );
1642 assert_eq!(
1643 Rgb {
1644 r: 0x61,
1645 g: 0x96,
1646 b: 0xff
1647 }
1648 .to_hex(),
1649 "#6196ff"
1650 );
1651 assert!(Rgb::from_hex("not-a-color").is_none());
1652 }
1653
1654 #[test]
1655 fn oklab_roundtrips_within_tolerance() {
1656 for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
1657 let c = Rgb::from_hex(hex).unwrap();
1658 let back = Rgb::from_oklab(c.to_oklab());
1659 // Gamut round-trip is near-exact (±1 per channel from rounding).
1660 assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
1661 assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
1662 assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
1663 }
1664 }
1665
1666 #[test]
1667 fn wcag_contrast_known_pairs() {
1668 let white = Rgb {
1669 r: 255,
1670 g: 255,
1671 b: 255,
1672 };
1673 let black = Rgb { r: 0, g: 0, b: 0 };
1674 assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
1675 assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
1676 }
1677
1678 #[test]
1679 fn readable_on_picks_by_wcag() {
1680 assert_eq!(
1681 readable_on(Rgb {
1682 r: 255,
1683 g: 255,
1684 b: 255
1685 }),
1686 Rgb { r: 0, g: 0, b: 0 }
1687 );
1688 assert_eq!(
1689 readable_on(Rgb { r: 0, g: 0, b: 0 }),
1690 Rgb {
1691 r: 255,
1692 g: 255,
1693 b: 255
1694 }
1695 );
1696 // A light blue action -> black text reads better.
1697 let action = Rgb::from_hex("#6196ff").unwrap();
1698 assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
1699 }
1700
1701 #[test]
1702 fn lighten_darken_move_oklab_lightness() {
1703 let c = Rgb::from_hex("#6196ff").unwrap();
1704 let l0 = c.to_oklab().l;
1705 assert!(lighten(c, 0.05).to_oklab().l > l0);
1706 assert!(darken(c, 0.05).to_oklab().l < l0);
1707 }
1708
1709 #[test]
1710 fn mix_endpoints_and_midpoint() {
1711 let a = Rgb::from_hex("#000000").unwrap();
1712 let b = Rgb::from_hex("#6196ff").unwrap();
1713 assert_eq!(mix(a, b, 0.0), a);
1714 assert_eq!(mix(a, b, 1.0), b);
1715 // Midpoint sits between the endpoints in OKLab lightness.
1716 let mid = mix(a, b, 0.5).to_oklab().l;
1717 assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
1718 }
1719
1720 // ---- extract + resolve ----
1721
1722 fn nord_toml() -> &'static str {
1723 r##"
1724 [meta]
1725 name = "Nord"
1726 variant = "dark"
1727
1728 [surface]
1729 page = "#2e3440"
1730 raised = "#3b4252"
1731 sunken = "#434c5e"
1732 overlay = "#3b4252"
1733
1734 [content]
1735 primary = "#d8dee9"
1736 secondary = "#e5e9f0"
1737 muted = "#616e88"
1738
1739 [action]
1740 primary = "#81a1c1"
1741
1742 [status]
1743 danger = "#bf616a"
1744 success = "#a3be8c"
1745 warning = "#ebcb8b"
1746 info = "#88c0d0"
1747
1748 [line]
1749 border = "#4c566a"
1750
1751 [category]
1752 one = "#bf616a"
1753 two = "#a3be8c"
1754 three = "#81a1c1"
1755 four = "#ebcb8b"
1756 five = "#b48ead"
1757 six = "#88c0d0"
1758 "##
1759 }
1760
1761 #[test]
1762 fn extract_colors_reads_intent_sections() {
1763 let table: toml::Table = nord_toml().parse().unwrap();
1764 let colors = extract_colors(&table);
1765 assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
1766 assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
1767 assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
1768 assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
1769 assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
1770 assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
1771 assert_eq!(colors.len(), 19);
1772 }
1773
1774 #[test]
1775 fn resolve_base_intents_passthrough() {
1776 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1777 let t = resolve(&theme);
1778 assert_eq!(t.hex("surface-page"), Some("#2e3440"));
1779 assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
1780 assert_eq!(t.hex("content-muted"), Some("#616e88"));
1781 assert_eq!(t.hex("action"), Some("#81a1c1"));
1782 assert_eq!(t.hex("danger"), Some("#bf616a"));
1783 assert_eq!(t.hex("border"), Some("#4c566a"));
1784 assert_eq!(t.hex("category-five"), Some("#b48ead"));
1785 }
1786
1787 #[test]
1788 fn resolve_derived_intents() {
1789 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1790 let t = resolve(&theme);
1791 let action = Rgb::from_hex("#81a1c1").unwrap();
1792 let page = Rgb::from_hex("#2e3440").unwrap();
1793 let _ = page;
1794 assert_eq!(
1795 t.hex("action-hover").unwrap(),
1796 lighten(action, 0.05).to_hex()
1797 );
1798 assert_eq!(
1799 t.hex("content-on-action").unwrap(),
1800 readable_on(action).to_hex()
1801 );
1802 assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
1803 assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
1804 // Pruned by the usage audit (0 consumers): action-active, the *-surface
1805 // tints, selection, row-stripe. Apps that need them derive inline via
1806 // the shared mix().
1807 assert!(t.hex("action-active").is_none());
1808 assert!(t.hex("danger-surface").is_none());
1809 assert!(t.hex("selection").is_none());
1810 assert!(t.hex("row-stripe").is_none());
1811 }
1812
1813 #[test]
1814 fn resolve_bevel_intents() {
1815 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1816 let t = resolve(&theme);
1817 let raised = Rgb::from_hex("#3b4252").unwrap();
1818 assert_eq!(
1819 t.hex("bevel-light").unwrap(),
1820 lighten(raised, 0.14).to_hex()
1821 );
1822 assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
1823 }
1824
1825 // A bevel is two edges around one face, so both edges have to be visibly off
1826 // that face or the control never resolves as lit. The lightening clamps at
1827 // the top of the ramp, which means a theme authoring a white raised surface
1828 // gets a highlight identical to the surface it is meant to sit on.
1829 //
1830 // The list is asserted rather than merely reported so that changing a theme
1831 // has to come here and say so. Shrinking it is the fix; growing it is a
1832 // regression in the theme, not in this derivation.
1833 #[test]
1834 fn bevel_edges_are_distinct_from_their_face() {
1835 const CANNOT_BEVEL: &[&str] = &["neobrute", "oxocarbon-light"];
1836
1837 let mut degenerate: Vec<String> = Vec::new();
1838 for (id, source) in embedded_themes() {
1839 let theme = parse_theme_str(id, source, false).unwrap();
1840 let t = resolve(&theme);
1841 let Some(raised) = t.hex("surface-raised") else {
1842 continue;
1843 };
1844 let light = t.hex("bevel-light").expect("raised implies bevel-light");
1845 let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
1846 if light == raised || dark == raised {
1847 degenerate.push(id.to_string());
1848 }
1849 }
1850 degenerate.sort();
1851
1852 assert_eq!(
1853 degenerate, CANNOT_BEVEL,
1854 "themes whose raised surface cannot hold both bevel edges"
1855 );
1856 }
1857
1858 // The well inverts by theme, so assert both directions explicitly rather
1859 // than only the one the light themes happen to take.
1860 #[test]
1861 fn resolve_well_intent_follows_the_content_direction() {
1862 // nord is dark: light text on a dark raised surface, so the well goes
1863 // down and away from the text.
1864 let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
1865 let dark_raised = Rgb::from_hex("#3b4252").unwrap();
1866 assert_eq!(
1867 dark.hex("surface-well").unwrap(),
1868 darken(dark_raised, 0.09).to_hex()
1869 );
1870
1871 // The shipped light themes take the other branch.
1872 let goingson = embedded_themes()
1873 .into_iter()
1874 .find(|(id, _)| *id == "goingson")
1875 .expect("goingson is embedded")
1876 .1;
1877 let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
1878 let light_raised = light
1879 .hex("surface-raised")
1880 .and_then(Rgb::from_hex)
1881 .expect("goingson authors a raised surface");
1882 assert_eq!(
1883 light.hex("surface-well").unwrap(),
1884 lighten(light_raised, 0.07).to_hex()
1885 );
1886 }
1887
1888 // A well is a fill, not an edge, so the only thing that makes it read is
1889 // being a different color from the surface it is cut into.
1890 //
1891 // Same shape and the same asserted-list discipline as
1892 // `bevel_edges_are_distinct_from_their_face`, and it bites the same two
1893 // themes for the same reason: a raised surface already at the top of the
1894 // ramp has nothing lighter to go to.
1895 #[test]
1896 fn well_is_distinct_from_its_face() {
1897 const CANNOT_WELL: &[&str] = &["neobrute", "oxocarbon-light"];
1898
1899 let mut degenerate: Vec<String> = Vec::new();
1900 for (id, source) in embedded_themes() {
1901 let theme = parse_theme_str(id, source, false).unwrap();
1902 let t = resolve(&theme);
1903 let Some(raised) = t.hex("surface-raised") else {
1904 continue;
1905 };
1906 let well = t.hex("surface-well").expect("raised implies surface-well");
1907 if well == raised {
1908 degenerate.push(id.to_string());
1909 }
1910 }
1911 degenerate.sort();
1912
1913 assert_eq!(
1914 degenerate, CANNOT_WELL,
1915 "themes whose raised surface cannot hold a well"
1916 );
1917 }
1918
1919 // Distinct is not the same as visible. A face near the top of the ramp
1920 // clamps partway rather than exactly, which yields a well that differs from
1921 // its face by a hex digit and by nothing the eye can find. `rosepine-dawn`
1922 // authors raised at L=0.987 and gets 0.009 of the 0.07 it asked for.
1923 //
1924 // Worth a separate test from the one above because the fix differs: an
1925 // exactly-degenerate theme needs its raised surface off the ramp end, while
1926 // these need it merely lowered. Both fixes are the theme's, not this
1927 // derivation's, which is why the list is asserted rather than warned about.
1928 #[test]
1929 fn well_is_visible_against_its_face() {
1930 // Below this, the well and its face are the same surface to a reader.
1931 const MIN_DELTA_L: f32 = 0.02;
1932 const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] =
1933 &["neobrute", "oxocarbon-light", "rosepine-dawn"];
1934
1935 let mut invisible: Vec<String> = Vec::new();
1936 for (id, source) in embedded_themes() {
1937 let theme = parse_theme_str(id, source, false).unwrap();
1938 let t = resolve(&theme);
1939 let (Some(raised), Some(well)) = (
1940 t.hex("surface-raised").and_then(Rgb::from_hex),
1941 t.hex("surface-well").and_then(Rgb::from_hex),
1942 ) else {
1943 continue;
1944 };
1945 if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_DELTA_L {
1946 invisible.push(id.to_string());
1947 }
1948 }
1949 invisible.sort();
1950
1951 assert_eq!(
1952 invisible, CANNOT_HOLD_A_VISIBLE_WELL,
1953 "themes whose well is too close to its face to read as one"
1954 );
1955 }
1956
1957 // The three tests above each measure a derived color against the face it was
1958 // derived from, so a theme can pass all of them and still have nothing lift
1959 // off anything: the face itself sits on the page, and that relationship is
1960 // the one a bevel needs in order to read as an object rather than as a
1961 // rectangle with decorated edges. makenot.work passed all three and could
1962 // not hold a bevel, which is what this covers.
1963 //
1964 // The threshold is picked against the ramps already ruled on rather than
1965 // against a round number. makenot.work shipped at 0.024 and was invisible,
1966 // was tried at 0.036 and rejected as marginal on badges and chips, and was
1967 // accepted at 0.058; goingson and audiofiles sit at 0.119 and 0.065. Every
1968 // ramp judged inadequate is below 0.036 and every one judged adequate is
1969 // above 0.058, so the line goes in the gap between them. Note the unit: this
1970 // is oklab L on 0 to 1, not the CIE L* on 0 to 100 that the theme files quote
1971 // in their comments, and the two are not interchangeable.
1972 //
1973 // Most of the list is imported palettes, which were authored for syntax
1974 // highlighting and owe our depth model nothing. Failing here says a theme
1975 // cannot hold a bevel, not that it is wrong. Shrinking the list is the fix;
1976 // growing it is a regression in the theme, not in this derivation.
1977 //
1978 // tokyonight left the list on 2026-08-15, and it is the only entry that could
1979 // leave without a judgment call about someone else's palette. Its page and
1980 // raised were the identical hex, so it had no ramp at all rather than a
1981 // shallow one, and the fix is upstream's own `bg_highlight` (#292e42, 0.079
1982 // above the page) rather than a color we picked. The other nineteen are
1983 // shallow ramps in published palettes, which is a different claim, and they
1984 // stay deferred until every app is migrated and eyeballed.
1985 #[test]
1986 fn raised_is_distinct_from_page() {
1987 // Below this, a raised surface and the page under it are one surface to
1988 // a reader, whichever direction the theme ramps in.
1989 const MIN_DELTA_L: f32 = 0.05;
1990 const CANNOT_LIFT_OFF_THE_PAGE: &[&str] = &[
1991 "akari-dawn",
1992 "akari-night",
1993 "ayu-light",
1994 "ayu-mirage",
1995 "catppuccin-latte",
1996 "catppuccin-mocha",
1997 "dawnfox",
1998 "dracula",
1999 "everforest",
2000 "flatwhite",
2001 "gruvbox-light",
2002 "neobrute",
2003 "one-dark",
2004 "oxocarbon-dark",
2005 "oxocarbon-light",
2006 "poimandres",
2007 "rosepine",
2008 "rosepine-dawn",
2009 "solarized-dark",
2010 ];
2011
2012 let mut flat: Vec<String> = Vec::new();
2013 for (id, source) in embedded_themes() {
2014 let theme = parse_theme_str(id, source, false).unwrap();
2015 let t = resolve(&theme);
2016 let (Some(page), Some(raised)) = (
2017 t.hex("surface-page").and_then(Rgb::from_hex),
2018 t.hex("surface-raised").and_then(Rgb::from_hex),
2019 ) else {
2020 continue;
2021 };
2022 if (raised.to_oklab().l - page.to_oklab().l).abs() < MIN_DELTA_L {
2023 flat.push(id.to_string());
2024 }
2025 }
2026 flat.sort();
2027
2028 assert_eq!(
2029 flat, CANNOT_LIFT_OFF_THE_PAGE,
2030 "themes whose raised surface is too close to the page to lift off it"
2031 );
2032 }
2033
2034 // What the bevel pair does on a sixteen-color terminal, measured across the
2035 // shipped set rather than assumed. Two results, both load-bearing for a
2036 // consumer that has to render one there.
2037 //
2038 // Exactly one edge survives, never both. A raised face quantizes onto one of
2039 // the palette's three grays, and the palette is too coarse to hold anything
2040 // between that entry and its neighbour, so whichever edge is pushed toward
2041 // the end of the ramp the face already sits on lands back on the face. Light
2042 // themes and most dark ones keep the shadow and lose the highlight; a face
2043 // that quantizes to black keeps the highlight and loses the shadow.
2044 //
2045 // So a low-color consumer draws the single edge it can render, on the side
2046 // the palette left it, rather than a bevel that resolves on two sides.
2047 //
2048 // And `quantize_against` is the wrong function for this pair, though it is
2049 // the right one for a border. It answers "nearest entry that clears DISTINCT
2050 // against the background", which has no notion of direction, so both edges
2051 // are pushed onto the same contrasting entry and the bevel inverts on one
2052 // side. Plain `quantize` keeps them apart and in the right order.
2053 #[test]
2054 fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
2055 for (id, source) in embedded_themes() {
2056 let theme = parse_theme_str(id, source, false).unwrap();
2057 let t = resolve(&theme);
2058 let (Some(face), Some(light), Some(dark)) = (
2059 t.hex("surface-raised").and_then(Rgb::from_hex),
2060 t.hex("bevel-light").and_then(Rgb::from_hex),
2061 t.hex("bevel-dark").and_then(Rgb::from_hex),
2062 ) else {
2063 continue;
2064 };
2065
2066 let face_index = quantize(face, &ANSI_16);
2067 let light_survives = quantize(light, &ANSI_16) != face_index;
2068 let dark_survives = quantize(dark, &ANSI_16) != face_index;
2069 assert!(
2070 light_survives != dark_survives,
2071 "{id}: expected exactly one bevel edge to survive 16 colors, \
2072 highlight {light_survives} shadow {dark_survives}"
2073 );
2074
2075 // Direction-blind, so it collapses the pair it is asked to separate.
2076 assert_eq!(
2077 quantize_against(light, face, &ANSI_16),
2078 quantize_against(dark, face, &ANSI_16),
2079 "{id}: quantize_against is expected to be unusable for a bevel pair"
2080 );
2081 }
2082 }
2083
2084 // 256 colors is where the bevel starts working. At 16 every shipped theme
2085 // loses an edge; here all but the five whose raised surface sits at the very
2086 // top of the ramp keep both, and those five fail for the reason they fail in
2087 // truecolor rather than for a palette reason.
2088 //
2089 // Three of them cannot bevel at any depth, so they are the
2090 // `bevel_edges_are_distinct_from_their_face` set. The other two are new here:
2091 // they hold a highlight in 24-bit, but not one wide enough to survive
2092 // rounding onto the cube.
2093 #[test]
2094 fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
2095 const LOSES_AN_EDGE: &[&str] = &[
2096 "gruvbox-light",
2097 "neobrute",
2098 "oxocarbon-light",
2099 "rosepine-dawn",
2100 ];
2101
2102 let mut lost: Vec<String> = Vec::new();
2103 for (id, source) in embedded_themes() {
2104 let theme = parse_theme_str(id, source, false).unwrap();
2105 let t = resolve(&theme);
2106 let (Some(face), Some(light), Some(dark)) = (
2107 t.hex("surface-raised").and_then(Rgb::from_hex),
2108 t.hex("bevel-light").and_then(Rgb::from_hex),
2109 t.hex("bevel-dark").and_then(Rgb::from_hex),
2110 ) else {
2111 continue;
2112 };
2113
2114 // Against the fixed region, which is what a consumer should use: a
2115 // match in the low sixteen is a match against a repaintable color.
2116 let f = quantize(face, ANSI_240);
2117 let l = quantize(light, ANSI_240);
2118 let d = quantize(dark, ANSI_240);
2119 if l == f || d == f || l == d {
2120 lost.push(id.to_string());
2121 }
2122 }
2123 lost.sort();
2124
2125 assert_eq!(
2126 lost, LOSES_AN_EDGE,
2127 "themes that cannot hold a two-tone bevel on a 256-color terminal"
2128 );
2129 }
2130
2131 #[test]
2132 fn the_256_table_has_its_three_regions() {
2133 // Index is the escape-sequence index, so the low sixteen must match.
2134 assert_eq!(ANSI_256[..16], ANSI_16);
2135 // The cube's corners, at both ends and one interior level.
2136 assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
2137 assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
2138 assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
2139 // The gray ramp runs 8 to 238 and contains neither black nor white.
2140 assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
2141 assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
2142 // The fixed region is the table minus the repaintable colors.
2143 assert_eq!(ANSI_240.len(), 240);
2144 assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
2145 }
2146
2147 #[test]
2148 fn resolve_overlay_is_dark_translucent_scrim() {
2149 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2150 let t = resolve(&theme);
2151 let overlay = t.hex("overlay").unwrap();
2152 assert!(
2153 overlay.starts_with("rgba("),
2154 "overlay is translucent: {overlay}"
2155 );
2156 assert!(overlay.ends_with(", 0.5)"));
2157 // The scrim tone is anchored very dark regardless of theme.
2158 let inner = overlay
2159 .trim_start_matches("rgba(")
2160 .trim_end_matches(", 0.5)");
2161 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
2162 let scrim = Rgb {
2163 r: parts[0],
2164 g: parts[1],
2165 b: parts[2],
2166 };
2167 assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
2168 }
2169
2170 /// Every shipped theme derives it, on both polarities, and it is always a
2171 /// near-black translucent tone. A shadow tinted to a dark theme's own
2172 /// lightness would not read as one.
2173 #[test]
2174 fn elevation_is_a_near_black_cast_on_every_theme() {
2175 for (id, source) in embedded_themes() {
2176 let theme = parse_theme_str(id, source, false).unwrap();
2177 let t = resolve(&theme);
2178 let Some(elevation) = t.hex("elevation") else {
2179 panic!("{id} derives no elevation");
2180 };
2181 assert!(
2182 elevation.starts_with("rgba(") && elevation.ends_with(", 0.18)"),
2183 "{id}: elevation is translucent: {elevation}"
2184 );
2185 let inner = elevation
2186 .trim_start_matches("rgba(")
2187 .trim_end_matches(", 0.18)");
2188 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
2189 let cast = Rgb {
2190 r: parts[0],
2191 g: parts[1],
2192 b: parts[2],
2193 };
2194 assert!(
2195 cast.to_oklab().l < 0.2,
2196 "{id}: a cast shadow must be near-black, got {elevation}"
2197 );
2198 }
2199 }
2200
2201 /// The scrim and the cast share an anchor and differ only in weight. Stated
2202 /// as a test because the two are easy to drift apart, and a scrim that
2203 /// stopped matching the shadow under the thing it dims would show.
2204 #[test]
2205 fn elevation_and_the_scrim_are_the_same_tone() {
2206 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2207 let t = resolve(&theme);
2208 let scrim = t.hex("overlay").unwrap();
2209 let cast = t.hex("elevation").unwrap();
2210 assert_eq!(
2211 scrim.trim_end_matches(", 0.5)"),
2212 cast.trim_end_matches(", 0.18)"),
2213 );
2214 }
2215
2216 /// The accessor that makes a translucent intent reachable from something
2217 /// that is not a stylesheet. Both spellings, and an opaque token answers
2218 /// 255 so a caller need not know which kind it asked for.
2219 #[test]
2220 fn rgba_reads_both_spellings() {
2221 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2222 let t = resolve(&theme);
2223
2224 let (_, _, _, opaque) = t.rgba("surface-page").expect("page is a hex token");
2225 assert_eq!(opaque, 255);
2226
2227 let (r, g, b, alpha) = t.rgba("elevation").expect("elevation is translucent");
2228 assert_eq!(alpha, 46, "0.18 of 255");
2229 assert_eq!(t.rgb("elevation"), None, "rgb declines to drop the alpha");
2230
2231 let (sr, sg, sb, scrim) = t.rgba("overlay").expect("overlay is translucent");
2232 assert_eq!((sr, sg, sb), (r, g, b), "one tone, two weights");
2233 assert_eq!(scrim, 128);
2234 }
2235
2236 #[test]
2237 fn resolve_drops_non_hex_base_intent() {
2238 // A base intent that isn't a hex color must never reach the resolved
2239 // token set (it would otherwise be inlined verbatim into a <style>
2240 // block). Skipped like a missing intent; valid siblings survive.
2241 let theme = parse_theme_str(
2242 "x",
2243 "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
2244 false,
2245 )
2246 .unwrap();
2247 let t = resolve(&theme);
2248 assert!(
2249 t.hex("surface-page").is_none(),
2250 "non-hex base intent leaked"
2251 );
2252 assert_eq!(t.hex("content").unwrap(), "#111111");
2253 // The injected markup appears in no resolved value.
2254 assert!(!t.intents.values().any(|v| v.contains('<')));
2255 }
2256
2257 #[test]
2258 fn resolve_skips_derived_when_source_missing() {
2259 // No [action] => no action-derived tokens.
2260 let theme = parse_theme_str(
2261 "x",
2262 "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
2263 false,
2264 )
2265 .unwrap();
2266 let t = resolve(&theme);
2267 assert!(t.hex("action").is_none());
2268 assert!(t.hex("action-hover").is_none());
2269 assert!(t.hex("selection").is_none());
2270 assert_eq!(
2271 t.hex("border-strong").unwrap(),
2272 darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()
2273 );
2274 }
2275
2276 #[test]
2277 fn rgb_accessor_for_native_consumers() {
2278 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2279 let t = resolve(&theme);
2280 assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1)));
2281 assert_eq!(t.rgb("nonexistent"), None);
2282 }
2283
2284 // ---- css emit ----
2285
2286 #[test]
2287 fn intent_css_vars_wraps_root_and_includes_tokens() {
2288 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
2289 let css = intent_css_vars(&resolve(&theme));
2290 assert!(css.starts_with(":root {\n"));
2291 assert!(css.contains(" --surface-page: #2e3440;\n"));
2292 assert!(css.contains(" --danger: #bf616a;\n"));
2293 assert!(css.contains(" --action-hover: "));
2294 assert!(css.trim_end().ends_with('}'));
2295 }
2296
2297 // ---- loading / fs ----
2298
2299 #[test]
2300 fn load_and_resolve_round_trip() {
2301 let dir = tempfile::tempdir().unwrap();
2302 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
2303 let dirs = vec![(dir.path().to_path_buf(), false)];
2304 let t = load_semantic(&dirs, "nord").unwrap();
2305 assert_eq!(t.meta.name, "Nord");
2306 assert_eq!(t.hex("action"), Some("#81a1c1"));
2307 }
2308
2309 #[test]
2310 fn load_theme_rejects_invalid_id() {
2311 assert!(load_theme(&[], "../evil").is_err());
2312 }
2313
2314 fn meta(id: &str, variant: &str) -> ThemeMeta {
2315 ThemeMeta {
2316 id: id.to_string(),
2317 name: id.to_string(),
2318 variant: variant.to_string(),
2319 is_custom: false,
2320 }
2321 }
2322
2323 fn defaults() -> ThemeDefaults {
2324 ThemeDefaults::new("flatwhite", "nord")
2325 }
2326
2327 // The three the shipped themes actually declare.
2328 #[test]
2329 fn every_shipped_variant_parses() {
2330 assert_eq!(Variant::parse("light"), Some(Variant::Light));
2331 assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
2332 assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
2333 assert_eq!(Variant::parse("sepia"), None);
2334 }
2335
2336 // parse_meta already defaults a *missing* variant to dark, so an
2337 // unrecognized one reading as light would have the crate disagreeing with
2338 // itself. alloy_tui did exactly that before this existed.
2339 #[test]
2340 fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
2341 assert_eq!(Variant::from("sepia"), Variant::Dark);
2342 assert_eq!(Variant::from(""), Variant::Dark);
2343
2344 let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
2345 assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
2346 }
2347
2348 #[test]
2349 fn a_selection_round_trips_through_any_store() {
2350 for (stored, expect) in [
2351 (Some("system"), ThemeSelection::Follow),
2352 (None, ThemeSelection::Follow),
2353 (Some(""), ThemeSelection::Follow),
2354 (Some(" "), ThemeSelection::Follow),
2355 (Some("nord"), ThemeSelection::Fixed("nord".into())),
2356 ] {
2357 let parsed = ThemeSelection::parse(stored);
2358 assert_eq!(parsed, expect, "{stored:?}");
2359 assert_eq!(
2360 ThemeSelection::parse(Some(parsed.as_str())),
2361 expect,
2362 "what is written reads back as what was meant",
2363 );
2364 }
2365 }
2366
2367 // Nothing saved is follow-the-system, which is what Balanced Breakfast
2368 // expressed as an absent value and GoingsOn as a sentinel. Both are now the
2369 // same thing.
2370 #[test]
2371 fn nothing_chosen_yet_is_follow() {
2372 assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
2373 }
2374
2375 #[test]
2376 fn a_fixed_selection_wins_when_its_theme_is_installed() {
2377 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
2378 let fixed = ThemeSelection::Fixed("nord".into());
2379 assert_eq!(
2380 fixed.resolve(Variant::Light, &defaults(), &available),
2381 "nord",
2382 "a chosen theme is not overridden by the ambient mode",
2383 );
2384 }
2385
2386 // Themes are deletable in three of the four apps. Handing back an id that
2387 // will fail to load only moves the error somewhere less helpful.
2388 #[test]
2389 fn a_fixed_selection_whose_theme_is_gone_falls_back() {
2390 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
2391 let fixed = ThemeSelection::Fixed("deleted".into());
2392 assert_eq!(
2393 fixed.resolve(Variant::Light, &defaults(), &available),
2394 "flatwhite",
2395 );
2396 }
2397
2398 #[test]
2399 fn follow_picks_the_apps_default_for_the_ambient_mode() {
2400 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
2401 let follow = ThemeSelection::Follow;
2402 assert_eq!(
2403 follow.resolve(Variant::Dark, &defaults(), &available),
2404 "nord",
2405 );
2406 assert_eq!(
2407 follow.resolve(Variant::Light, &defaults(), &available),
2408 "flatwhite",
2409 );
2410 }
2411
2412 // The behaviour Balanced Breakfast could not have: following the system
2413 // into a theme the user installed, when the app's own default is absent.
2414 #[test]
2415 fn follow_uses_any_installed_theme_of_the_right_variant() {
2416 let available = [meta("solarized-light", "light"), meta("mine", "dark")];
2417 assert_eq!(
2418 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
2419 "mine",
2420 "the app's `nord` is not installed, but a dark theme is",
2421 );
2422 }
2423
2424 // Always returns something: an app with no theme directory gets the id it
2425 // ships with, and the load error it would have had anyway.
2426 #[test]
2427 fn an_empty_catalog_still_names_the_apps_default() {
2428 assert_eq!(
2429 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]),
2430 "nord",
2431 );
2432 }
2433
2434 #[test]
2435 fn high_contrast_falls_back_to_dark_unless_named() {
2436 let plain = defaults();
2437 assert_eq!(plain.for_variant(Variant::HighContrast), "nord");
2438
2439 let named = defaults().high_contrast("sharp");
2440 assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
2441 }
2442
2443 // The bug this builder exists to prevent: the Alloy console pushed the
2444 // user's directory first under a comment reading "highest precedence
2445 // first", when both consumers of this vector resolve last-wins. A custom
2446 // theme lost to the packaged one of the same id.
2447 #[test]
2448 fn the_users_own_themes_outrank_everything() {
2449 let root = tempfile::tempdir().unwrap();
2450 let make = |name: &str| {
2451 let dir = root.path().join(name);
2452 std::fs::create_dir_all(&dir).unwrap();
2453 dir
2454 };
2455 let (bundled, system, custom) = (make("bundled"), make("system"), make("custom"));
2456
2457 let dirs = ThemeDirs::new()
2458 .custom(Some(custom.clone()))
2459 .bundled(Some(bundled.clone()))
2460 .system(Some(system.clone()))
2461 .build();
2462
2463 assert_eq!(
2464 dirs,
2465 vec![(bundled, false), (system, false), (custom.clone(), true)],
2466 "lowest precedence first, whatever order the tiers were added in",
2467 );
2468 assert!(dirs.last().unwrap().1, "only the user's tier is custom");
2469
2470 // And the ordering means what the consumers think it means.
2471 for dir in dirs.iter().map(|(dir, _)| dir) {
2472 std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
2473 }
2474 assert_eq!(
2475 find_theme_path(&dirs, "shared").unwrap().0,
2476 custom.join("shared.toml"),
2477 "the user's copy is the one that loads",
2478 );
2479 }
2480
2481 #[test]
2482 fn a_directory_that_does_not_exist_is_dropped() {
2483 let root = tempfile::tempdir().unwrap();
2484 let real = root.path().join("real");
2485 std::fs::create_dir_all(&real).unwrap();
2486
2487 let dirs = ThemeDirs::new()
2488 .bundled(Some(root.path().join("nope")))
2489 .system(None)
2490 .custom(Some(real.clone()))
2491 .build();
2492
2493 assert_eq!(dirs, vec![(real, true)]);
2494 }
2495
2496 // A Tauri app has two bundled tiers: the resource dir in production and the
2497 // tree build.rs materialized for a dev run with no resource dir.
2498 #[test]
2499 fn more_than_one_bundled_tier_is_allowed() {
2500 let root = tempfile::tempdir().unwrap();
2501 let (first, second) = (root.path().join("a"), root.path().join("b"));
2502 std::fs::create_dir_all(&first).unwrap();
2503 std::fs::create_dir_all(&second).unwrap();
2504
2505 let dirs = ThemeDirs::new()
2506 .bundled(Some(first.clone()))
2507 .bundled(Some(second.clone()))
2508 .build();
2509 assert_eq!(dirs, vec![(first, false), (second, false)]);
2510 }
2511
2512 #[test]
2513 fn list_themes_from_dirs_finds_toml_files() {
2514 let dir = tempfile::tempdir().unwrap();
2515 fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
2516 fs::write(dir.path().join("x.txt"), "ignored").unwrap();
2517 let dirs = vec![(dir.path().to_path_buf(), false)];
2518 let themes = list_themes_from_dirs(&dirs);
2519 assert_eq!(themes.len(), 1);
2520 assert_eq!(themes[0].id, "t");
2521 }
2522
2523 #[test]
2524 fn find_theme_path_reverse_priority() {
2525 let d1 = tempfile::tempdir().unwrap();
2526 let d2 = tempfile::tempdir().unwrap();
2527 fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap();
2528 fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap();
2529 let dirs = vec![
2530 (d1.path().to_path_buf(), false),
2531 (d2.path().to_path_buf(), true),
2532 ];
2533 let (path, is_custom) = find_theme_path(&dirs, "s").unwrap();
2534 assert!(is_custom);
2535 assert_eq!(path, d2.path().join("s.toml"));
2536 }
2537
2538 #[test]
2539 fn import_theme_valid_and_rejects_empty() {
2540 let src_dir = tempfile::tempdir().unwrap();
2541 let custom_dir = tempfile::tempdir().unwrap();
2542
2543 let good = src_dir.path().join("my-theme.toml");
2544 fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
2545 let meta = import_theme(&good, custom_dir.path()).unwrap();
2546 assert_eq!(meta.id, "my-theme");
2547 assert!(custom_dir.path().join("my-theme.toml").exists());
2548
2549 let empty = src_dir.path().join("empty.toml");
2550 fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
2551 assert!(import_theme(&empty, custom_dir.path()).is_err());
2552 }
2553
2554 #[test]
2555 fn import_theme_rejects_invalid_toml() {
2556 let src_dir = tempfile::tempdir().unwrap();
2557 let custom_dir = tempfile::tempdir().unwrap();
2558 let src = src_dir.path().join("bad.toml");
2559 fs::write(&src, "this is not [valid toml [[[").unwrap();
2560 assert!(import_theme(&src, custom_dir.path()).is_err());
2561 }
2562
2563 #[test]
2564 fn delete_theme_removes_and_guards() {
2565 let custom = tempfile::tempdir().unwrap();
2566 let path = custom.path().join("doomed.toml");
2567 fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
2568 delete_theme(custom.path(), "doomed").unwrap();
2569 assert!(!path.exists());
2570 assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
2571 assert!(delete_theme(custom.path(), "ghost").is_err());
2572 }
2573
2574 #[test]
2575 fn export_theme_copies_file() {
2576 let src_dir = tempfile::tempdir().unwrap();
2577 let dest_dir = tempfile::tempdir().unwrap();
2578 let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
2579 fs::write(src_dir.path().join("e.toml"), content).unwrap();
2580 let dirs = vec![(src_dir.path().to_path_buf(), false)];
2581 let dest = dest_dir.path().join("out.toml");
2582 export_theme(&dirs, "e", &dest).unwrap();
2583 assert_eq!(fs::read_to_string(&dest).unwrap(), content);
2584 assert!(export_theme(&dirs, "missing", &dest).is_err());
2585 }
2586
2587 #[test]
2588 fn load_theme_preview_returns_role_swatches() {
2589 let dir = tempfile::tempdir().unwrap();
2590 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
2591 let dirs = vec![(dir.path().to_path_buf(), false)];
2592 let p = load_theme_preview(&dirs, "nord").unwrap();
2593 assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page
2594 assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary
2595 assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary
2596 assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border
2597 }
2598
2599 #[test]
2600 fn bundled_themes_dir_resolves_to_shipped_themes() {
2601 // The crate ships its themes, so this must resolve in-tree and the
2602 // Akari defaults the console falls back to must be present.
2603 let dir = bundled_themes_dir().expect("makeover ships a themes/ directory");
2604 assert!(dir.join("akari-dawn.toml").is_file());
2605 assert!(dir.join("akari-night.toml").is_file());
2606 }
2607
2608 #[test]
2609 fn every_theme_is_accounted_for_in_third_party_notices() {
2610 // Attribution is a redistribution obligation, not a nicety: adding a
2611 // theme without a notice entry silently ships someone's work
2612 // uncredited. Fail here instead.
2613 let notices = std::fs::read_to_string(
2614 Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"),
2615 )
2616 .expect("THIRD-PARTY-NOTICES.md must exist");
2617 let missing: Vec<&str> = embedded_themes()
2618 .map(|(id, _)| id)
2619 .filter(|id| !notices.contains(*id))
2620 .collect();
2621 assert!(
2622 missing.is_empty(),
2623 "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}"
2624 );
2625 }
2626
2627 #[test]
2628 fn adapted_themes_carry_inline_attribution() {
2629 // Each adapted file must name its upstream in-file, so the credit
2630 // survives someone copying a single .toml out of the crate.
2631 const ORIGINALS: [&str; 5] = [
2632 "makenotwork",
2633 "goingson",
2634 "audiofiles",
2635 "high-contrast",
2636 "neobrute",
2637 ];
2638 for (id, source) in embedded_themes() {
2639 if ORIGINALS.contains(&id) {
2640 continue;
2641 }
2642 assert!(
2643 source.contains("adapted from"),
2644 "adapted theme `{id}` is missing its inline attribution header"
2645 );
2646 }
2647 }
2648
2649 #[test]
2650 fn embedded_themes_match_the_directory() {
2651 // The embedded copy and themes/ are two views of one source. If they
2652 // ever disagree, path-based and path-free consumers render different
2653 // theme sets, which is exactly the drift shipping the data was meant
2654 // to prevent.
2655 let dir = bundled_themes_dir().unwrap();
2656 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
2657 .unwrap()
2658 .filter_map(|e| {
2659 let path = e.ok()?.path();
2660 if path.extension()? != "toml" {
2661 return None;
2662 }
2663 Some(path.file_stem()?.to_str()?.to_string())
2664 })
2665 .collect();
2666 let mut embedded: Vec<String> = embedded_themes().map(|(id, _)| id.to_string()).collect();
2667 on_disk.sort();
2668 embedded.sort();
2669 assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/");
2670 }
2671
2672 #[test]
2673 fn every_embedded_theme_parses() {
2674 // Guards the path-free consumers (MNW server, the Tauri build steps)
2675 // the same way every_shipped_theme_loads guards the path-based ones.
2676 let mut count = 0;
2677 for (id, source) in embedded_themes() {
2678 parse_theme_str(id, source, false)
2679 .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}"));
2680 count += 1;
2681 }
2682 assert!(count >= 30, "expected the full theme set, got {count}");
2683 }
2684
2685 #[test]
2686 fn every_shipped_theme_loads() {
2687 // Guards the data, not just the loader: a malformed or truncated
2688 // .toml in themes/ is a shipping bug, and it should fail here rather
2689 // than at a user's first launch.
2690 let dir = bundled_themes_dir().unwrap();
2691 let dirs = vec![(dir.clone(), false)];
2692 let themes = list_themes_from_dirs(&dirs);
2693 assert!(
2694 themes.len() >= 30,
2695 "expected the full theme set, got {}",
2696 themes.len()
2697 );
2698 for meta in &themes {
2699 load_theme(&dirs, &meta.id)
2700 .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
2701 }
2702 }
2703 }
2704