Skip to main content

max / makeover

67.0 KB · 1953 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 contrast ratio two colors must clear to read as separate areas.
331 ///
332 /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what
333 /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing
334 /// text can ask for more by checking [`wcag_contrast`] itself.
335 pub const DISTINCT: f32 = 3.0;
336
337 /// Perceptual distance between two colors, for choosing the closest of a set.
338 fn oklab_distance(a: Rgb, b: Rgb) -> f32 {
339 let (x, y) = (a.to_oklab(), b.to_oklab());
340 ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt()
341 }
342
343 /// Index of the entry in `palette` that looks most like `c`.
344 ///
345 /// OKLab distance rather than distance in sRGB, for the same reason [`mix`]
346 /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a
347 /// nearest match computed in it picks visibly wrong entries in the mid tones.
348 ///
349 /// # Panics
350 ///
351 /// If `palette` is empty.
352 pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize {
353 assert!(!palette.is_empty(), "a palette needs at least one color");
354 let mut best = 0;
355 let mut best_distance = f32::INFINITY;
356 for (index, entry) in palette.iter().enumerate() {
357 let distance = oklab_distance(c, *entry);
358 if distance < best_distance {
359 best = index;
360 best_distance = distance;
361 }
362 }
363 best
364 }
365
366 /// Index of the entry in `palette` closest to `fg` that still reads against
367 /// `bg`.
368 ///
369 /// [`quantize`] answers about one color at a time, and two colors that differ
370 /// can quantize to the same entry: a themed page and a border drawn on it are
371 /// often a few steps apart in a 24-bit theme and land together on a 16-color
372 /// terminal, leaving one flat area where there was a frame. Alloy's console
373 /// showed exactly this, and it is not a contrived pairing: a light page and the
374 /// mid-tone border derived from it both land on index 7.
375 ///
376 /// So the background is quantized first, because what the border must be
377 /// distinguished from is the entry the terminal will actually paint, not the
378 /// color the theme asked for. Then the nearest entry to `fg` clearing
379 /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets
380 /// furthest does: at that point the palette cannot honor the design, and the
381 /// most legible approximation beats the closest invisible one.
382 ///
383 /// Only for colors whose whole job is to be told apart from their background.
384 /// Applied to every token it would push a deliberately quiet one until it
385 /// shouted.
386 ///
387 /// # Panics
388 ///
389 /// If `palette` is empty.
390 pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize {
391 assert!(!palette.is_empty(), "a palette needs at least one color");
392 let shown = palette[quantize(bg, palette)];
393
394 let mut order: Vec<usize> = (0..palette.len()).collect();
395 order.sort_by(|a, b| {
396 oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b]))
397 });
398
399 order
400 .iter()
401 .copied()
402 .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT)
403 .unwrap_or_else(|| {
404 order
405 .iter()
406 .copied()
407 .max_by(|a, b| {
408 wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown))
409 })
410 .expect("the palette is not empty")
411 })
412 }
413
414 // ============================================================================
415 // Intent resolution
416 // ============================================================================
417
418 /// Authored base intents: (TOML dotted source key, canonical token key).
419 /// These are read straight from the theme; the token key is the CSS-var stem
420 /// (`--{token}`) and the `rgb()` lookup key.
421 pub const BASE_INTENTS: &[(&str, &str)] = &[
422 ("surface.page", "surface-page"),
423 ("surface.raised", "surface-raised"),
424 ("surface.sunken", "surface-sunken"),
425 ("surface.overlay", "surface-overlay"),
426 ("content.primary", "content"),
427 ("content.secondary", "content-secondary"),
428 ("content.muted", "content-muted"),
429 ("action.primary", "action"),
430 ("status.danger", "danger"),
431 ("status.success", "success"),
432 ("status.warning", "warning"),
433 ("status.info", "info"),
434 ("line.border", "border"),
435 ("category.one", "category-one"),
436 ("category.two", "category-two"),
437 ("category.three", "category-three"),
438 ("category.four", "category-four"),
439 ("category.five", "category-five"),
440 ("category.six", "category-six"),
441 ];
442
443 /// A fully resolved intent layer: every token key → concrete `#rrggbb`.
444 /// Includes both authored base intents and the computed derived intents.
445 #[derive(Debug, Clone, Serialize)]
446 #[serde(rename_all = "camelCase")]
447 pub struct SemanticTokens {
448 pub meta: ThemeMeta,
449 /// token-key → resolved hex. Stable, deterministic ordering.
450 pub intents: BTreeMap<String, String>,
451 }
452
453 impl SemanticTokens {
454 /// Resolved hex for a token key, if present.
455 pub fn hex(&self, key: &str) -> Option<&str> {
456 self.intents.get(key).map(String::as_str)
457 }
458
459 /// Resolved RGB tuple for a token key (for egui / native consumers).
460 pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
461 self.intents
462 .get(key)
463 .and_then(|h| Rgb::from_hex(h))
464 .map(Rgb::tuple)
465 }
466 }
467
468 /// Resolve an authored theme into the full intent token set.
469 ///
470 /// 1. Copy each present base intent from the authored colors.
471 /// 2. Compute the derived interactive states from the base intents, using the
472 /// same math the apps used to apply individually (so output is identical).
473 ///
474 /// Each derived token is emitted only when its source intents exist, mirroring
475 /// the skip-missing behavior of the rest of the crate.
476 pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
477 let mut intents: BTreeMap<String, String> = BTreeMap::new();
478
479 // 1. Base intents (authored). Copy only values that parse as a hex color and
480 // re-emit them in canonical `#rrggbb` form, so an authored value can never
481 // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined
482 // raw into a `<style>` block by the web server). A malformed value is skipped,
483 // mirroring the skip-missing behavior for absent intents.
484 for (src, token) in BASE_INTENTS {
485 if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
486 intents.insert((*token).to_string(), rgb.to_hex());
487 }
488 }
489
490 // Helper: parse an already-resolved token to Rgb.
491 let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
492
493 // 2. Derived intents — perceptual (OKLab) steps + WCAG-picked text.
494 // Lightness deltas are in OKLab L units; mix ratios interpolate in OKLab.
495 let mut derived: Vec<(String, Rgb)> = Vec::new();
496 if let Some(action) = get(&intents, "action") {
497 derived.push(("action-hover".into(), lighten(action, 0.05)));
498 derived.push(("content-on-action".into(), readable_on(action)));
499 derived.push(("focus-ring".into(), action));
500 }
501 if let Some(page) = get(&intents, "surface-page") {
502 // Modal scrim: a near-black tone carrying a faint hint of the theme's
503 // hue, at 50% alpha. Anchored very dark (OKLab L=0.08) so it dims the
504 // page on light *and* dark themes. Emitted as rgba (not a flat hex), so
505 // it is inserted directly rather than through the hex loop below.
506 let mut o = page.to_oklab();
507 o.l = 0.08;
508 let s = Rgb::from_oklab(o);
509 intents.insert(
510 "overlay".into(),
511 format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
512 );
513 }
514 if let Some(sunken) = get(&intents, "surface-sunken") {
515 derived.push(("hover-surface".into(), sunken));
516 }
517 if let Some(border) = get(&intents, "border") {
518 derived.push(("border-strong".into(), darken(border, 0.05)));
519 }
520
521 for (token, rgb) in derived {
522 intents.insert(token, rgb.to_hex());
523 }
524
525 SemanticTokens {
526 meta: theme.meta.clone(),
527 intents,
528 }
529 }
530
531 /// Emit the resolved intent layer as CSS declarations (no selector), one
532 /// ` --token: #hex;` line each, in deterministic (BTreeMap) order.
533 pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
534 let mut out = String::new();
535 for (token, hex) in &tokens.intents {
536 out.push_str(" --");
537 out.push_str(token);
538 out.push_str(": ");
539 out.push_str(hex);
540 out.push_str(";\n");
541 }
542 out
543 }
544
545 /// Emit the resolved intent layer as a `:root { … }` block — the single TOML →
546 /// CSS mapping every web surface injects.
547 pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
548 format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
549 }
550
551 // ============================================================================
552 // Loading / parsing
553 // ============================================================================
554
555 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
556 pub fn validate_theme_id(id: &str) -> Result<(), String> {
557 if !id
558 .chars()
559 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
560 {
561 return Err(format!("Invalid theme ID: {id}"));
562 }
563 Ok(())
564 }
565
566 /// Parse the `[meta]` section into `ThemeMeta`.
567 ///
568 /// Falls back to the file ID as the name and `"dark"` as the variant.
569 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
570 let meta = table.get("meta").and_then(|m| m.as_table());
571 let name = meta
572 .and_then(|m| m.get("name"))
573 .and_then(|v| v.as_str())
574 .unwrap_or(id)
575 .to_string();
576 let variant = meta
577 .and_then(|m| m.get("variant"))
578 .and_then(|v| v.as_str())
579 .unwrap_or("dark")
580 .to_string();
581
582 ThemeMeta {
583 id: id.to_string(),
584 name,
585 variant,
586 is_custom,
587 }
588 }
589
590 // ============================================================================
591 // Choosing a theme.
592 //
593 // The file half of this crate was always shared; the *selection* half was not,
594 // and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
595 // localStorage, Balanced Breakfast treats an absent value as follow-the-system
596 // and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
597 // in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
598 // disagreed about what a variant string means: this crate defaults a missing
599 // one to "dark" while alloy_tui parsed an unrecognized one as light.
600 //
601 // What cannot be shared is the store — localStorage, a synced config table and
602 // a TOML file are genuinely different places. What can be shared, and is here,
603 // is the *meaning*: one vocabulary for variants, one encoding for "what did the
604 // user choose", and one rule for turning that into an id that exists.
605 // ============================================================================
606
607 /// A theme's kind, as declared by `meta.variant`.
608 ///
609 /// Three, not two: one shipped theme is `high-contrast`, and an app that
610 /// matched on light-or-dark alone would quietly file it under the wrong one.
611 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
612 #[serde(rename_all = "kebab-case")]
613 pub enum Variant {
614 Light,
615 Dark,
616 HighContrast,
617 }
618
619 impl Variant {
620 /// The spelling used in a theme file and in [`ThemeMeta::variant`].
621 #[must_use]
622 pub const fn as_str(self) -> &'static str {
623 match self {
624 Variant::Light => "light",
625 Variant::Dark => "dark",
626 Variant::HighContrast => "high-contrast",
627 }
628 }
629
630 /// Read a variant string, or `None` if it names none of them.
631 #[must_use]
632 pub fn parse(raw: &str) -> Option<Self> {
633 match raw {
634 "light" => Some(Variant::Light),
635 "dark" => Some(Variant::Dark),
636 "high-contrast" => Some(Variant::HighContrast),
637 _ => None,
638 }
639 }
640 }
641
642 impl std::fmt::Display for Variant {
643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644 f.write_str(self.as_str())
645 }
646 }
647
648 /// Anything unrecognized reads as dark, which is what [`parse_meta`] already
649 /// does with a missing one. Consumers that guessed light for an unknown string
650 /// were disagreeing with the crate that produced it.
651 impl From<&str> for Variant {
652 fn from(raw: &str) -> Self {
653 Variant::parse(raw).unwrap_or(Variant::Dark)
654 }
655 }
656
657 impl ThemeMeta {
658 /// This theme's variant as a value rather than a string.
659 #[must_use]
660 pub fn kind(&self) -> Variant {
661 Variant::from(self.variant.as_str())
662 }
663 }
664
665 /// The spelling of "follow whatever the system is doing", in every store.
666 pub const FOLLOW: &str = "system";
667
668 /// What the user chose, as opposed to what is being rendered.
669 ///
670 /// The distinction is the whole point: `Follow` is a standing instruction that
671 /// resolves differently as the ambient mode changes, and a `Fixed` id is an
672 /// answer that does not. An app that stored only the rendered id could not tell
673 /// the two apart the next time the system flipped to dark.
674 #[derive(Debug, Clone, PartialEq, Eq, Default)]
675 pub enum ThemeSelection {
676 /// Track the ambient light/dark mode.
677 #[default]
678 Follow,
679 /// Always this theme.
680 Fixed(String),
681 }
682
683 impl ThemeSelection {
684 /// Read a stored selection. An empty or absent value is [`Follow`], which
685 /// is what an app with nothing saved yet should do.
686 ///
687 /// [`Follow`]: ThemeSelection::Follow
688 #[must_use]
689 pub fn parse(raw: Option<&str>) -> Self {
690 match raw.map(str::trim) {
691 None | Some("" | FOLLOW) => ThemeSelection::Follow,
692 Some(id) => ThemeSelection::Fixed(id.to_string()),
693 }
694 }
695
696 /// The string to persist, whatever the store is.
697 #[must_use]
698 pub fn as_str(&self) -> &str {
699 match self {
700 ThemeSelection::Follow => FOLLOW,
701 ThemeSelection::Fixed(id) => id,
702 }
703 }
704
705 /// Turn a selection into a theme id that exists.
706 ///
707 /// `ambient` is the light/dark mode the app learned however it can: a
708 /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
709 /// from a terminal. `available` is what [`list_themes_from_dirs`] found.
710 ///
711 /// A `Fixed` id that is no longer on disk falls through to the same path as
712 /// `Follow` rather than being returned anyway. Themes are deletable in
713 /// three of the four apps, and handing back an id that will fail to load
714 /// only moves the error somewhere less helpful.
715 ///
716 /// The fallback chain is: the app's own default for the ambient mode if it
717 /// is installed, then any installed theme of that variant, then the app's
718 /// default regardless. The last step means this always returns something,
719 /// and an app with no theme directory at all gets the id it ships with and
720 /// the load error it would have had anyway.
721 #[must_use]
722 pub fn resolve(
723 &self,
724 ambient: Variant,
725 defaults: &ThemeDefaults,
726 available: &[ThemeMeta],
727 ) -> String {
728 let installed = |id: &str| available.iter().any(|meta| meta.id == id);
729
730 if let ThemeSelection::Fixed(id) = self
731 && installed(id)
732 {
733 return id.clone();
734 }
735
736 let preferred = defaults.for_variant(ambient);
737 if installed(preferred) {
738 return preferred.to_string();
739 }
740 available
741 .iter()
742 .find(|meta| meta.kind() == ambient)
743 .map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
744 }
745 }
746
747 impl std::fmt::Display for ThemeSelection {
748 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749 f.write_str(self.as_str())
750 }
751 }
752
753 /// The themes an app falls back to, one per ambient mode.
754 ///
755 /// App-specific on purpose: which theme is "the app's own" is the app's
756 /// identity, not this crate's business. What is shared is everything around it.
757 #[derive(Debug, Clone)]
758 pub struct ThemeDefaults {
759 light: String,
760 dark: String,
761 high_contrast: Option<String>,
762 }
763
764 impl ThemeDefaults {
765 pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
766 Self {
767 light: light.into(),
768 dark: dark.into(),
769 high_contrast: None,
770 }
771 }
772
773 /// Name a theme for a high-contrast ambient mode. Without one, that mode
774 /// falls back to the dark default, which is the safer of the two to read.
775 #[must_use]
776 pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
777 self.high_contrast = Some(id.into());
778 self
779 }
780
781 #[must_use]
782 pub fn for_variant(&self, variant: Variant) -> &str {
783 match variant {
784 Variant::Light => &self.light,
785 Variant::Dark => &self.dark,
786 Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
787 }
788 }
789 }
790
791 // ============================================================================
792 // Where themes are looked for.
793 //
794 // Four apps built this vector by hand, two of them byte-for-byte identically,
795 // and one of them built it backwards: the Alloy console pushed the user's own
796 // directory first, under a comment saying "highest precedence first", when both
797 // consumers of the vector resolve *last* wins. A user's custom theme lost to
798 // the packaged one of the same id.
799 //
800 // Hence a builder that names the tiers rather than a function taking a vector.
801 // The precedence is stated once, here, and a caller cannot express it backwards
802 // because the order is not theirs to choose.
803 // ============================================================================
804
805 /// Builds the search path [`load_theme`] and [`list_themes_from_dirs`] take.
806 ///
807 /// Tiers are added in whatever order is convenient and always end up in
808 /// precedence order: the user's own themes win, then whatever the system
809 /// ships, then whatever the app bundles.
810 ///
811 /// A directory that does not exist is dropped rather than carried, so callers
812 /// can offer every tier they might have without checking each one.
813 #[derive(Debug, Default, Clone)]
814 pub struct ThemeDirs {
815 bundled: Vec<PathBuf>,
816 system: Vec<PathBuf>,
817 custom: Option<PathBuf>,
818 }
819
820 impl ThemeDirs {
821 #[must_use]
822 pub fn new() -> Self {
823 Self::default()
824 }
825
826 /// Themes the app ships with. Lowest precedence.
827 ///
828 /// Takes more than one because a Tauri app has two: the bundled resource
829 /// directory in production, and the tree `build.rs` materialized for a
830 /// `cargo run` that has no resource directory at all.
831 #[must_use]
832 pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
833 self.bundled.extend(dir);
834 self
835 }
836
837 /// Themes the machine ships, from an image or a package. Overrides bundled.
838 #[must_use]
839 pub fn system(mut self, dir: Option<PathBuf>) -> Self {
840 self.system.extend(dir);
841 self
842 }
843
844 /// The user's own themes. Highest precedence, and the only tier flagged
845 /// custom, which is what makes them exportable and deletable.
846 #[must_use]
847 pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
848 self.custom = dir;
849 self
850 }
851
852 /// The search path, lowest precedence first.
853 #[must_use]
854 pub fn build(self) -> Vec<(PathBuf, bool)> {
855 let mut dirs = Vec::new();
856 for dir in self.bundled.into_iter().chain(self.system) {
857 if dir.is_dir() {
858 dirs.push((dir, false));
859 }
860 }
861 if let Some(dir) = self.custom
862 && dir.is_dir()
863 {
864 dirs.push((dir, true));
865 }
866 dirs
867 }
868 }
869
870 /// Extract the intent color sections into a flat `HashMap` with dotted keys
871 /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
872 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
873 let mut colors = HashMap::new();
874 for section in COLOR_SECTIONS {
875 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
876 for (key, val) in sect {
877 if let Some(color) = val.as_str() {
878 colors.insert(format!("{section}.{key}"), color.to_string());
879 }
880 }
881 }
882 }
883 colors
884 }
885
886 /// Scan directories for `.toml` theme files and return metadata for each.
887 ///
888 /// Directories are checked in order; later entries override earlier ones by ID.
889 /// Each entry in `dirs` is `(path, is_custom)`.
890 pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
891 let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
892
893 for (dir, is_custom) in dirs {
894 let Ok(entries) = std::fs::read_dir(dir) else {
895 continue;
896 };
897
898 for entry in entries {
899 let Ok(entry) = entry else {
900 continue;
901 };
902 let path = entry.path();
903 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
904 continue;
905 }
906
907 let id = path
908 .file_stem()
909 .and_then(|s| s.to_str())
910 .unwrap_or_default()
911 .to_string();
912
913 let Ok(content) = std::fs::read_to_string(&path) else {
914 continue;
915 };
916 let table: toml::Table = match content.parse() {
917 Ok(t) => t,
918 Err(_) => continue,
919 };
920
921 seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
922 }
923 }
924
925 let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
926 themes.sort_by(|a, b| a.name.cmp(&b.name));
927 themes
928 }
929
930 /// Find a theme file by ID in the given directories.
931 ///
932 /// Checks directories in reverse order so the highest-priority directory wins.
933 /// Returns `(path, is_custom)` or `None` if not found.
934 pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
935 let filename = format!("{id}.toml");
936
937 for (dir, is_custom) in dirs.iter().rev() {
938 let path = dir.join(&filename);
939 if path.is_file() {
940 return Some((path, *is_custom));
941 }
942 }
943
944 None
945 }
946
947 /// Parse a complete theme (metadata + colors) from raw TOML content, with no
948 /// filesystem access. For callers that embed themes at compile time.
949 pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
950 validate_theme_id(id)?;
951 let table: toml::Table = content
952 .parse()
953 .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
954 let meta = parse_meta(id, &table, is_custom);
955 let colors = extract_colors(&table);
956 Ok(ThemeColors { meta, colors })
957 }
958
959 /// Load a complete theme (metadata + colors) by ID from the given directories.
960 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
961 validate_theme_id(id)?;
962
963 let (path, is_custom) =
964 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
965
966 let content = std::fs::read_to_string(&path)
967 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
968
969 let table: toml::Table = content
970 .parse()
971 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
972
973 let meta = parse_meta(id, &table, is_custom);
974 let colors = extract_colors(&table);
975
976 Ok(ThemeColors { meta, colors })
977 }
978
979 /// Load a theme and resolve it to the full intent token set in one step.
980 pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
981 Ok(resolve(&load_theme(dirs, id)?))
982 }
983
984 /// Import a theme TOML file into the custom themes directory.
985 ///
986 /// Validates that the file is parseable TOML with at least one intent color
987 /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
988 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
989 let content = std::fs::read_to_string(source_path)
990 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
991
992 let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
993
994 let has_colors = COLOR_SECTIONS
995 .iter()
996 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
997 if !has_colors {
998 return Err(format!(
999 "Theme file must have at least one color section ({})",
1000 COLOR_SECTIONS.join(", ")
1001 ));
1002 }
1003
1004 let id = source_path
1005 .file_stem()
1006 .and_then(|s| s.to_str())
1007 .ok_or("Invalid file name")?
1008 .to_string();
1009 validate_theme_id(&id)?;
1010
1011 std::fs::create_dir_all(custom_dir)
1012 .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
1013
1014 let dest = custom_dir.join(format!("{id}.toml"));
1015 std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
1016
1017 Ok(parse_meta(&id, &table, true))
1018 }
1019
1020 /// Delete a custom theme by ID.
1021 ///
1022 /// Only operates on `custom_dir` — bundled themes are not deletable through
1023 /// this entry point.
1024 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
1025 validate_theme_id(id)?;
1026
1027 let path = custom_dir.join(format!("{id}.toml"));
1028 if !path.is_file() {
1029 return Err(format!("Custom theme '{id}' not found"));
1030 }
1031
1032 std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
1033 }
1034
1035 /// A four-color preview for theme thumbnails: the representative swatch from
1036 /// each of the principal roles.
1037 #[derive(Debug, Clone, Serialize)]
1038 #[serde(rename_all = "camelCase")]
1039 pub struct ThemePreview {
1040 pub meta: ThemeMeta,
1041 /// Page background (`surface.page`).
1042 pub background: Option<String>,
1043 /// Body text (`content.primary`).
1044 pub foreground: Option<String>,
1045 /// Brand/interactive color (`action.primary`).
1046 pub accent: Option<String>,
1047 /// Divider/outline color (`line.border`).
1048 pub border: Option<String>,
1049 }
1050
1051 fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
1052 table
1053 .get(section)
1054 .and_then(|s| s.as_table())
1055 .and_then(|s| s.get(key))
1056 .and_then(|v| v.as_str())
1057 .map(std::string::ToString::to_string)
1058 }
1059
1060 /// Load just the preview swatches for a theme — for UI thumbnails.
1061 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
1062 validate_theme_id(id)?;
1063
1064 let (path, is_custom) =
1065 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
1066
1067 let content = std::fs::read_to_string(&path)
1068 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1069
1070 let table: toml::Table = content
1071 .parse()
1072 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
1073
1074 Ok(ThemePreview {
1075 meta: parse_meta(id, &table, is_custom),
1076 background: color_at(&table, "surface", "page"),
1077 foreground: color_at(&table, "content", "primary"),
1078 accent: color_at(&table, "action", "primary"),
1079 border: color_at(&table, "line", "border"),
1080 })
1081 }
1082
1083 /// Export a theme to a user-chosen path.
1084 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
1085 validate_theme_id(id)?;
1086
1087 let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
1088
1089 std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
1090
1091 Ok(())
1092 }
1093
1094 /// The themes this crate ships, embedded at compile time.
1095 ///
1096 /// `include_dir` is an implementation detail: the public API hands back plain
1097 /// `(id, toml_source)` pairs, so how the data is embedded can change without
1098 /// a breaking release.
1099 static EMBEDDED: include_dir::Dir<'static> =
1100 include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
1101
1102 /// The themes this crate ships, as `(id, toml_source)` pairs.
1103 ///
1104 /// This is the path-free way to reach the bundled set, for consumers that
1105 /// cannot rely on a directory existing at runtime: a crate pulled from
1106 /// crates.io lives in a registry checkout whose location is not knowable at
1107 /// compile time, so `include_dir!` and asset-bundling globs in the depending
1108 /// crate have nothing stable to point at. Embedding here and re-exporting the
1109 /// contents gives them one source of truth without a path.
1110 ///
1111 /// Ordering follows the embedded directory and is not guaranteed; collect and
1112 /// sort by id where a stable order matters (a theme picker, say).
1113 pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
1114 EMBEDDED.files().filter_map(|file| {
1115 let path = file.path();
1116 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
1117 return None;
1118 }
1119 let id = path.file_stem()?.to_str()?;
1120 Some((id, file.contents_utf8()?))
1121 })
1122 }
1123
1124 /// The theme directory this crate ships, for use as a build-from-source
1125 /// fallback.
1126 ///
1127 /// Resolves against `makeover`'s own manifest directory, fixed at compile
1128 /// time, so it works from a path dependency and from a cargo git checkout
1129 /// alike. Installed systems should put their packaged theme directory ahead
1130 /// of this in the search path; this is the entry that keeps `cargo run` in a
1131 /// fresh clone from coming up with no themes at all.
1132 ///
1133 /// Returns `None` when the directory is absent — a cargo cache that has been
1134 /// cleaned, or a vendored copy that dropped the data — so callers degrade to
1135 /// their remaining search path rather than failing.
1136 pub fn bundled_themes_dir() -> Option<PathBuf> {
1137 let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
1138 if themes.is_dir() { Some(themes) } else { None }
1139 }
1140
1141 #[cfg(test)]
1142 mod tests {
1143 use super::*;
1144 use std::fs;
1145
1146 // ---- id validation ----
1147
1148 #[test]
1149 fn validate_theme_id_alphanumeric() {
1150 assert!(validate_theme_id("darkmode").is_ok());
1151 assert!(validate_theme_id("Theme123").is_ok());
1152 }
1153
1154 #[test]
1155 fn validate_theme_id_hyphens_underscores() {
1156 assert!(validate_theme_id("dark-mode").is_ok());
1157 assert!(validate_theme_id("my_theme_v2").is_ok());
1158 }
1159
1160 #[test]
1161 fn validate_theme_id_rejects_path_traversal() {
1162 assert!(validate_theme_id("../etc/passwd").is_err());
1163 assert!(validate_theme_id("foo/bar").is_err());
1164 assert!(validate_theme_id("theme.toml").is_err());
1165 }
1166
1167 // ---- low-color terminals ----
1168
1169 #[test]
1170 fn the_ansi_palette_is_sixteen_distinct_colors() {
1171 let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
1172 seen.sort_unstable();
1173 seen.dedup();
1174 assert_eq!(seen.len(), 16);
1175 }
1176
1177 #[test]
1178 fn quantize_picks_the_obvious_entry() {
1179 let black = Rgb { r: 0, g: 0, b: 0 };
1180 let white = Rgb {
1181 r: 255,
1182 g: 255,
1183 b: 255,
1184 };
1185 assert_eq!(quantize(black, &ANSI_16), 0);
1186 assert_eq!(quantize(white, &ANSI_16), 15);
1187 }
1188
1189 // Nearest-entry quantization is per-color, so two colors a theme keeps
1190 // apart can arrive as one. These two are both closest to the palette's
1191 // light gray, and a border drawn in one on a page painted the other is not
1192 // drawn at all.
1193 #[test]
1194 fn two_colors_can_quantize_to_one_entry() {
1195 let page = Rgb::from_hex("#a8a8a8").unwrap();
1196 let border = Rgb::from_hex("#b4b4b4").unwrap();
1197
1198 assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
1199 assert!(quantize_against(border, page, &ANSI_16) != quantize(page, &ANSI_16));
1200 }
1201
1202 #[test]
1203 fn quantize_against_keeps_the_border_off_the_page() {
1204 let page = Rgb::from_hex("#e4ded6").unwrap();
1205 let border = Rgb::from_hex("#7f786d").unwrap();
1206
1207 let shown_page = ANSI_16[quantize(page, &ANSI_16)];
1208 let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
1209
1210 assert!(
1211 wcag_contrast(shown_border, shown_page) >= DISTINCT,
1212 "border {} on page {} is {:.2}:1",
1213 shown_border.to_hex(),
1214 shown_page.to_hex(),
1215 wcag_contrast(shown_border, shown_page)
1216 );
1217 }
1218
1219 // A color that already reads against its background is left where it is,
1220 // so this can be applied without redesigning what already worked.
1221 #[test]
1222 fn quantize_against_leaves_a_readable_color_alone() {
1223 let page = Rgb::from_hex("#e4ded6").unwrap();
1224 let text = Rgb::from_hex("#1a1816").unwrap();
1225
1226 assert_eq!(
1227 quantize_against(text, page, &ANSI_16),
1228 quantize(text, &ANSI_16)
1229 );
1230 }
1231
1232 // With nothing in the palette to satisfy the request, the most legible
1233 // entry is the answer. Returning the nearest one would return the
1234 // background itself, which is the failure this function exists to avoid.
1235 #[test]
1236 fn an_impossible_palette_gets_the_most_legible_entry() {
1237 let page = Rgb::from_hex("#ffffff").unwrap();
1238 let border = Rgb::from_hex("#fefefe").unwrap();
1239 let palette = [
1240 Rgb::from_hex("#ffffff").unwrap(),
1241 Rgb::from_hex("#fdfdfd").unwrap(),
1242 ];
1243
1244 let chosen = palette[quantize_against(border, page, &palette)];
1245 assert_eq!(chosen.to_hex(), "#fdfdfd");
1246 }
1247
1248 // ---- meta ----
1249
1250 #[test]
1251 fn parse_meta_with_name_and_variant() {
1252 let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
1253 .parse()
1254 .unwrap();
1255 let meta = parse_meta("nord", &table, false);
1256 assert_eq!(meta.id, "nord");
1257 assert_eq!(meta.name, "Nord");
1258 assert_eq!(meta.variant, "light");
1259 assert!(!meta.is_custom);
1260 }
1261
1262 #[test]
1263 fn parse_meta_defaults_to_id_and_dark() {
1264 let table: toml::Table = "".parse().unwrap();
1265 let meta = parse_meta("fallback", &table, true);
1266 assert_eq!(meta.name, "fallback");
1267 assert_eq!(meta.variant, "dark");
1268 assert!(meta.is_custom);
1269 }
1270
1271 // ---- color math (formulas must match the apps they came from) ----
1272
1273 #[test]
1274 fn rgb_hex_roundtrip() {
1275 assert_eq!(
1276 Rgb::from_hex("#6196FF").unwrap(),
1277 Rgb {
1278 r: 0x61,
1279 g: 0x96,
1280 b: 0xff
1281 }
1282 );
1283 assert_eq!(
1284 Rgb::from_hex("#abc").unwrap(),
1285 Rgb {
1286 r: 0xaa,
1287 g: 0xbb,
1288 b: 0xcc
1289 }
1290 );
1291 assert_eq!(
1292 Rgb {
1293 r: 0x61,
1294 g: 0x96,
1295 b: 0xff
1296 }
1297 .to_hex(),
1298 "#6196ff"
1299 );
1300 assert!(Rgb::from_hex("not-a-color").is_none());
1301 }
1302
1303 #[test]
1304 fn oklab_roundtrips_within_tolerance() {
1305 for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
1306 let c = Rgb::from_hex(hex).unwrap();
1307 let back = Rgb::from_oklab(c.to_oklab());
1308 // Gamut round-trip is near-exact (±1 per channel from rounding).
1309 assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
1310 assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
1311 assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
1312 }
1313 }
1314
1315 #[test]
1316 fn wcag_contrast_known_pairs() {
1317 let white = Rgb {
1318 r: 255,
1319 g: 255,
1320 b: 255,
1321 };
1322 let black = Rgb { r: 0, g: 0, b: 0 };
1323 assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
1324 assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
1325 }
1326
1327 #[test]
1328 fn readable_on_picks_by_wcag() {
1329 assert_eq!(
1330 readable_on(Rgb {
1331 r: 255,
1332 g: 255,
1333 b: 255
1334 }),
1335 Rgb { r: 0, g: 0, b: 0 }
1336 );
1337 assert_eq!(
1338 readable_on(Rgb { r: 0, g: 0, b: 0 }),
1339 Rgb {
1340 r: 255,
1341 g: 255,
1342 b: 255
1343 }
1344 );
1345 // A light blue action -> black text reads better.
1346 let action = Rgb::from_hex("#6196ff").unwrap();
1347 assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
1348 }
1349
1350 #[test]
1351 fn lighten_darken_move_oklab_lightness() {
1352 let c = Rgb::from_hex("#6196ff").unwrap();
1353 let l0 = c.to_oklab().l;
1354 assert!(lighten(c, 0.05).to_oklab().l > l0);
1355 assert!(darken(c, 0.05).to_oklab().l < l0);
1356 }
1357
1358 #[test]
1359 fn mix_endpoints_and_midpoint() {
1360 let a = Rgb::from_hex("#000000").unwrap();
1361 let b = Rgb::from_hex("#6196ff").unwrap();
1362 assert_eq!(mix(a, b, 0.0), a);
1363 assert_eq!(mix(a, b, 1.0), b);
1364 // Midpoint sits between the endpoints in OKLab lightness.
1365 let mid = mix(a, b, 0.5).to_oklab().l;
1366 assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
1367 }
1368
1369 // ---- extract + resolve ----
1370
1371 fn nord_toml() -> &'static str {
1372 r##"
1373 [meta]
1374 name = "Nord"
1375 variant = "dark"
1376
1377 [surface]
1378 page = "#2e3440"
1379 raised = "#3b4252"
1380 sunken = "#434c5e"
1381 overlay = "#3b4252"
1382
1383 [content]
1384 primary = "#d8dee9"
1385 secondary = "#e5e9f0"
1386 muted = "#616e88"
1387
1388 [action]
1389 primary = "#81a1c1"
1390
1391 [status]
1392 danger = "#bf616a"
1393 success = "#a3be8c"
1394 warning = "#ebcb8b"
1395 info = "#88c0d0"
1396
1397 [line]
1398 border = "#4c566a"
1399
1400 [category]
1401 one = "#bf616a"
1402 two = "#a3be8c"
1403 three = "#81a1c1"
1404 four = "#ebcb8b"
1405 five = "#b48ead"
1406 six = "#88c0d0"
1407 "##
1408 }
1409
1410 #[test]
1411 fn extract_colors_reads_intent_sections() {
1412 let table: toml::Table = nord_toml().parse().unwrap();
1413 let colors = extract_colors(&table);
1414 assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
1415 assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
1416 assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
1417 assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
1418 assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
1419 assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
1420 assert_eq!(colors.len(), 19);
1421 }
1422
1423 #[test]
1424 fn resolve_base_intents_passthrough() {
1425 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1426 let t = resolve(&theme);
1427 assert_eq!(t.hex("surface-page"), Some("#2e3440"));
1428 assert_eq!(t.hex("content"), Some("#d8dee9")); // content.primary -> content
1429 assert_eq!(t.hex("content-muted"), Some("#616e88"));
1430 assert_eq!(t.hex("action"), Some("#81a1c1"));
1431 assert_eq!(t.hex("danger"), Some("#bf616a"));
1432 assert_eq!(t.hex("border"), Some("#4c566a"));
1433 assert_eq!(t.hex("category-five"), Some("#b48ead"));
1434 }
1435
1436 #[test]
1437 fn resolve_derived_intents() {
1438 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1439 let t = resolve(&theme);
1440 let action = Rgb::from_hex("#81a1c1").unwrap();
1441 let page = Rgb::from_hex("#2e3440").unwrap();
1442 let _ = page;
1443 assert_eq!(
1444 t.hex("action-hover").unwrap(),
1445 lighten(action, 0.05).to_hex()
1446 );
1447 assert_eq!(
1448 t.hex("content-on-action").unwrap(),
1449 readable_on(action).to_hex()
1450 );
1451 assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
1452 assert_eq!(t.hex("hover-surface"), Some("#434c5e")); // = surface.sunken
1453 // Pruned by the usage audit (0 consumers): action-active, the *-surface
1454 // tints, selection, row-stripe. Apps that need them derive inline via
1455 // the shared mix().
1456 assert!(t.hex("action-active").is_none());
1457 assert!(t.hex("danger-surface").is_none());
1458 assert!(t.hex("selection").is_none());
1459 assert!(t.hex("row-stripe").is_none());
1460 }
1461
1462 #[test]
1463 fn resolve_overlay_is_dark_translucent_scrim() {
1464 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1465 let t = resolve(&theme);
1466 let overlay = t.hex("overlay").unwrap();
1467 assert!(
1468 overlay.starts_with("rgba("),
1469 "overlay is translucent: {overlay}"
1470 );
1471 assert!(overlay.ends_with(", 0.5)"));
1472 // The scrim tone is anchored very dark regardless of theme.
1473 let inner = overlay
1474 .trim_start_matches("rgba(")
1475 .trim_end_matches(", 0.5)");
1476 let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
1477 let scrim = Rgb {
1478 r: parts[0],
1479 g: parts[1],
1480 b: parts[2],
1481 };
1482 assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
1483 }
1484
1485 #[test]
1486 fn resolve_drops_non_hex_base_intent() {
1487 // A base intent that isn't a hex color must never reach the resolved
1488 // token set (it would otherwise be inlined verbatim into a <style>
1489 // block). Skipped like a missing intent; valid siblings survive.
1490 let theme = parse_theme_str(
1491 "x",
1492 "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
1493 false,
1494 )
1495 .unwrap();
1496 let t = resolve(&theme);
1497 assert!(
1498 t.hex("surface-page").is_none(),
1499 "non-hex base intent leaked"
1500 );
1501 assert_eq!(t.hex("content").unwrap(), "#111111");
1502 // The injected markup appears in no resolved value.
1503 assert!(!t.intents.values().any(|v| v.contains('<')));
1504 }
1505
1506 #[test]
1507 fn resolve_skips_derived_when_source_missing() {
1508 // No [action] => no action-derived tokens.
1509 let theme = parse_theme_str(
1510 "x",
1511 "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
1512 false,
1513 )
1514 .unwrap();
1515 let t = resolve(&theme);
1516 assert!(t.hex("action").is_none());
1517 assert!(t.hex("action-hover").is_none());
1518 assert!(t.hex("selection").is_none());
1519 assert_eq!(
1520 t.hex("border-strong").unwrap(),
1521 darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()
1522 );
1523 }
1524
1525 #[test]
1526 fn rgb_accessor_for_native_consumers() {
1527 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1528 let t = resolve(&theme);
1529 assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1)));
1530 assert_eq!(t.rgb("nonexistent"), None);
1531 }
1532
1533 // ---- css emit ----
1534
1535 #[test]
1536 fn intent_css_vars_wraps_root_and_includes_tokens() {
1537 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
1538 let css = intent_css_vars(&resolve(&theme));
1539 assert!(css.starts_with(":root {\n"));
1540 assert!(css.contains(" --surface-page: #2e3440;\n"));
1541 assert!(css.contains(" --danger: #bf616a;\n"));
1542 assert!(css.contains(" --action-hover: "));
1543 assert!(css.trim_end().ends_with('}'));
1544 }
1545
1546 // ---- loading / fs ----
1547
1548 #[test]
1549 fn load_and_resolve_round_trip() {
1550 let dir = tempfile::tempdir().unwrap();
1551 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
1552 let dirs = vec![(dir.path().to_path_buf(), false)];
1553 let t = load_semantic(&dirs, "nord").unwrap();
1554 assert_eq!(t.meta.name, "Nord");
1555 assert_eq!(t.hex("action"), Some("#81a1c1"));
1556 }
1557
1558 #[test]
1559 fn load_theme_rejects_invalid_id() {
1560 assert!(load_theme(&[], "../evil").is_err());
1561 }
1562
1563 fn meta(id: &str, variant: &str) -> ThemeMeta {
1564 ThemeMeta {
1565 id: id.to_string(),
1566 name: id.to_string(),
1567 variant: variant.to_string(),
1568 is_custom: false,
1569 }
1570 }
1571
1572 fn defaults() -> ThemeDefaults {
1573 ThemeDefaults::new("flatwhite", "nord")
1574 }
1575
1576 // The three the shipped themes actually declare.
1577 #[test]
1578 fn every_shipped_variant_parses() {
1579 assert_eq!(Variant::parse("light"), Some(Variant::Light));
1580 assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
1581 assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
1582 assert_eq!(Variant::parse("sepia"), None);
1583 }
1584
1585 // parse_meta already defaults a *missing* variant to dark, so an
1586 // unrecognized one reading as light would have the crate disagreeing with
1587 // itself. alloy_tui did exactly that before this existed.
1588 #[test]
1589 fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
1590 assert_eq!(Variant::from("sepia"), Variant::Dark);
1591 assert_eq!(Variant::from(""), Variant::Dark);
1592
1593 let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
1594 assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
1595 }
1596
1597 #[test]
1598 fn a_selection_round_trips_through_any_store() {
1599 for (stored, expect) in [
1600 (Some("system"), ThemeSelection::Follow),
1601 (None, ThemeSelection::Follow),
1602 (Some(""), ThemeSelection::Follow),
1603 (Some(" "), ThemeSelection::Follow),
1604 (Some("nord"), ThemeSelection::Fixed("nord".into())),
1605 ] {
1606 let parsed = ThemeSelection::parse(stored);
1607 assert_eq!(parsed, expect, "{stored:?}");
1608 assert_eq!(
1609 ThemeSelection::parse(Some(parsed.as_str())),
1610 expect,
1611 "what is written reads back as what was meant",
1612 );
1613 }
1614 }
1615
1616 // Nothing saved is follow-the-system, which is what Balanced Breakfast
1617 // expressed as an absent value and GoingsOn as a sentinel. Both are now the
1618 // same thing.
1619 #[test]
1620 fn nothing_chosen_yet_is_follow() {
1621 assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
1622 }
1623
1624 #[test]
1625 fn a_fixed_selection_wins_when_its_theme_is_installed() {
1626 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
1627 let fixed = ThemeSelection::Fixed("nord".into());
1628 assert_eq!(
1629 fixed.resolve(Variant::Light, &defaults(), &available),
1630 "nord",
1631 "a chosen theme is not overridden by the ambient mode",
1632 );
1633 }
1634
1635 // Themes are deletable in three of the four apps. Handing back an id that
1636 // will fail to load only moves the error somewhere less helpful.
1637 #[test]
1638 fn a_fixed_selection_whose_theme_is_gone_falls_back() {
1639 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
1640 let fixed = ThemeSelection::Fixed("deleted".into());
1641 assert_eq!(
1642 fixed.resolve(Variant::Light, &defaults(), &available),
1643 "flatwhite",
1644 );
1645 }
1646
1647 #[test]
1648 fn follow_picks_the_apps_default_for_the_ambient_mode() {
1649 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
1650 let follow = ThemeSelection::Follow;
1651 assert_eq!(
1652 follow.resolve(Variant::Dark, &defaults(), &available),
1653 "nord",
1654 );
1655 assert_eq!(
1656 follow.resolve(Variant::Light, &defaults(), &available),
1657 "flatwhite",
1658 );
1659 }
1660
1661 // The behaviour Balanced Breakfast could not have: following the system
1662 // into a theme the user installed, when the app's own default is absent.
1663 #[test]
1664 fn follow_uses_any_installed_theme_of_the_right_variant() {
1665 let available = [meta("solarized-light", "light"), meta("mine", "dark")];
1666 assert_eq!(
1667 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
1668 "mine",
1669 "the app's `nord` is not installed, but a dark theme is",
1670 );
1671 }
1672
1673 // Always returns something: an app with no theme directory gets the id it
1674 // ships with, and the load error it would have had anyway.
1675 #[test]
1676 fn an_empty_catalog_still_names_the_apps_default() {
1677 assert_eq!(
1678 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]),
1679 "nord",
1680 );
1681 }
1682
1683 #[test]
1684 fn high_contrast_falls_back_to_dark_unless_named() {
1685 let plain = defaults();
1686 assert_eq!(plain.for_variant(Variant::HighContrast), "nord");
1687
1688 let named = defaults().high_contrast("sharp");
1689 assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
1690 }
1691
1692 // The bug this builder exists to prevent: the Alloy console pushed the
1693 // user's directory first under a comment reading "highest precedence
1694 // first", when both consumers of this vector resolve last-wins. A custom
1695 // theme lost to the packaged one of the same id.
1696 #[test]
1697 fn the_users_own_themes_outrank_everything() {
1698 let root = tempfile::tempdir().unwrap();
1699 let make = |name: &str| {
1700 let dir = root.path().join(name);
1701 std::fs::create_dir_all(&dir).unwrap();
1702 dir
1703 };
1704 let (bundled, system, custom) = (make("bundled"), make("system"), make("custom"));
1705
1706 let dirs = ThemeDirs::new()
1707 .custom(Some(custom.clone()))
1708 .bundled(Some(bundled.clone()))
1709 .system(Some(system.clone()))
1710 .build();
1711
1712 assert_eq!(
1713 dirs,
1714 vec![(bundled, false), (system, false), (custom.clone(), true)],
1715 "lowest precedence first, whatever order the tiers were added in",
1716 );
1717 assert!(dirs.last().unwrap().1, "only the user's tier is custom");
1718
1719 // And the ordering means what the consumers think it means.
1720 for dir in dirs.iter().map(|(dir, _)| dir) {
1721 std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap();
1722 }
1723 assert_eq!(
1724 find_theme_path(&dirs, "shared").unwrap().0,
1725 custom.join("shared.toml"),
1726 "the user's copy is the one that loads",
1727 );
1728 }
1729
1730 #[test]
1731 fn a_directory_that_does_not_exist_is_dropped() {
1732 let root = tempfile::tempdir().unwrap();
1733 let real = root.path().join("real");
1734 std::fs::create_dir_all(&real).unwrap();
1735
1736 let dirs = ThemeDirs::new()
1737 .bundled(Some(root.path().join("nope")))
1738 .system(None)
1739 .custom(Some(real.clone()))
1740 .build();
1741
1742 assert_eq!(dirs, vec![(real, true)]);
1743 }
1744
1745 // A Tauri app has two bundled tiers: the resource dir in production and the
1746 // tree build.rs materialized for a dev run with no resource dir.
1747 #[test]
1748 fn more_than_one_bundled_tier_is_allowed() {
1749 let root = tempfile::tempdir().unwrap();
1750 let (first, second) = (root.path().join("a"), root.path().join("b"));
1751 std::fs::create_dir_all(&first).unwrap();
1752 std::fs::create_dir_all(&second).unwrap();
1753
1754 let dirs = ThemeDirs::new()
1755 .bundled(Some(first.clone()))
1756 .bundled(Some(second.clone()))
1757 .build();
1758 assert_eq!(dirs, vec![(first, false), (second, false)]);
1759 }
1760
1761 #[test]
1762 fn list_themes_from_dirs_finds_toml_files() {
1763 let dir = tempfile::tempdir().unwrap();
1764 fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
1765 fs::write(dir.path().join("x.txt"), "ignored").unwrap();
1766 let dirs = vec![(dir.path().to_path_buf(), false)];
1767 let themes = list_themes_from_dirs(&dirs);
1768 assert_eq!(themes.len(), 1);
1769 assert_eq!(themes[0].id, "t");
1770 }
1771
1772 #[test]
1773 fn find_theme_path_reverse_priority() {
1774 let d1 = tempfile::tempdir().unwrap();
1775 let d2 = tempfile::tempdir().unwrap();
1776 fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap();
1777 fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap();
1778 let dirs = vec![
1779 (d1.path().to_path_buf(), false),
1780 (d2.path().to_path_buf(), true),
1781 ];
1782 let (path, is_custom) = find_theme_path(&dirs, "s").unwrap();
1783 assert!(is_custom);
1784 assert_eq!(path, d2.path().join("s.toml"));
1785 }
1786
1787 #[test]
1788 fn import_theme_valid_and_rejects_empty() {
1789 let src_dir = tempfile::tempdir().unwrap();
1790 let custom_dir = tempfile::tempdir().unwrap();
1791
1792 let good = src_dir.path().join("my-theme.toml");
1793 fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
1794 let meta = import_theme(&good, custom_dir.path()).unwrap();
1795 assert_eq!(meta.id, "my-theme");
1796 assert!(custom_dir.path().join("my-theme.toml").exists());
1797
1798 let empty = src_dir.path().join("empty.toml");
1799 fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
1800 assert!(import_theme(&empty, custom_dir.path()).is_err());
1801 }
1802
1803 #[test]
1804 fn import_theme_rejects_invalid_toml() {
1805 let src_dir = tempfile::tempdir().unwrap();
1806 let custom_dir = tempfile::tempdir().unwrap();
1807 let src = src_dir.path().join("bad.toml");
1808 fs::write(&src, "this is not [valid toml [[[").unwrap();
1809 assert!(import_theme(&src, custom_dir.path()).is_err());
1810 }
1811
1812 #[test]
1813 fn delete_theme_removes_and_guards() {
1814 let custom = tempfile::tempdir().unwrap();
1815 let path = custom.path().join("doomed.toml");
1816 fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
1817 delete_theme(custom.path(), "doomed").unwrap();
1818 assert!(!path.exists());
1819 assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
1820 assert!(delete_theme(custom.path(), "ghost").is_err());
1821 }
1822
1823 #[test]
1824 fn export_theme_copies_file() {
1825 let src_dir = tempfile::tempdir().unwrap();
1826 let dest_dir = tempfile::tempdir().unwrap();
1827 let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
1828 fs::write(src_dir.path().join("e.toml"), content).unwrap();
1829 let dirs = vec![(src_dir.path().to_path_buf(), false)];
1830 let dest = dest_dir.path().join("out.toml");
1831 export_theme(&dirs, "e", &dest).unwrap();
1832 assert_eq!(fs::read_to_string(&dest).unwrap(), content);
1833 assert!(export_theme(&dirs, "missing", &dest).is_err());
1834 }
1835
1836 #[test]
1837 fn load_theme_preview_returns_role_swatches() {
1838 let dir = tempfile::tempdir().unwrap();
1839 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
1840 let dirs = vec![(dir.path().to_path_buf(), false)];
1841 let p = load_theme_preview(&dirs, "nord").unwrap();
1842 assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page
1843 assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary
1844 assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary
1845 assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border
1846 }
1847
1848 #[test]
1849 fn bundled_themes_dir_resolves_to_shipped_themes() {
1850 // The crate ships its themes, so this must resolve in-tree and the
1851 // Akari defaults the console falls back to must be present.
1852 let dir = bundled_themes_dir().expect("makeover ships a themes/ directory");
1853 assert!(dir.join("akari-dawn.toml").is_file());
1854 assert!(dir.join("akari-night.toml").is_file());
1855 }
1856
1857 #[test]
1858 fn every_theme_is_accounted_for_in_third_party_notices() {
1859 // Attribution is a redistribution obligation, not a nicety: adding a
1860 // theme without a notice entry silently ships someone's work
1861 // uncredited. Fail here instead.
1862 let notices = std::fs::read_to_string(
1863 Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"),
1864 )
1865 .expect("THIRD-PARTY-NOTICES.md must exist");
1866 let missing: Vec<&str> = embedded_themes()
1867 .map(|(id, _)| id)
1868 .filter(|id| !notices.contains(*id))
1869 .collect();
1870 assert!(
1871 missing.is_empty(),
1872 "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}"
1873 );
1874 }
1875
1876 #[test]
1877 fn adapted_themes_carry_inline_attribution() {
1878 // Each adapted file must name its upstream in-file, so the credit
1879 // survives someone copying a single .toml out of the crate.
1880 const ORIGINALS: [&str; 5] = [
1881 "makenotwork",
1882 "goingson",
1883 "audiofiles",
1884 "high-contrast",
1885 "neobrute",
1886 ];
1887 for (id, source) in embedded_themes() {
1888 if ORIGINALS.contains(&id) {
1889 continue;
1890 }
1891 assert!(
1892 source.contains("adapted from"),
1893 "adapted theme `{id}` is missing its inline attribution header"
1894 );
1895 }
1896 }
1897
1898 #[test]
1899 fn embedded_themes_match_the_directory() {
1900 // The embedded copy and themes/ are two views of one source. If they
1901 // ever disagree, path-based and path-free consumers render different
1902 // theme sets, which is exactly the drift shipping the data was meant
1903 // to prevent.
1904 let dir = bundled_themes_dir().unwrap();
1905 let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
1906 .unwrap()
1907 .filter_map(|e| {
1908 let path = e.ok()?.path();
1909 if path.extension()? != "toml" {
1910 return None;
1911 }
1912 Some(path.file_stem()?.to_str()?.to_string())
1913 })
1914 .collect();
1915 let mut embedded: Vec<String> = embedded_themes().map(|(id, _)| id.to_string()).collect();
1916 on_disk.sort();
1917 embedded.sort();
1918 assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/");
1919 }
1920
1921 #[test]
1922 fn every_embedded_theme_parses() {
1923 // Guards the path-free consumers (MNW server, the Tauri build steps)
1924 // the same way every_shipped_theme_loads guards the path-based ones.
1925 let mut count = 0;
1926 for (id, source) in embedded_themes() {
1927 parse_theme_str(id, source, false)
1928 .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}"));
1929 count += 1;
1930 }
1931 assert!(count >= 30, "expected the full theme set, got {count}");
1932 }
1933
1934 #[test]
1935 fn every_shipped_theme_loads() {
1936 // Guards the data, not just the loader: a malformed or truncated
1937 // .toml in themes/ is a shipping bug, and it should fail here rather
1938 // than at a user's first launch.
1939 let dir = bundled_themes_dir().unwrap();
1940 let dirs = vec![(dir.clone(), false)];
1941 let themes = list_themes_from_dirs(&dirs);
1942 assert!(
1943 themes.len() >= 30,
1944 "expected the full theme set, got {}",
1945 themes.len()
1946 );
1947 for meta in &themes {
1948 load_theme(&dirs, &meta.id)
1949 .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
1950 }
1951 }
1952 }
1953