//! Shared theme loading + intent resolution for TOML-based theme files. //! //! Used by GoingsOn, Balanced Breakfast (Tauri apps), audiofiles (egui), and the //! MNW web server. Themes are authored by **intent** ("human design"): colors are //! declared by role (surface / content / action / status / line / category), not //! by hue. This crate is the single place that resolves an authored theme into a //! full set of intent tokens — including the derived interactive states //! (hover/active/selection/row-stripe/contrast) that each app used to recompute //! itself — and emits them as CSS variables or RGB tuples. //! //! Theme file shape: //! ```text //! [meta] //! name = "Nord" //! variant = "dark" # or "light" //! //! [surface] # container backgrounds by role/elevation //! page = "#2e3440"; raised = "#3b4252"; sunken = "#434c5e"; overlay = "#3b4252" //! //! [content] # the ink. Its emphasis steps are derived, not authored: //! primary = "#d8dee9" # `content-secondary` and `content-muted` are tonal //! # steps of this toward `surface.page`. See `Emphasis`. //! //! [action] # interactive / brand color //! primary = "#81a1c1" //! //! [status] # state semantics //! danger = "#bf616a"; success = "#a3be8c"; warning = "#ebcb8b"; info = "#88c0d0" //! //! [line] //! border = "#4c566a" //! //! [category] # distinct decorative colors for tags/badges/charts //! one = "#bf616a"; two = "#a3be8c"; three = "#81a1c1" //! four = "#ebcb8b"; five = "#b48ead"; six = "#88c0d0" //! ``` // Color-space math: single-letter channel names (r/g/b/l/m/s) and the published // high-precision OKLab/sRGB matrix constants are the domain vocabulary here. #![allow(clippy::many_single_char_names, clippy::unreadable_literal)] use serde::Serialize; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; /// The color sections an authored theme may declare. pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"]; /// Theme metadata parsed from the `[meta]` section. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeMeta { pub id: String, pub name: String, pub variant: String, pub is_custom: bool, } /// A loaded theme: metadata plus the authored colors, flattened to dotted keys /// (e.g. `"surface.page"`, `"status.danger"`, `"category.one"`). #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeColors { pub meta: ThemeMeta, pub colors: HashMap, } // ============================================================================ // Color math — perceptual (OKLab) derivations + WCAG contrast. // // Interactive states (hover/active/selection/surfaces) are derived in OKLab so // equal steps look equal across every theme's hues (Ottosson 2020; the modern // CIELAB). Text-on-color is picked by the WCAG 2.x contrast ratio, not a naive // luminance threshold, so the choice actually meets AA where achievable. // This is the single source of truth shared by every product. // ============================================================================ /// An sRGB color. Hex round-trips losslessly. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Rgb { pub r: u8, pub g: u8, pub b: u8, } impl Rgb { /// Parse `#rgb` or `#rrggbb` (case-insensitive). Returns `None` otherwise. pub fn from_hex(s: &str) -> Option { let h = s.strip_prefix('#')?; let (r, g, b) = match h.len() { 6 => ( u8::from_str_radix(&h[0..2], 16).ok()?, u8::from_str_radix(&h[2..4], 16).ok()?, u8::from_str_radix(&h[4..6], 16).ok()?, ), 3 => { let d = |c: &str| u8::from_str_radix(c, 16).ok().map(|v| v * 17); (d(&h[0..1])?, d(&h[1..2])?, d(&h[2..3])?) } _ => return None, }; Some(Rgb { r, g, b }) } /// Lowercase `#rrggbb`. pub fn to_hex(self) -> String { format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) } pub fn tuple(self) -> (u8, u8, u8) { (self.r, self.g, self.b) } } /// A color in OKLab (perceptually uniform): `l` lightness in [0,1], `a`/`b` opponent axes. #[derive(Clone, Copy, Debug)] pub struct Oklab { pub l: f32, pub a: f32, pub b: f32, } fn srgb_to_linear(c: u8) -> f32 { let c = c as f32 / 255.0; if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } } fn linear_to_srgb(c: f32) -> u8 { let c = c.clamp(0.0, 1.0); let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; (v * 255.0).round().clamp(0.0, 255.0) as u8 } impl Rgb { /// Convert to OKLab (Ottosson's sRGB matrices). /// /// The matrix coefficients are quoted at their published precision so they /// can be diffed against the reference. `f32` rounds them at compile time; /// truncating the literals would only make them harder to check. #[allow(clippy::excessive_precision)] pub fn to_oklab(self) -> Oklab { let (r, g, b) = ( srgb_to_linear(self.r), srgb_to_linear(self.g), srgb_to_linear(self.b), ); let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt()); Oklab { l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, } } /// Convert from OKLab back to the nearest in-gamut sRGB. /// /// Published precision, as in [`Rgb::to_oklab`]. #[allow(clippy::excessive_precision)] pub fn from_oklab(c: Oklab) -> Rgb { let l_ = c.l + 0.3963377774 * c.a + 0.2158037573 * c.b; let m_ = c.l - 0.1055613458 * c.a - 0.0638541728 * c.b; let s_ = c.l - 0.0894841775 * c.a - 1.2914855480 * c.b; let (l, m, s) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); Rgb { r: linear_to_srgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), g: linear_to_srgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), b: linear_to_srgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s), } } } /// WCAG 2.x relative luminance of an sRGB color. fn rel_luminance(c: Rgb) -> f32 { 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) } /// WCAG 2.x contrast ratio between two colors, in [1, 21]. pub fn wcag_contrast(a: Rgb, b: Rgb) -> f32 { let (la, lb) = (rel_luminance(a), rel_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } /// Pick black or white for legible text on `bg`, by the higher WCAG contrast /// ratio (so the choice meets AA wherever the background allows it). pub fn readable_on(bg: Rgb) -> Rgb { let white = Rgb { r: 255, g: 255, b: 255, }; let black = Rgb { r: 0, g: 0, b: 0 }; if wcag_contrast(white, bg) >= wcag_contrast(black, bg) { white } else { black } } /// Shift OKLab lightness by `delta` (perceptually uniform). Positive lightens. pub fn lighten(c: Rgb, delta: f32) -> Rgb { let mut lab = c.to_oklab(); lab.l = (lab.l + delta).clamp(0.0, 1.0); Rgb::from_oklab(lab) } /// Shift OKLab lightness down by `delta` (perceptually uniform). pub fn darken(c: Rgb, delta: f32) -> Rgb { lighten(c, -delta) } /// Interpolate between `a` and `b` by `t` in [0,1] in OKLab (perceptual blend). pub fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { let (x, y) = (a.to_oklab(), b.to_oklab()); Rgb::from_oklab(Oklab { l: x.l + (y.l - x.l) * t, a: x.a + (y.a - x.a) * t, b: x.b + (y.b - x.b) * t, }) } // ============================================================================ // Tonal steps // ============================================================================ /// How far a tonal step sits from the token it is a step of. /// /// The named ratios. [`tonal`] is the same operation with the number written /// out, and this is the small set of steps the vocabulary has agreed on, so a /// consumer asking for "the muted form of this" names it rather than picking a /// number and disagreeing with the next consumer to pick one. /// /// The rule these encode, stated as the three-tone convention: /// /// | step | what it means | /// |------|---------------| /// | [`Full`](Self::Full) | active, emphasised, the thing itself | /// | [`Secondary`](Self::Secondary) | inactive but usable: a control that still answers | /// | [`Muted`](Self::Muted) | inert: disabled, or not a control at all | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Emphasis { /// The token unchanged. Full, /// One step back. Still legible as content, not competing with `Full`. Secondary, /// Two steps back. Present, and saying it is not the point. Muted, } impl Emphasis { /// The fraction of the way to the ground this step travels. /// /// Both numbers are the shipped corpus' own, not invented: across the 31 /// bundled themes, hand-authored `content.secondary` sat at a median 0.115 /// of the way from `content.primary` to `surface.page`, and `content.muted` /// at 0.424. So the derivation reproduces what theme authors converged on /// by eye, and the themes that move are the ones that were off the cluster. #[must_use] pub const fn ratio(self) -> f32 { match self { Self::Full => 0.0, Self::Secondary => 0.12, Self::Muted => 0.42, } } /// The suffix a derived token takes, or `None` for the token itself. /// /// `content` + [`Muted`](Self::Muted) is `content-muted`, which is the /// naming every consumer already spells by hand. Grouping a family this way /// is what makes `danger-muted` or `action-secondary` nameable without a /// second table saying what they mean. #[must_use] pub const fn suffix(self) -> Option<&'static str> { match self { Self::Full => None, Self::Secondary => Some("-secondary"), Self::Muted => Some("-muted"), } } /// The derived token key for `token` at this step. #[must_use] pub fn token(self, token: &str) -> String { match self.suffix() { Some(suffix) => format!("{token}{suffix}"), None => token.to_string(), } } } /// The contrast a tonal step must clear against the token it is a step of. /// /// A ratio says how far to travel, not how far that lands, and the two are the /// same thing only when the base has room to travel in. Across the bundled /// themes a derived `content.secondary` sits between 1.21 and 1.44 of its ink; /// the exceptions were the two themes whose ink is `#000000`, where OKLab L is /// 0, 12 percent of nothing is nothing, and the sRGB transfer curve compresses /// what is left into a 3/255 move. So the floor is the bottom of the band the /// healthy themes already reach, and a theme inside it does not move. /// /// Deliberately below [`DISTINCT`]: that is the 3:1 two *areas* need to read as /// separate, and an emphasis step is one voice quieter rather than a second /// region. Asking 3:1 of it would flatten every theme's ramp into three widely /// spaced greys. pub const STEP_FLOOR: f32 = 1.21; /// A tonal step of `base`, `ratio` of the way toward the `ground` it is read /// against. /// /// The numerical form of [`Emphasis`], for a consumer that wants a step the /// named set does not have. `ratio` is clamped to [0,1]: past 1 the step is no /// longer a step of `base` but a colour beyond the ground, which is a different /// operation wearing this one's name. /// /// # Toward the ground, not toward grey /// /// A tonal step is a *reduction in contrast against what it is read on*, so it /// interpolates toward the surface rather than desaturating or lightening. That /// is why it takes two colours: lightening is wrong on a light theme and /// darkening is wrong on a dark one, and mixing toward the ground is correct on /// both without asking which theme this is. It is also why the ground is a /// parameter rather than assumed — text in a well is read against the well. /// /// # It composes /// /// Two steps toward the same ground are one step toward that ground, since /// OKLab interpolation is linear: `tonal(tonal(c, g, a), g, b)` is /// `tonal(c, g, a + b - a*b)`. So a family can be derived recursively — the /// muted form of a secondary is a well-defined colour and not a compounding /// error — and re-deriving a token that was already derived is stable rather /// than a slow slide into the background. #[must_use] pub fn tonal(base: Rgb, ground: Rgb, ratio: f32) -> Rgb { mix(base, ground, ratio.clamp(0.0, 1.0)) } /// A named tonal step of `base` against the `ground` it is read on. /// /// [`tonal`] with [`Emphasis::ratio`], and the form to reach for: the two /// spellings of "muted" a pair of consumers pick independently are the drift /// this replaces. #[must_use] pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb { tonal(base, ground, emphasis.ratio()) } // ============================================================================ // Low-color terminals // ============================================================================ /// The 16 colors an ANSI terminal addresses by index, in the PC/VGA /// arrangement the Linux console and most emulators start from. /// /// 0-7 are the normal colors and 8-15 the bright ones. Index 7 is a light gray /// rather than white, which is the entry a themed surface usually lands on, and /// index 15 is the true white. /// /// Emulators let the user repaint all sixteen, so this is the standard /// arrangement rather than a promise about any one terminal. The Linux console /// keeps it, which is the case that matters: a console app cannot fall back to /// 24-bit color there. pub const ANSI_16: [Rgb; 16] = [ Rgb { r: 0x00, g: 0x00, b: 0x00, }, Rgb { r: 0xaa, g: 0x00, b: 0x00, }, Rgb { r: 0x00, g: 0xaa, b: 0x00, }, Rgb { r: 0xaa, g: 0x55, b: 0x00, }, Rgb { r: 0x00, g: 0x00, b: 0xaa, }, Rgb { r: 0xaa, g: 0x00, b: 0xaa, }, Rgb { r: 0x00, g: 0xaa, b: 0xaa, }, Rgb { r: 0xaa, g: 0xaa, b: 0xaa, }, Rgb { r: 0x55, g: 0x55, b: 0x55, }, Rgb { r: 0xff, g: 0x55, b: 0x55, }, Rgb { r: 0x55, g: 0xff, b: 0x55, }, Rgb { r: 0xff, g: 0xff, b: 0x55, }, Rgb { r: 0x55, g: 0x55, b: 0xff, }, Rgb { r: 0xff, g: 0x55, b: 0xff, }, Rgb { r: 0x55, g: 0xff, b: 0xff, }, Rgb { r: 0xff, g: 0xff, b: 0xff, }, ]; /// The 256 colors an xterm-compatible terminal addresses by index, so that /// entry `i` is what the terminal paints for `38;5;i`. /// /// Three regions, and they are not equally trustworthy. 0-15 are the [`ANSI_16`] /// system colors, which every emulator lets the user repaint. 16-231 are a /// 6x6x6 RGB cube and 232-255 a 24-step gray ramp, and those 240 are fixed. /// /// So a color whose whole job is to be told apart from another should quantize /// against [`ANSI_240`] rather than against this table: a match landing in the /// low sixteen is a match against a color the user may have moved. pub const ANSI_256: [Rgb; 256] = build_ansi_256(); /// The fixed region of [`ANSI_256`]: the 6x6x6 cube and the gray ramp, without /// the sixteen repaintable system colors. /// /// Quantizing against this returns an index into *this* slice; add /// [`ANSI_240_OFFSET`] to get the index the terminal wants. pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1; /// What to add to an [`ANSI_240`] index to get an [`ANSI_256`] one. pub const ANSI_240_OFFSET: usize = 16; /// The twelve chromatic ANSI slots, as the intents that paint them. /// /// Indexed 1-6 and 9-14. The hues do not depend on whether the theme is light /// or dark, since red is the theme's danger tone either way, which is exactly /// why the four achromatic slots are not in this table. /// /// Lifted from Alloy's `skelgen` on 2026-07-31, which had folded three /// disagreeing hand-maintained copies into one and is the reason the /// arrangement is trusted. It moved here so a program that paints its own /// palette at runtime, rather than reading a generated config, resolves the /// same slots. Slot 14 was the one the copies disagreed on and is /// `category.six`, which both the Linux console table and the retired /// `vtrgb.py` had. const CHROMATIC: [(usize, &str); 12] = [ (1, "status.danger"), (2, "status.success"), (3, "status.warning"), (4, "status.info"), (5, "category.five"), (6, "category.six"), (9, "action.primary"), // bright red, the theme's warm accent (10, "status.success"), (11, "status.warning"), (12, "status.info"), (13, "category.five"), (14, "category.six"), ]; /// The four achromatic slots, 0, 7, 8 and 15, which invert with the theme. /// /// These are the slots a naive table gets wrong. ANSI 0 is "black" and 7 is /// "white", but what a terminal wants there is *the darkest tone* and *the /// lightest tone*, and which intent that is flips with the theme's polarity. A /// light theme's darkest tone is its ink; a dark theme's is its deepest /// surface. Pinning slot 0 to `content.primary` reads correctly on a light /// theme and hands a dark one a pale cream as "black". /// /// Slot 7 is a surface and not a text tone, because it is what a program with /// no way to name anything else draws its container on: a greeter's login card /// is a light card on the darker field slot 0 paints. /// /// Anything that is not `dark`, including `high-contrast`, follows the light /// anchors. fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> { let dark = variant == "dark"; Some(match (index, dark) { (0, false) => "content.primary", // darkest text tone (0, true) => "surface.sunken", // darkest surface (7, false) => "surface.raised", // the login card (7, true) => "content.secondary", // a readable light tone (8, _) => "content.muted", // muted chrome, either way (15, false) => "surface.overlay", // lightest surface (15, true) => "content.primary", // lightest text tone _ => return None, }) } /// The authored intent painting ANSI slot `index` under a theme of `variant`, /// as a dotted key into [`ThemeColors::colors`]. /// /// `None` for an index outside 0-15. Every slot in range resolves, so a caller /// that has the intent can fill all sixteen. /// /// This is what makes a bare console, a terminal emulator and a generated /// config agree on what red means. They disagreed for as long as each kept its /// own table. #[must_use] pub fn ansi_intent(index: usize, variant: &str) -> Option<&'static str> { achromatic_slot(index, variant).or_else(|| { CHROMATIC .iter() .find(|(slot, _)| *slot == index) .map(|(_, intent)| *intent) }) } const fn build_ansi_256() -> [Rgb; 256] { let mut table = [Rgb { r: 0, g: 0, b: 0 }; 256]; let mut i = 0; while i < 16 { table[i] = ANSI_16[i]; i += 1; } // The cube's six levels are not evenly spaced. The step from black to the // first is more than twice any later one, which is xterm's arrangement // rather than a choice available here, and it is why the darkest tones a // theme can reach on 256 colors come from the gray ramp instead. const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255]; let mut r = 0; while r < 6 { let mut g = 0; while g < 6 { let mut b = 0; while b < 6 { table[16 + 36 * r + 6 * g + b] = Rgb { r: LEVELS[r], g: LEVELS[g], b: LEVELS[b], }; b += 1; } g += 1; } r += 1; } // 8 to 238 in steps of 10. Neither end is black or white; both of those are // in the cube, so the ramp is 24 steps of gray between them rather than 24 // steps of the whole range. let mut k = 0; while k < 24 { let v = 8 + 10 * k as u8; table[232 + k as usize] = Rgb { r: v, g: v, b: v }; k += 1; } table } /// The contrast ratio two colors must clear to read as separate areas. /// /// WCAG 2.x asks 3:1 of user interface components and graphics, which is what /// a border, a rule, or a focus ring is. Text wants more, and a caller drawing /// text can ask for more by checking [`wcag_contrast`] itself. pub const DISTINCT: f32 = 3.0; /// Perceptual distance between two colors, for choosing the closest of a set. fn oklab_distance(a: Rgb, b: Rgb) -> f32 { let (x, y) = (a.to_oklab(), b.to_oklab()); ((x.l - y.l).powi(2) + (x.a - y.a).powi(2) + (x.b - y.b).powi(2)).sqrt() } /// Index of the entry in `palette` that looks most like `c`. /// /// OKLab distance rather than distance in sRGB, for the same reason [`mix`] /// interpolates there: sRGB's numbers are not spaced the way seeing is, so a /// nearest match computed in it picks visibly wrong entries in the mid tones. /// /// # Panics /// /// If `palette` is empty. pub fn quantize(c: Rgb, palette: &[Rgb]) -> usize { assert!(!palette.is_empty(), "a palette needs at least one color"); let mut best = 0; let mut best_distance = f32::INFINITY; for (index, entry) in palette.iter().enumerate() { let distance = oklab_distance(c, *entry); if distance < best_distance { best = index; best_distance = distance; } } best } /// Index of the entry in `palette` closest to `fg` that still reads against /// `bg`. /// /// [`quantize`] answers about one color at a time, and two colors that differ /// can quantize to the same entry: a themed page and a border drawn on it are /// often a few steps apart in a 24-bit theme and land together on a 16-color /// terminal, leaving one flat area where there was a frame. Alloy's console /// showed exactly this, and it is not a contrived pairing: a light page and the /// mid-tone border derived from it both land on index 7. /// /// So the background is quantized first, because what the border must be /// distinguished from is the entry the terminal will actually paint, not the /// color the theme asked for. Then the nearest entry to `fg` clearing /// [`DISTINCT`] against it wins. When nothing clears it, the entry that gets /// furthest does: at that point the palette cannot honor the design, and the /// most legible approximation beats the closest invisible one. /// /// Only for colors whose whole job is to be told apart from their background. /// Applied to every token it would push a deliberately quiet one until it /// shouted. /// /// # Panics /// /// If `palette` is empty. pub fn quantize_against(fg: Rgb, bg: Rgb, palette: &[Rgb]) -> usize { assert!(!palette.is_empty(), "a palette needs at least one color"); let shown = palette[quantize(bg, palette)]; let mut order: Vec = (0..palette.len()).collect(); order.sort_by(|a, b| { oklab_distance(fg, palette[*a]).total_cmp(&oklab_distance(fg, palette[*b])) }); order .iter() .copied() .find(|index| wcag_contrast(palette[*index], shown) >= DISTINCT) .unwrap_or_else(|| { order .iter() .copied() .max_by(|a, b| { wcag_contrast(palette[*a], shown).total_cmp(&wcag_contrast(palette[*b], shown)) }) .expect("the palette is not empty") }) } // ============================================================================ // Intent resolution // ============================================================================ /// Base intents: (TOML dotted source key, canonical token key). The token key /// is the CSS-var stem (`--{token}`) and the `rgb()` lookup key. /// /// Read straight from the loaded theme, which is not quite the same as read /// from the file: `content.secondary` and `content.muted` are tonal steps of /// `content.primary` and are filled in at load by [`derive_tonal_steps`], so /// they arrive here already computed and take this path like any other. pub const BASE_INTENTS: &[(&str, &str)] = &[ ("surface.page", "surface-page"), ("surface.raised", "surface-raised"), ("surface.sunken", "surface-sunken"), ("surface.overlay", "surface-overlay"), ("content.primary", "content"), ("content.secondary", "content-secondary"), ("content.muted", "content-muted"), ("action.primary", "action"), ("status.danger", "danger"), ("status.success", "success"), ("status.warning", "warning"), ("status.info", "info"), ("line.border", "border"), ("category.one", "category-one"), ("category.two", "category-two"), ("category.three", "category-three"), ("category.four", "category-four"), ("category.five", "category-five"), ("category.six", "category-six"), ]; /// A fully resolved intent layer: every token key → concrete `#rrggbb`. /// Includes both authored base intents and the computed derived intents. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SemanticTokens { pub meta: ThemeMeta, /// token-key → resolved hex. Stable, deterministic ordering. pub intents: BTreeMap, } impl SemanticTokens { /// Resolved hex for a token key, if present. pub fn hex(&self, key: &str) -> Option<&str> { self.intents.get(key).map(String::as_str) } /// Resolved RGB tuple for a token key (for egui / native consumers). /// /// `None` for a translucent token. Two intents are emitted as `rgba(...)` /// rather than hex, `overlay` and `elevation`, and dropping the alpha would /// hand a native consumer an opaque near-black where it asked for a scrim. /// Those want [`rgba`](Self::rgba). pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> { self.intents .get(key) .and_then(|h| Rgb::from_hex(h)) .map(Rgb::tuple) } /// Resolved RGBA tuple for a token key, alpha as 0-255. /// /// Reads both spellings, so a caller that does not care whether an intent /// happens to be translucent can use this for everything: an opaque token /// comes back at 255. /// /// It exists because a CSS consumer can take `rgba(...)` as a string /// straight out of [`hex`](Self::hex) and a native one cannot. Without it /// the two translucent intents are reachable from a stylesheet and from /// nowhere else, which is the coupling deriving in the crate was meant to /// avoid. pub fn rgba(&self, key: &str) -> Option<(u8, u8, u8, u8)> { let value = self.intents.get(key)?; if let Some(rgb) = Rgb::from_hex(value) { let (r, g, b) = rgb.tuple(); return Some((r, g, b, 255)); } let inner = value.strip_prefix("rgba(")?.strip_suffix(')')?; let mut parts = inner.split(',').map(str::trim); let r = parts.next()?.parse().ok()?; let g = parts.next()?.parse().ok()?; let b = parts.next()?.parse().ok()?; let alpha: f32 = parts.next()?.parse().ok()?; if parts.next().is_some() || !(0.0..=1.0).contains(&alpha) { return None; } Some((r, g, b, (alpha * 255.0).round() as u8)) } } /// Resolve an authored theme into the full intent token set. /// /// 1. Copy each present base intent from the authored colors. /// 2. Compute the derived interactive states from the base intents, using the /// same math the apps used to apply individually (so output is identical). /// /// Each derived token is emitted only when its source intents exist, mirroring /// the skip-missing behavior of the rest of the crate. pub fn resolve(theme: &ThemeColors) -> SemanticTokens { let mut intents: BTreeMap = BTreeMap::new(); // 1. Base intents (authored). Copy only values that parse as a hex color and // re-emit them in canonical `#rrggbb` form, so an authored value can never // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined // raw into a `\"\n[content]\nprimary = \"#111111\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!( t.hex("surface-page").is_none(), "non-hex base intent leaked" ); assert_eq!(t.hex("content").unwrap(), "#111111"); // The injected markup appears in no resolved value. assert!(!t.intents.values().any(|v| v.contains('<'))); } #[test] fn resolve_skips_derived_when_source_missing() { // No [action] => no action-derived tokens. let theme = parse_theme_str( "x", "[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n", false, ) .unwrap(); let t = resolve(&theme); assert!(t.hex("action").is_none()); assert!(t.hex("action-hover").is_none()); assert!(t.hex("selection").is_none()); assert_eq!( t.hex("border-strong").unwrap(), darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex() ); } #[test] fn rgb_accessor_for_native_consumers() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let t = resolve(&theme); assert_eq!(t.rgb("action"), Some((0x81, 0xa1, 0xc1))); assert_eq!(t.rgb("nonexistent"), None); } // ---- css emit ---- #[test] fn intent_css_vars_wraps_root_and_includes_tokens() { let theme = parse_theme_str("nord", nord_toml(), false).unwrap(); let css = intent_css_vars(&resolve(&theme)); assert!(css.starts_with(":root {\n")); assert!(css.contains(" --surface-page: #2e3440;\n")); assert!(css.contains(" --danger: #bf616a;\n")); assert!(css.contains(" --action-hover: ")); assert!(css.trim_end().ends_with('}')); } // ---- typography ---- #[test] fn the_font_tokens_are_two_names_and_each_ends_at_a_system_generic() { let css = typography_css_vars(); assert!(css.starts_with(":root {\n")); assert!(css.contains(" --font-mono: \"Quasi Mono\", monospace;\n")); assert!(css.contains(" --font-sans: \"Quasi Body\", sans-serif;\n")); // Layer 2 is one hop and no further. A third entry in either stack is // the shape the standard exists to delete: a chain nobody can predict // the metrics of, which is what `--font-sans: -apple-system, // BlinkMacSystemFont, 'Segoe UI', Roboto, ...` was in three apps. for stack in [FONT_MONO, FONT_SANS] { assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop"); } // Two tokens, and no others. `--font-body`, `--font-heading` and // `--font-display` are gone or out of scope; a token appearing here // is a fifth answer to a question that has two. assert_eq!(css.matches("--font-").count(), 2); } #[test] fn every_font_face_names_the_weight_range_because_the_mono_opens_at_200() { let css = font_face_css("/static/fonts"); assert_eq!(css.matches("@font-face").count(), 2); assert!(css.contains("src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");")); assert!(css.contains("src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");")); // The trap. Atkinson Hyperlegible Mono's default instance is // ExtraLight and the cut keeps the axis, so a `@font-face` that omits // the range draws the whole UI at 200. assert_eq!(css.matches("font-weight: 200 800;").count(), 2); // The families have to be exactly what the tokens ask for, or the // stack falls through to the generic and the face is dead weight. for family in [FONT_MONO, FONT_SANS] { let quoted = family.split(',').next().unwrap(); assert!(css.contains(&format!("font-family: {quoted};"))); } } #[test] fn a_trailing_slash_on_the_base_url_does_not_double_it() { assert_eq!(font_face_css("fonts/"), font_face_css("fonts")); assert!(font_face_css("fonts").contains("url(\"fonts/QuasiMono.woff2\")")); } // ---- typography, layer 0 ---- /// The live case: MNW's Young Serif, which reached the page through a /// hand-maintained `@font-face` and a `--font-heading` nothing else knew /// about. fn young_serif() -> FontOverride { FontOverride::new(FontSlot::Display, "\"Young Serif\", serif") .with_face(FontFace::new("Young Serif", ["ysrf.woff2", "ysrf.ttf"])) } #[test] fn the_house_layer_alone_is_exactly_what_the_free_functions_emit() { let t = Typography::house("/static/fonts"); assert_eq!(t.font_face_css(), font_face_css("/static/fonts")); assert_eq!(t.css_vars(), typography_css_vars()); } #[test] fn an_unoverridden_display_slot_defines_no_token_at_all() { // Not "defined empty": undefined, so the consumer's own fallback in // `var(--font-display, …)` renders. The MNW embeds depend on it. let t = Typography::house("fonts"); assert!(!t.css_vars().contains("--font-display")); assert_eq!(t.resolve(FontSlot::Display), None); assert_eq!(t.css_vars().matches("--font-").count(), 2); } #[test] fn an_override_adds_its_token_and_its_face_without_touching_the_house_two() { let t = Typography::house("/static/fonts").with_override(young_serif()); assert!( t.css_vars() .contains(" --font-display: \"Young Serif\", serif;\n") ); assert!( t.css_vars() .contains(" --font-mono: \"Quasi Mono\", monospace;\n") ); assert!( t.css_vars() .contains(" --font-sans: \"Quasi Body\", sans-serif;\n") ); assert_eq!(t.resolve(FontSlot::Display), Some("\"Young Serif\", serif")); let faces = t.font_face_css(); assert_eq!(faces.matches("@font-face").count(), 3); assert!(faces.contains("font-family: \"Young Serif\";")); assert!(faces.contains("url(\"/static/fonts/ysrf.woff2\") format(\"woff2\")")); assert!(faces.contains("url(\"/static/fonts/ysrf.ttf\") format(\"truetype\")")); // The house faces still come first, so a product face never shadows a // slot it did not claim. assert!(faces.find("Quasi Mono").unwrap() < faces.find("Young Serif").unwrap()); } #[test] fn overriding_mono_or_sans_replaces_the_house_stack_rather_than_adding_to_it() { // Nobody wants this today. A layer that only permits overriding the // slot nobody describes is the exemption restated, not a layer. let t = Typography::house("fonts").with_override(FontOverride::new( FontSlot::Mono, "\"Departure Mono\", monospace", )); assert!( t.css_vars() .contains(" --font-mono: \"Departure Mono\", monospace;\n") ); assert!(!t.css_vars().contains("Quasi Mono")); assert_eq!(t.css_vars().matches("--font-").count(), 2); } #[test] #[should_panic(expected = "--font-display is overridden twice")] fn a_second_override_of_one_slot_is_a_vocabulary_bug_and_says_so() { let _ = Typography::house("fonts") .with_override(young_serif()) .with_override(FontOverride::new(FontSlot::Display, "\"Reglo\", serif")); } #[test] fn an_absolute_source_is_taken_as_written_and_a_relative_one_joins_the_base() { let t = Typography::house("/static/fonts").with_override( FontOverride::new(FontSlot::Display, "\"Reglo\", serif").with_face( FontFace::new( "Reglo", ["Reglo-Bold.woff2", "https://cdn.example/reglo.woff2"], ) .weight("700"), ), ); let faces = t.font_face_css(); assert!(faces.contains("url(\"/static/fonts/Reglo-Bold.woff2\")")); assert!(faces.contains("url(\"https://cdn.example/reglo.woff2\")")); assert!(faces.contains(" font-weight: 700;\n")); } #[test] fn the_house_tier_renders_byte_for_byte_what_the_format_string_wrote() { // The house faces became `FontFace` values so they could be read as // well as emitted. Nothing about the sheet was meant to move, and this // is the whole of that claim: the literal the format string produced. let expected = concat!( "@font-face {\n", " font-family: \"Quasi Mono\";\n", " src: url(\"/static/fonts/QuasiMono.woff2\") format(\"woff2\");\n", " font-weight: 200 800;\n", " font-style: normal;\n", " font-display: swap;\n", "}\n\n", "@font-face {\n", " font-family: \"Quasi Body\";\n", " src: url(\"/static/fonts/QuasiBody.woff2\") format(\"woff2\");\n", " font-weight: 200 800;\n", " font-style: normal;\n", " font-display: swap;\n", "}\n\n", ); assert_eq!(font_face_css("/static/fonts"), expected); } #[test] fn a_house_slot_names_the_same_family_in_its_stack_and_in_its_face() { // The family is spelled once as a bare name and once inside a CSS // stack, because a stack cannot be built from a const at compile time. // A face whose family is not the one the stack names loads and is // never asked for. for (slot, family) in [ (FontSlot::Mono, HOUSE_MONO_FAMILY), (FontSlot::Sans, HOUSE_SANS_FAMILY), ] { let face = slot.house_face().expect("a house slot has a house face"); assert_eq!(face.family(), family); assert!( slot.house_default() .unwrap() .starts_with(&format!("\"{family}\"")) ); } } #[test] fn the_brand_tier_has_no_house_face_the_way_it_has_no_house_stack() { assert!(FontSlot::Display.house_face().is_none()); assert!(FontSlot::Display.house_default().is_none()); } #[test] fn a_face_loading_renderer_reads_the_family_and_the_source_off_the_layer() { // The egui case, which has no stylesheet in the path at all: the // renderer registers the file under a name, and the name has to be // the one the stack spells or the two halves drift. let t = Typography::house("fonts").with_override( FontOverride::new(FontSlot::Display, "\"RecursiveMono\", monospace").with_face( FontFace::new("RecursiveMono", ["RecursiveMonoLnrSt-Bold.ttf"]).weight("700"), ), ); let [face] = t.faces(FontSlot::Display) else { panic!("the display slot ships exactly one face"); }; assert_eq!(face.family(), "RecursiveMono"); assert_eq!(face.sources(), ["RecursiveMonoLnrSt-Bold.ttf"]); assert!( t.resolve(FontSlot::Display) .unwrap() .contains(face.family()) ); } #[test] fn a_source_is_read_back_unresolved_because_only_the_css_wants_a_url() { let t = Typography::house("/static/fonts").with_override(young_serif()); assert_eq!( t.faces(FontSlot::Display)[0].sources(), ["ysrf.woff2", "ysrf.ttf"] ); // The same face, joined to the base, in the sheet. assert!( t.font_face_css() .contains("url(\"/static/fonts/ysrf.woff2\")") ); } #[test] fn a_slot_nobody_overrode_ships_no_faces_including_the_house_two() { let t = Typography::house("fonts").with_override(young_serif()); assert!(t.faces(FontSlot::Mono).is_empty()); assert!(t.faces(FontSlot::Sans).is_empty()); assert_eq!(t.faces(FontSlot::Display).len(), 1); } #[test] fn an_unrecognised_extension_gets_no_format_hint_rather_than_a_guessed_one() { let t = Typography::house("fonts").with_override( FontOverride::new(FontSlot::Display, "\"Odd\", serif") .with_face(FontFace::new("Odd", ["odd.eot"])), ); assert!(t.font_face_css().contains("url(\"fonts/odd.eot\");")); assert!(!t.font_face_css().contains("format(\"eot\")")); } #[test] fn css_puts_the_faces_before_the_tokens_that_name_them() { let t = Typography::house("fonts").with_override(young_serif()); let css = t.css(); assert!(css.starts_with("@font-face")); assert!(css.find("@font-face").unwrap() < css.find(":root").unwrap()); } // ---- loading / fs ---- #[test] fn load_and_resolve_round_trip() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let t = load_semantic(&dirs, "nord").unwrap(); assert_eq!(t.meta.name, "Nord"); assert_eq!(t.hex("action"), Some("#81a1c1")); } #[test] fn load_theme_rejects_invalid_id() { assert!(load_theme(&[], "../evil").is_err()); } fn meta(id: &str, variant: &str) -> ThemeMeta { ThemeMeta { id: id.to_string(), name: id.to_string(), variant: variant.to_string(), is_custom: false, } } fn defaults() -> ThemeDefaults { ThemeDefaults::new("flatwhite", "nord") } // The three the shipped themes actually declare. #[test] fn every_shipped_variant_parses() { assert_eq!(Variant::parse("light"), Some(Variant::Light)); assert_eq!(Variant::parse("dark"), Some(Variant::Dark)); assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast)); assert_eq!(Variant::parse("sepia"), None); } // parse_meta already defaults a *missing* variant to dark, so an // unrecognized one reading as light would have the crate disagreeing with // itself. alloy_tui did exactly that before this existed. #[test] fn an_unrecognized_variant_reads_the_way_a_missing_one_does() { assert_eq!(Variant::from("sepia"), Variant::Dark); assert_eq!(Variant::from(""), Variant::Dark); let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap(); assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark); } #[test] fn a_selection_round_trips_through_any_store() { for (stored, expect) in [ (Some("system"), ThemeSelection::Follow), (None, ThemeSelection::Follow), (Some(""), ThemeSelection::Follow), (Some(" "), ThemeSelection::Follow), (Some("nord"), ThemeSelection::Fixed("nord".into())), ] { let parsed = ThemeSelection::parse(stored); assert_eq!(parsed, expect, "{stored:?}"); assert_eq!( ThemeSelection::parse(Some(parsed.as_str())), expect, "what is written reads back as what was meant", ); } } // Nothing saved is follow-the-system, which is what Balanced Breakfast // expressed as an absent value and GoingsOn as a sentinel. Both are now the // same thing. #[test] fn nothing_chosen_yet_is_follow() { assert_eq!(ThemeSelection::default(), ThemeSelection::Follow); } #[test] fn a_fixed_selection_wins_when_its_theme_is_installed() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let fixed = ThemeSelection::Fixed("nord".into()); assert_eq!( fixed.resolve(Variant::Light, &defaults(), &available), "nord", "a chosen theme is not overridden by the ambient mode", ); } // Themes are deletable in three of the four apps. Handing back an id that // will fail to load only moves the error somewhere less helpful. #[test] fn a_fixed_selection_whose_theme_is_gone_falls_back() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let fixed = ThemeSelection::Fixed("deleted".into()); assert_eq!( fixed.resolve(Variant::Light, &defaults(), &available), "flatwhite", ); } #[test] fn follow_picks_the_apps_default_for_the_ambient_mode() { let available = [meta("nord", "dark"), meta("flatwhite", "light")]; let follow = ThemeSelection::Follow; assert_eq!( follow.resolve(Variant::Dark, &defaults(), &available), "nord", ); assert_eq!( follow.resolve(Variant::Light, &defaults(), &available), "flatwhite", ); } // The behaviour Balanced Breakfast could not have: following the system // into a theme the user installed, when the app's own default is absent. #[test] fn follow_uses_any_installed_theme_of_the_right_variant() { let available = [meta("solarized-light", "light"), meta("mine", "dark")]; assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), "mine", "the app's `nord` is not installed, but a dark theme is", ); } // Always returns something: an app with no theme directory gets the id it // ships with, and the load error it would have had anyway. #[test] fn an_empty_catalog_still_names_the_apps_default() { assert_eq!( ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]), "nord", ); } #[test] fn high_contrast_falls_back_to_dark_unless_named() { let plain = defaults(); assert_eq!(plain.for_variant(Variant::HighContrast), "nord"); let named = defaults().high_contrast("sharp"); assert_eq!(named.for_variant(Variant::HighContrast), "sharp"); } // The bug this builder exists to prevent: the Alloy console pushed the // user's directory first under a comment reading "highest precedence // first", when both consumers of this vector resolve last-wins. A custom // theme lost to the packaged one of the same id. #[test] fn the_users_own_themes_outrank_everything() { let root = tempfile::tempdir().unwrap(); let make = |name: &str| { let dir = root.path().join(name); std::fs::create_dir_all(&dir).unwrap(); dir }; let (bundled, system, custom) = (make("bundled"), make("system"), make("custom")); let dirs = ThemeDirs::new() .custom(Some(custom.clone())) .bundled(Some(bundled.clone())) .system(Some(system.clone())) .build(); assert_eq!( dirs, vec![(bundled, false), (system, false), (custom.clone(), true)], "lowest precedence first, whatever order the tiers were added in", ); assert!(dirs.last().unwrap().1, "only the user's tier is custom"); // And the ordering means what the consumers think it means. for dir in dirs.iter().map(|(dir, _)| dir) { std::fs::write(dir.join("shared.toml"), "[meta]\nname = \"x\"\n").unwrap(); } assert_eq!( find_theme_path(&dirs, "shared").unwrap().0, custom.join("shared.toml"), "the user's copy is the one that loads", ); } #[test] fn a_directory_that_does_not_exist_is_dropped() { let root = tempfile::tempdir().unwrap(); let real = root.path().join("real"); std::fs::create_dir_all(&real).unwrap(); let dirs = ThemeDirs::new() .bundled(Some(root.path().join("nope"))) .system(None) .custom(Some(real.clone())) .build(); assert_eq!(dirs, vec![(real, true)]); } // A Tauri app has two bundled tiers: the resource dir in production and the // tree build.rs materialized for a dev run with no resource dir. #[test] fn more_than_one_bundled_tier_is_allowed() { let root = tempfile::tempdir().unwrap(); let (first, second) = (root.path().join("a"), root.path().join("b")); std::fs::create_dir_all(&first).unwrap(); std::fs::create_dir_all(&second).unwrap(); let dirs = ThemeDirs::new() .bundled(Some(first.clone())) .bundled(Some(second.clone())) .build(); assert_eq!(dirs, vec![(first, false), (second, false)]); } #[test] fn list_themes_from_dirs_finds_toml_files() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap(); fs::write(dir.path().join("x.txt"), "ignored").unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let themes = list_themes_from_dirs(&dirs); assert_eq!(themes.len(), 1); assert_eq!(themes[0].id, "t"); } #[test] fn find_theme_path_reverse_priority() { let d1 = tempfile::tempdir().unwrap(); let d2 = tempfile::tempdir().unwrap(); fs::write(d1.path().join("s.toml"), "[meta]\n").unwrap(); fs::write(d2.path().join("s.toml"), "[meta]\n").unwrap(); let dirs = vec![ (d1.path().to_path_buf(), false), (d2.path().to_path_buf(), true), ]; let (path, is_custom) = find_theme_path(&dirs, "s").unwrap(); assert!(is_custom); assert_eq!(path, d2.path().join("s.toml")); } #[test] fn import_theme_valid_and_rejects_empty() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let good = src_dir.path().join("my-theme.toml"); fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap(); let meta = import_theme(&good, custom_dir.path()).unwrap(); assert_eq!(meta.id, "my-theme"); assert!(custom_dir.path().join("my-theme.toml").exists()); let empty = src_dir.path().join("empty.toml"); fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap(); assert!(import_theme(&empty, custom_dir.path()).is_err()); } #[test] fn import_theme_rejects_invalid_toml() { let src_dir = tempfile::tempdir().unwrap(); let custom_dir = tempfile::tempdir().unwrap(); let src = src_dir.path().join("bad.toml"); fs::write(&src, "this is not [valid toml [[[").unwrap(); assert!(import_theme(&src, custom_dir.path()).is_err()); } #[test] fn delete_theme_removes_and_guards() { let custom = tempfile::tempdir().unwrap(); let path = custom.path().join("doomed.toml"); fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap(); delete_theme(custom.path(), "doomed").unwrap(); assert!(!path.exists()); assert!(delete_theme(custom.path(), "../etc/passwd").is_err()); assert!(delete_theme(custom.path(), "ghost").is_err()); } #[test] fn export_theme_copies_file() { let src_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap(); let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n"; fs::write(src_dir.path().join("e.toml"), content).unwrap(); let dirs = vec![(src_dir.path().to_path_buf(), false)]; let dest = dest_dir.path().join("out.toml"); export_theme(&dirs, "e", &dest).unwrap(); assert_eq!(fs::read_to_string(&dest).unwrap(), content); assert!(export_theme(&dirs, "missing", &dest).is_err()); } #[test] fn load_theme_preview_returns_role_swatches() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap(); let dirs = vec![(dir.path().to_path_buf(), false)]; let p = load_theme_preview(&dirs, "nord").unwrap(); assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border } #[test] fn bundled_themes_dir_resolves_to_shipped_themes() { // The crate ships its themes, so this must resolve in-tree and the // Akari defaults the console falls back to must be present. let dir = bundled_themes_dir().expect("makeover ships a themes/ directory"); assert!(dir.join("akari-dawn.toml").is_file()); assert!(dir.join("akari-night.toml").is_file()); } #[test] fn every_theme_is_accounted_for_in_third_party_notices() { // Attribution is a redistribution obligation, not a nicety: adding a // theme without a notice entry silently ships someone's work // uncredited. Fail here instead. let notices = std::fs::read_to_string( Path::new(env!("CARGO_MANIFEST_DIR")).join("THIRD-PARTY-NOTICES.md"), ) .expect("THIRD-PARTY-NOTICES.md must exist"); let missing: Vec<&str> = embedded_themes() .map(|(id, _)| id) .filter(|id| !notices.contains(*id)) .collect(); assert!( missing.is_empty(), "themes missing from THIRD-PARTY-NOTICES.md: {missing:?}" ); } #[test] fn adapted_themes_carry_inline_attribution() { // Each adapted file must name its upstream in-file, so the credit // survives someone copying a single .toml out of the crate. const ORIGINALS: [&str; 5] = [ "makenotwork", "goingson", "audiofiles", "high-contrast", "neobrute", ]; for (id, source) in embedded_themes() { if ORIGINALS.contains(&id) { continue; } assert!( source.contains("adapted from"), "adapted theme `{id}` is missing its inline attribution header" ); } } #[test] fn embedded_themes_match_the_directory() { // The embedded copy and themes/ are two views of one source. If they // ever disagree, path-based and path-free consumers render different // theme sets, which is exactly the drift shipping the data was meant // to prevent. let dir = bundled_themes_dir().unwrap(); let mut on_disk: Vec = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| { let path = e.ok()?.path(); if path.extension()? != "toml" { return None; } Some(path.file_stem()?.to_str()?.to_string()) }) .collect(); let mut embedded: Vec = embedded_themes().map(|(id, _)| id.to_string()).collect(); on_disk.sort(); embedded.sort(); assert_eq!(embedded, on_disk, "embedded theme set drifted from themes/"); } #[test] fn every_embedded_theme_parses() { // Guards the path-free consumers (MNW server, the Tauri build steps) // the same way every_shipped_theme_loads guards the path-based ones. let mut count = 0; for (id, source) in embedded_themes() { parse_theme_str(id, source, false) .unwrap_or_else(|e| panic!("embedded theme `{id}` failed to parse: {e}")); count += 1; } assert!(count >= 30, "expected the full theme set, got {count}"); } #[test] fn every_shipped_theme_loads() { // Guards the data, not just the loader: a malformed or truncated // .toml in themes/ is a shipping bug, and it should fail here rather // than at a user's first launch. let dir = bundled_themes_dir().unwrap(); let dirs = vec![(dir.clone(), false)]; let themes = list_themes_from_dirs(&dirs); assert!( themes.len() >= 30, "expected the full theme set, got {}", themes.len() ); for meta in &themes { load_theme(&dirs, &meta.id) .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id)); } } }