Skip to main content

makeover/
lib.rs

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