Skip to main content

max / makeover

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