#![allow(clippy::many_single_char_names, clippy::unreadable_literal)]
use serde::Serialize;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
pub const COLOR_SECTIONS: &[&str] = &["surface", "content", "action", "status", "line", "category"];
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeMeta {
pub id: String,
pub name: String,
pub variant: String,
pub is_custom: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeColors {
pub meta: ThemeMeta,
pub colors: HashMap<String, String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Rgb {
pub fn from_hex(s: &str) -> Option<Rgb> {
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 })
}
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)
}
}
#[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 {
#[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_,
}
}
#[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),
}
}
}
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)
}
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)
}
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
}
}
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)
}
pub fn darken(c: Rgb, delta: f32) -> Rgb {
lighten(c, -delta)
}
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,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Emphasis {
Full,
Secondary,
Muted,
}
impl Emphasis {
#[must_use]
pub const fn ratio(self) -> f32 {
match self {
Self::Full => 0.0,
Self::Secondary => 0.12,
Self::Muted => 0.42,
}
}
#[must_use]
pub const fn suffix(self) -> Option<&'static str> {
match self {
Self::Full => None,
Self::Secondary => Some("-secondary"),
Self::Muted => Some("-muted"),
}
}
#[must_use]
pub fn token(self, token: &str) -> String {
match self.suffix() {
Some(suffix) => format!("{token}{suffix}"),
None => token.to_string(),
}
}
}
#[must_use]
pub fn tonal(base: Rgb, ground: Rgb, ratio: f32) -> Rgb {
mix(base, ground, ratio.clamp(0.0, 1.0))
}
#[must_use]
pub fn emphasized(base: Rgb, ground: Rgb, emphasis: Emphasis) -> Rgb {
tonal(base, ground, emphasis.ratio())
}
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,
},
];
pub const ANSI_256: [Rgb; 256] = build_ansi_256();
pub const ANSI_240: &[Rgb] = ANSI_256.split_at(16).1;
pub const ANSI_240_OFFSET: usize = 16;
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"), (10, "status.success"),
(11, "status.warning"),
(12, "status.info"),
(13, "category.five"),
(14, "category.six"),
];
fn achromatic_slot(index: usize, variant: &str) -> Option<&'static str> {
let dark = variant == "dark";
Some(match (index, dark) {
(0, false) => "content.primary", (0, true) => "surface.sunken", (7, false) => "surface.raised", (7, true) => "content.secondary", (8, _) => "content.muted", (15, false) => "surface.overlay", (15, true) => "content.primary", _ => return None,
})
}
#[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;
}
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;
}
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
}
pub const DISTINCT: f32 = 3.0;
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()
}
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
}
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<usize> = (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")
})
}
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"),
];
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SemanticTokens {
pub meta: ThemeMeta,
pub intents: BTreeMap<String, String>,
}
impl SemanticTokens {
pub fn hex(&self, key: &str) -> Option<&str> {
self.intents.get(key).map(String::as_str)
}
pub fn rgb(&self, key: &str) -> Option<(u8, u8, u8)> {
self.intents
.get(key)
.and_then(|h| Rgb::from_hex(h))
.map(Rgb::tuple)
}
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))
}
}
pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
let mut intents: BTreeMap<String, String> = BTreeMap::new();
for (src, token) in BASE_INTENTS {
if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
intents.insert((*token).to_string(), rgb.to_hex());
}
}
let get = |m: &BTreeMap<String, String>, k: &str| m.get(k).and_then(|h| Rgb::from_hex(h));
let mut derived: Vec<(String, Rgb)> = Vec::new();
if let Some(action) = get(&intents, "action") {
derived.push(("action-hover".into(), lighten(action, 0.05)));
derived.push(("content-on-action".into(), readable_on(action)));
derived.push(("focus-ring".into(), action));
}
if let Some(page) = get(&intents, "surface-page") {
let mut o = page.to_oklab();
o.l = 0.08;
let s = Rgb::from_oklab(o);
intents.insert(
"overlay".into(),
format!("rgba({}, {}, {}, 0.5)", s.r, s.g, s.b),
);
intents.insert(
"elevation".into(),
format!("rgba({}, {}, {}, 0.18)", s.r, s.g, s.b),
);
}
if let Some(raised) = get(&intents, "surface-raised") {
derived.push(("bevel-light".into(), lighten(raised, 0.14)));
derived.push(("bevel-dark".into(), darken(raised, 0.18)));
if let Some(content) = get(&intents, "content") {
let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
let well = if content_is_darker {
lighten(raised, 0.07)
} else {
darken(raised, 0.09)
};
derived.push(("surface-well".into(), well));
}
}
if let Some(sunken) = get(&intents, "surface-sunken") {
derived.push(("hover-surface".into(), sunken));
}
if let Some(border) = get(&intents, "border") {
derived.push(("border-strong".into(), darken(border, 0.05)));
}
for (token, rgb) in derived {
intents.insert(token, rgb.to_hex());
}
SemanticTokens {
meta: theme.meta.clone(),
intents,
}
}
pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
let mut out = String::new();
for (token, hex) in &tokens.intents {
out.push_str(" --");
out.push_str(token);
out.push_str(": ");
out.push_str(hex);
out.push_str(";\n");
}
out
}
pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
}
pub const FONT_MONO: &str = "\"Quasi Mono\", monospace";
pub const FONT_SANS: &str = "\"Quasi Body\", sans-serif";
pub const WEBFONT_MONO_FILE: &str = "QuasiMono.woff2";
pub const WEBFONT_SANS_FILE: &str = "QuasiBody.woff2";
pub fn typography_css_declarations() -> String {
format!(" --font-mono: {FONT_MONO};\n --font-sans: {FONT_SANS};\n")
}
pub fn typography_css_vars() -> String {
format!(":root {{\n{}}}\n", typography_css_declarations())
}
pub fn font_face_css(base_url: &str) -> String {
use std::fmt::Write as _;
let base = base_url.trim_end_matches('/');
let mut out = String::new();
for (family, file) in [
("Quasi Mono", WEBFONT_MONO_FILE),
("Quasi Body", WEBFONT_SANS_FILE),
] {
let _ = write!(
out,
"@font-face {{\n \
font-family: \"{family}\";\n \
src: url(\"{base}/{file}\") format(\"woff2\");\n \
font-weight: 200 800;\n \
font-style: normal;\n \
font-display: swap;\n\
}}\n\n"
);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontSlot {
Mono,
Sans,
Display,
}
impl FontSlot {
pub const ALL: [FontSlot; 3] = [FontSlot::Mono, FontSlot::Sans, FontSlot::Display];
pub fn token(self) -> &'static str {
match self {
FontSlot::Mono => "--font-mono",
FontSlot::Sans => "--font-sans",
FontSlot::Display => "--font-display",
}
}
pub fn house_default(self) -> Option<&'static str> {
match self {
FontSlot::Mono => Some(FONT_MONO),
FontSlot::Sans => Some(FONT_SANS),
FontSlot::Display => None,
}
}
}
#[derive(Debug, Clone)]
pub struct FontFace {
family: String,
sources: Vec<String>,
weight: Option<String>,
style: Option<String>,
}
impl FontFace {
pub fn new<S: Into<String>>(
family: impl Into<String>,
sources: impl IntoIterator<Item = S>,
) -> Self {
Self {
family: family.into(),
sources: sources.into_iter().map(Into::into).collect(),
weight: None,
style: None,
}
}
#[must_use]
pub fn weight(mut self, weight: impl Into<String>) -> Self {
self.weight = Some(weight.into());
self
}
#[must_use]
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
fn css(&self, base: &str) -> String {
use std::fmt::Write as _;
let src = self
.sources
.iter()
.map(|s| {
let url = if s.starts_with('/') || s.contains("://") {
s.clone()
} else {
format!("{base}/{s}")
};
match font_format(s) {
Some(fmt) => format!("url(\"{url}\") format(\"{fmt}\")"),
None => format!("url(\"{url}\")"),
}
})
.collect::<Vec<_>>()
.join(",\n ");
let mut out = format!(
"@font-face {{\n font-family: \"{}\";\n src: {src};\n",
self.family
);
if let Some(w) = &self.weight {
let _ = writeln!(out, " font-weight: {w};");
}
if let Some(s) = &self.style {
let _ = writeln!(out, " font-style: {s};");
}
out.push_str(" font-display: swap;\n}\n\n");
out
}
}
fn font_format(source: &str) -> Option<&'static str> {
match source.rsplit('.').next()?.to_ascii_lowercase().as_str() {
"woff2" => Some("woff2"),
"woff" => Some("woff"),
"ttf" => Some("truetype"),
"otf" => Some("opentype"),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct FontOverride {
slot: FontSlot,
stack: String,
faces: Vec<FontFace>,
}
impl FontOverride {
pub fn new(slot: FontSlot, stack: impl Into<String>) -> Self {
Self {
slot,
stack: stack.into(),
faces: Vec::new(),
}
}
#[must_use]
pub fn with_face(mut self, face: FontFace) -> Self {
self.faces.push(face);
self
}
pub fn slot(&self) -> FontSlot {
self.slot
}
pub fn stack(&self) -> &str {
&self.stack
}
}
#[derive(Debug, Clone)]
pub struct Typography {
base_url: String,
overrides: Vec<FontOverride>,
}
impl Typography {
pub fn house(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
overrides: Vec::new(),
}
}
#[must_use]
pub fn with_override(mut self, ov: FontOverride) -> Self {
assert!(
!self.overrides.iter().any(|o| o.slot == ov.slot),
"{} is overridden twice; one declaration per product per slot",
ov.slot.token()
);
self.overrides.push(ov);
self
}
pub fn resolve(&self, slot: FontSlot) -> Option<&str> {
self.overrides
.iter()
.find(|o| o.slot == slot)
.map(|o| o.stack.as_str())
.or_else(|| slot.house_default())
}
pub fn font_face_css(&self) -> String {
let base = self.base_url.trim_end_matches('/');
let mut out = font_face_css(base);
for ov in &self.overrides {
for face in &ov.faces {
out.push_str(&face.css(base));
}
}
out
}
pub fn css_declarations(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for slot in FontSlot::ALL {
if let Some(stack) = self.resolve(slot) {
let _ = writeln!(out, " {}: {stack};", slot.token());
}
}
out
}
pub fn css_vars(&self) -> String {
format!(":root {{\n{}}}\n", self.css_declarations())
}
pub fn css(&self) -> String {
format!("{}{}", self.font_face_css(), self.css_vars())
}
}
pub fn validate_theme_id(id: &str) -> Result<(), String> {
if !id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(format!("Invalid theme ID: {id}"));
}
Ok(())
}
pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
let meta = table.get("meta").and_then(|m| m.as_table());
let name = meta
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or(id)
.to_string();
let variant = meta
.and_then(|m| m.get("variant"))
.and_then(|v| v.as_str())
.unwrap_or("dark")
.to_string();
ThemeMeta {
id: id.to_string(),
name,
variant,
is_custom,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
Light,
Dark,
HighContrast,
}
impl Variant {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Variant::Light => "light",
Variant::Dark => "dark",
Variant::HighContrast => "high-contrast",
}
}
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"light" => Some(Variant::Light),
"dark" => Some(Variant::Dark),
"high-contrast" => Some(Variant::HighContrast),
_ => None,
}
}
}
impl std::fmt::Display for Variant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&str> for Variant {
fn from(raw: &str) -> Self {
Variant::parse(raw).unwrap_or(Variant::Dark)
}
}
impl ThemeMeta {
#[must_use]
pub fn kind(&self) -> Variant {
Variant::from(self.variant.as_str())
}
}
pub const FOLLOW: &str = "system";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ThemeSelection {
#[default]
Follow,
Fixed(String),
}
impl ThemeSelection {
#[must_use]
pub fn parse(raw: Option<&str>) -> Self {
match raw.map(str::trim) {
None | Some("" | FOLLOW) => ThemeSelection::Follow,
Some(id) => ThemeSelection::Fixed(id.to_string()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
ThemeSelection::Follow => FOLLOW,
ThemeSelection::Fixed(id) => id,
}
}
#[must_use]
pub fn resolve(
&self,
ambient: Variant,
defaults: &ThemeDefaults,
available: &[ThemeMeta],
) -> String {
let installed = |id: &str| available.iter().any(|meta| meta.id == id);
if let ThemeSelection::Fixed(id) = self
&& installed(id)
{
return id.clone();
}
let preferred = defaults.for_variant(ambient);
if installed(preferred) {
return preferred.to_string();
}
available
.iter()
.find(|meta| meta.kind() == ambient)
.map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
}
}
impl std::fmt::Display for ThemeSelection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
pub struct ThemeDefaults {
light: String,
dark: String,
high_contrast: Option<String>,
}
impl ThemeDefaults {
pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
Self {
light: light.into(),
dark: dark.into(),
high_contrast: None,
}
}
#[must_use]
pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
self.high_contrast = Some(id.into());
self
}
#[must_use]
pub fn for_variant(&self, variant: Variant) -> &str {
match variant {
Variant::Light => &self.light,
Variant::Dark => &self.dark,
Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ThemeDirs {
bundled: Vec<PathBuf>,
system: Vec<PathBuf>,
custom: Option<PathBuf>,
}
impl ThemeDirs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn bundled(mut self, dir: Option<PathBuf>) -> Self {
self.bundled.extend(dir);
self
}
#[must_use]
pub fn system(mut self, dir: Option<PathBuf>) -> Self {
self.system.extend(dir);
self
}
#[must_use]
pub fn custom(mut self, dir: Option<PathBuf>) -> Self {
self.custom = dir;
self
}
#[must_use]
pub fn build(self) -> Vec<(PathBuf, bool)> {
let mut dirs = Vec::new();
for dir in self.bundled.into_iter().chain(self.system) {
if dir.is_dir() {
dirs.push((dir, false));
}
}
if let Some(dir) = self.custom
&& dir.is_dir()
{
dirs.push((dir, true));
}
dirs
}
}
pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
let mut colors = HashMap::new();
for section in COLOR_SECTIONS {
if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
for (key, val) in sect {
if let Some(color) = val.as_str() {
colors.insert(format!("{section}.{key}"), color.to_string());
}
}
}
}
derive_tonal_steps(&mut colors);
colors
}
pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
let (Some(ink), Some(page)) = (ink, page) else {
return;
};
for (key, step) in [
("content.secondary", Emphasis::Secondary),
("content.muted", Emphasis::Muted),
] {
colors.insert(key.to_string(), emphasized(ink, page, step).to_hex());
}
}
pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
for (dir, is_custom) in dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let table: toml::Table = match content.parse() {
Ok(t) => t,
Err(_) => continue,
};
seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
}
}
let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
themes.sort_by(|a, b| a.name.cmp(&b.name));
themes
}
pub fn find_theme_path(dirs: &[(PathBuf, bool)], id: &str) -> Option<(PathBuf, bool)> {
let filename = format!("{id}.toml");
for (dir, is_custom) in dirs.iter().rev() {
let path = dir.join(&filename);
if path.is_file() {
return Some((path, *is_custom));
}
}
None
}
pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
validate_theme_id(id)?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
let meta = parse_meta(id, &table, is_custom);
let colors = extract_colors(&table);
Ok(ThemeColors { meta, colors })
}
pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
validate_theme_id(id)?;
let (path, is_custom) =
find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
let meta = parse_meta(id, &table, is_custom);
let colors = extract_colors(&table);
Ok(ThemeColors { meta, colors })
}
pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
Ok(resolve(&load_theme(dirs, id)?))
}
pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
let content = std::fs::read_to_string(source_path)
.map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
let has_colors = COLOR_SECTIONS
.iter()
.any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
if !has_colors {
return Err(format!(
"Theme file must have at least one color section ({})",
COLOR_SECTIONS.join(", ")
));
}
let id = source_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or("Invalid file name")?
.to_string();
validate_theme_id(&id)?;
std::fs::create_dir_all(custom_dir)
.map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
let dest = custom_dir.join(format!("{id}.toml"));
std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
Ok(parse_meta(&id, &table, true))
}
pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
validate_theme_id(id)?;
let path = custom_dir.join(format!("{id}.toml"));
if !path.is_file() {
return Err(format!("Custom theme '{id}' not found"));
}
std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemePreview {
pub meta: ThemeMeta,
pub background: Option<String>,
pub foreground: Option<String>,
pub accent: Option<String>,
pub border: Option<String>,
}
fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
table
.get(section)
.and_then(|s| s.as_table())
.and_then(|s| s.get(key))
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
validate_theme_id(id)?;
let (path, is_custom) =
find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
Ok(ThemePreview {
meta: parse_meta(id, &table, is_custom),
background: color_at(&table, "surface", "page"),
foreground: color_at(&table, "content", "primary"),
accent: color_at(&table, "action", "primary"),
border: color_at(&table, "line", "border"),
})
}
pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
validate_theme_id(id)?;
let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
Ok(())
}
static EMBEDDED: include_dir::Dir<'static> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/themes");
pub fn embedded_themes() -> impl Iterator<Item = (&'static str, &'static str)> {
EMBEDDED.files().filter_map(|file| {
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
return None;
}
let id = path.file_stem()?.to_str()?;
Some((id, file.contents_utf8()?))
})
}
pub fn bundled_themes_dir() -> Option<PathBuf> {
let themes = Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
if themes.is_dir() { Some(themes) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn validate_theme_id_alphanumeric() {
assert!(validate_theme_id("darkmode").is_ok());
assert!(validate_theme_id("Theme123").is_ok());
}
#[test]
fn validate_theme_id_hyphens_underscores() {
assert!(validate_theme_id("dark-mode").is_ok());
assert!(validate_theme_id("my_theme_v2").is_ok());
}
#[test]
fn validate_theme_id_rejects_path_traversal() {
assert!(validate_theme_id("../etc/passwd").is_err());
assert!(validate_theme_id("foo/bar").is_err());
assert!(validate_theme_id("theme.toml").is_err());
}
#[test]
fn the_ansi_palette_is_sixteen_distinct_colors() {
let mut seen: Vec<(u8, u8, u8)> = ANSI_16.iter().map(|c| c.tuple()).collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), 16);
}
#[test]
fn every_ansi_slot_names_an_intent_on_either_polarity() {
for variant in ["light", "dark", "high-contrast"] {
for index in 0..16 {
assert!(
ansi_intent(index, variant).is_some(),
"slot {index} unanswered on {variant}"
);
}
assert_eq!(ansi_intent(16, variant), None);
}
}
#[test]
fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
for id in ["akari-dawn", "akari-night"] {
let theme = bundled(id);
let slot = |i: usize| -> Rgb {
let key = ansi_intent(i, &theme.meta.variant).expect("in range");
Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
};
assert!(
rel_luminance(slot(0)) < rel_luminance(slot(15)),
"{id}: ANSI 0 {} should be darker than ANSI 15 {}",
slot(0).to_hex(),
slot(15).to_hex(),
);
}
}
#[test]
fn the_container_slot_and_the_text_slot_stay_legible() {
for id in ["akari-dawn", "akari-night"] {
let theme = bundled(id);
let slot = |i: usize| -> Rgb {
let key = ansi_intent(i, &theme.meta.variant).expect("in range");
Rgb::from_hex(theme.colors.get(key).expect("theme carries it")).expect("valid hex")
};
let contrast = wcag_contrast(slot(0), slot(7));
assert!(contrast >= 4.5, "{id}: ANSI 0 on ANSI 7 is {contrast:.2}:1");
}
}
#[test]
fn the_chromatic_slots_do_not_vary_with_polarity() {
for index in [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14] {
assert_eq!(
ansi_intent(index, "light"),
ansi_intent(index, "dark"),
"slot {index} moved with polarity"
);
}
}
fn bundled(id: &str) -> ThemeColors {
let dir = bundled_themes_dir().expect("makeover ships its themes");
load_theme(&[(dir, false)], id).expect("the akari pair ships")
}
#[test]
fn quantize_picks_the_obvious_entry() {
let black = Rgb { r: 0, g: 0, b: 0 };
let white = Rgb {
r: 255,
g: 255,
b: 255,
};
assert_eq!(quantize(black, &ANSI_16), 0);
assert_eq!(quantize(white, &ANSI_16), 15);
}
#[test]
fn two_colors_can_quantize_to_one_entry() {
let page = Rgb::from_hex("#a8a8a8").unwrap();
let border = Rgb::from_hex("#b4b4b4").unwrap();
assert_eq!(quantize(page, &ANSI_16), quantize(border, &ANSI_16));
assert_ne!(
quantize_against(border, page, &ANSI_16),
quantize(page, &ANSI_16)
);
}
#[test]
fn quantize_against_keeps_the_border_off_the_page() {
let page = Rgb::from_hex("#e4ded6").unwrap();
let border = Rgb::from_hex("#7f786d").unwrap();
let shown_page = ANSI_16[quantize(page, &ANSI_16)];
let shown_border = ANSI_16[quantize_against(border, page, &ANSI_16)];
assert!(
wcag_contrast(shown_border, shown_page) >= DISTINCT,
"border {} on page {} is {:.2}:1",
shown_border.to_hex(),
shown_page.to_hex(),
wcag_contrast(shown_border, shown_page)
);
}
#[test]
fn quantize_against_leaves_a_readable_color_alone() {
let page = Rgb::from_hex("#e4ded6").unwrap();
let text = Rgb::from_hex("#1a1816").unwrap();
assert_eq!(
quantize_against(text, page, &ANSI_16),
quantize(text, &ANSI_16)
);
}
#[test]
fn an_impossible_palette_gets_the_most_legible_entry() {
let page = Rgb::from_hex("#ffffff").unwrap();
let border = Rgb::from_hex("#fefefe").unwrap();
let palette = [
Rgb::from_hex("#ffffff").unwrap(),
Rgb::from_hex("#fdfdfd").unwrap(),
];
let chosen = palette[quantize_against(border, page, &palette)];
assert_eq!(chosen.to_hex(), "#fdfdfd");
}
#[test]
fn parse_meta_with_name_and_variant() {
let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
.parse()
.unwrap();
let meta = parse_meta("nord", &table, false);
assert_eq!(meta.id, "nord");
assert_eq!(meta.name, "Nord");
assert_eq!(meta.variant, "light");
assert!(!meta.is_custom);
}
#[test]
fn parse_meta_defaults_to_id_and_dark() {
let table: toml::Table = "".parse().unwrap();
let meta = parse_meta("fallback", &table, true);
assert_eq!(meta.name, "fallback");
assert_eq!(meta.variant, "dark");
assert!(meta.is_custom);
}
#[test]
fn rgb_hex_roundtrip() {
assert_eq!(
Rgb::from_hex("#6196FF").unwrap(),
Rgb {
r: 0x61,
g: 0x96,
b: 0xff
}
);
assert_eq!(
Rgb::from_hex("#abc").unwrap(),
Rgb {
r: 0xaa,
g: 0xbb,
b: 0xcc
}
);
assert_eq!(
Rgb {
r: 0x61,
g: 0x96,
b: 0xff
}
.to_hex(),
"#6196ff"
);
assert!(Rgb::from_hex("not-a-color").is_none());
}
#[test]
fn oklab_roundtrips_within_tolerance() {
for hex in ["#6196ff", "#2e3440", "#ffffff", "#000000", "#c0392b"] {
let c = Rgb::from_hex(hex).unwrap();
let back = Rgb::from_oklab(c.to_oklab());
assert!((c.r as i16 - back.r as i16).abs() <= 1, "{hex} r");
assert!((c.g as i16 - back.g as i16).abs() <= 1, "{hex} g");
assert!((c.b as i16 - back.b as i16).abs() <= 1, "{hex} b");
}
}
#[test]
fn wcag_contrast_known_pairs() {
let white = Rgb {
r: 255,
g: 255,
b: 255,
};
let black = Rgb { r: 0, g: 0, b: 0 };
assert!((wcag_contrast(white, black) - 21.0).abs() < 0.01);
assert!((wcag_contrast(white, white) - 1.0).abs() < 0.01);
}
#[test]
fn readable_on_picks_by_wcag() {
assert_eq!(
readable_on(Rgb {
r: 255,
g: 255,
b: 255
}),
Rgb { r: 0, g: 0, b: 0 }
);
assert_eq!(
readable_on(Rgb { r: 0, g: 0, b: 0 }),
Rgb {
r: 255,
g: 255,
b: 255
}
);
let action = Rgb::from_hex("#6196ff").unwrap();
assert_eq!(readable_on(action), Rgb { r: 0, g: 0, b: 0 });
}
#[test]
fn lighten_darken_move_oklab_lightness() {
let c = Rgb::from_hex("#6196ff").unwrap();
let l0 = c.to_oklab().l;
assert!(lighten(c, 0.05).to_oklab().l > l0);
assert!(darken(c, 0.05).to_oklab().l < l0);
}
#[test]
fn mix_endpoints_and_midpoint() {
let a = Rgb::from_hex("#000000").unwrap();
let b = Rgb::from_hex("#6196ff").unwrap();
assert_eq!(mix(a, b, 0.0), a);
assert_eq!(mix(a, b, 1.0), b);
let mid = mix(a, b, 0.5).to_oklab().l;
assert!(mid > a.to_oklab().l && mid < b.to_oklab().l);
}
fn nord_toml() -> &'static str {
r##"
[meta]
name = "Nord"
variant = "dark"
[surface]
page = "#2e3440"
raised = "#3b4252"
sunken = "#434c5e"
overlay = "#3b4252"
[content]
primary = "#d8dee9"
secondary = "#e5e9f0"
muted = "#616e88"
[action]
primary = "#81a1c1"
[status]
danger = "#bf616a"
success = "#a3be8c"
warning = "#ebcb8b"
info = "#88c0d0"
[line]
border = "#4c566a"
[category]
one = "#bf616a"
two = "#a3be8c"
three = "#81a1c1"
four = "#ebcb8b"
five = "#b48ead"
six = "#88c0d0"
"##
}
#[test]
fn extract_colors_reads_intent_sections() {
let table: toml::Table = nord_toml().parse().unwrap();
let colors = extract_colors(&table);
assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
assert_eq!(colors.len(), 19);
}
#[test]
fn resolve_base_intents_passthrough() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
let t = resolve(&theme);
assert_eq!(t.hex("surface-page"), Some("#2e3440"));
assert_eq!(t.hex("content"), Some("#d8dee9")); assert_eq!(
t.hex("content-muted").unwrap(),
emphasized(
Rgb::from_hex("#d8dee9").unwrap(),
Rgb::from_hex("#2e3440").unwrap(),
Emphasis::Muted
)
.to_hex()
);
assert_eq!(t.hex("action"), Some("#81a1c1"));
assert_eq!(t.hex("danger"), Some("#bf616a"));
assert_eq!(t.hex("border"), Some("#4c566a"));
assert_eq!(t.hex("category-five"), Some("#b48ead"));
}
#[test]
fn a_tonal_step_lands_between_its_base_and_its_ground() {
let ink = Rgb::from_hex("#d8dee9").unwrap();
let page = Rgb::from_hex("#2e3440").unwrap();
for step in [Emphasis::Full, Emphasis::Secondary, Emphasis::Muted] {
let out = emphasized(ink, page, step).to_oklab().l;
assert!(
out <= ink.to_oklab().l && out >= page.to_oklab().l,
"{step:?} left the interval between the ink and the page"
);
}
assert_eq!(emphasized(ink, page, Emphasis::Full).to_hex(), ink.to_hex());
}
#[test]
fn tonal_steps_compose_rather_than_compound() {
let ink = Rgb::from_hex("#d8dee9").unwrap();
let page = Rgb::from_hex("#2e3440").unwrap();
let (a, b) = (0.12f32, 0.42f32);
let twice = tonal(tonal(ink, page, a), page, b);
let once = tonal(ink, page, a + b - a * b);
let (x, y) = (twice.tuple(), once.tuple());
for (l, r) in [(x.0, y.0), (x.1, y.1), (x.2, y.2)] {
assert!(l.abs_diff(r) <= 1, "{twice:?} is not {once:?}");
}
}
#[test]
fn a_ratio_outside_the_interval_is_clamped_rather_than_extrapolated() {
let ink = Rgb::from_hex("#d8dee9").unwrap();
let page = Rgb::from_hex("#2e3440").unwrap();
assert_eq!(tonal(ink, page, -1.0).to_hex(), ink.to_hex());
assert_eq!(tonal(ink, page, 2.0).to_hex(), page.to_hex());
}
#[test]
fn a_derived_token_key_is_the_family_plus_the_step() {
assert_eq!(Emphasis::Muted.token("content"), "content-muted");
assert_eq!(Emphasis::Secondary.token("content"), "content-secondary");
assert_eq!(Emphasis::Full.token("content"), "content");
assert_eq!(Emphasis::Muted.token("danger"), "danger-muted");
}
#[test]
fn every_shipped_theme_ramps_one_way() {
for (id, toml) in embedded_themes() {
let theme = parse_theme_str(id, toml, false).unwrap();
let t = resolve(&theme);
let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
let steps = ["content", "content-secondary", "content-muted"]
.map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
assert!(
steps[0] > steps[1] && steps[1] > steps[2],
"{id}: emphasis does not fall monotonically: {steps:?}"
);
}
}
#[test]
fn an_authored_emphasis_step_does_not_survive_loading() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
}
#[test]
fn a_theme_with_no_page_keeps_what_it_authored() {
let mut colors = HashMap::new();
colors.insert("content.primary".to_string(), "#d8dee9".to_string());
colors.insert("content.muted".to_string(), "#616e88".to_string());
derive_tonal_steps(&mut colors);
assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
}
#[test]
fn resolve_derived_intents() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
let t = resolve(&theme);
let action = Rgb::from_hex("#81a1c1").unwrap();
let page = Rgb::from_hex("#2e3440").unwrap();
let _ = page;
assert_eq!(
t.hex("action-hover").unwrap(),
lighten(action, 0.05).to_hex()
);
assert_eq!(
t.hex("content-on-action").unwrap(),
readable_on(action).to_hex()
);
assert_eq!(t.hex("focus-ring"), Some("#81a1c1"));
assert_eq!(t.hex("hover-surface"), Some("#434c5e")); assert!(t.hex("action-active").is_none());
assert!(t.hex("danger-surface").is_none());
assert!(t.hex("selection").is_none());
assert!(t.hex("row-stripe").is_none());
}
#[test]
fn resolve_bevel_intents() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
let t = resolve(&theme);
let raised = Rgb::from_hex("#3b4252").unwrap();
assert_eq!(
t.hex("bevel-light").unwrap(),
lighten(raised, 0.14).to_hex()
);
assert_eq!(t.hex("bevel-dark").unwrap(), darken(raised, 0.18).to_hex());
}
#[test]
fn bevel_edges_are_distinct_from_their_face() {
const CANNOT_BEVEL: &[&str] = &["neobrute", "oxocarbon-light"];
let mut degenerate: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let Some(raised) = t.hex("surface-raised") else {
continue;
};
let light = t.hex("bevel-light").expect("raised implies bevel-light");
let dark = t.hex("bevel-dark").expect("raised implies bevel-dark");
if light == raised || dark == raised {
degenerate.push(id.to_string());
}
}
degenerate.sort();
assert_eq!(
degenerate, CANNOT_BEVEL,
"themes whose raised surface cannot hold both bevel edges"
);
}
#[test]
fn resolve_well_intent_follows_the_content_direction() {
let dark = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
let dark_raised = Rgb::from_hex("#3b4252").unwrap();
assert_eq!(
dark.hex("surface-well").unwrap(),
darken(dark_raised, 0.09).to_hex()
);
let goingson = embedded_themes()
.into_iter()
.find(|(id, _)| *id == "goingson")
.expect("goingson is embedded")
.1;
let light = resolve(&parse_theme_str("goingson", goingson, false).unwrap());
let light_raised = light
.hex("surface-raised")
.and_then(Rgb::from_hex)
.expect("goingson authors a raised surface");
assert_eq!(
light.hex("surface-well").unwrap(),
lighten(light_raised, 0.07).to_hex()
);
}
#[test]
fn well_is_distinct_from_its_face() {
const CANNOT_WELL: &[&str] = &["neobrute", "oxocarbon-light"];
let mut degenerate: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let Some(raised) = t.hex("surface-raised") else {
continue;
};
let well = t.hex("surface-well").expect("raised implies surface-well");
if well == raised {
degenerate.push(id.to_string());
}
}
degenerate.sort();
assert_eq!(
degenerate, CANNOT_WELL,
"themes whose raised surface cannot hold a well"
);
}
#[test]
fn well_is_visible_against_its_face() {
const MIN_DELTA_L: f32 = 0.02;
const CANNOT_HOLD_A_VISIBLE_WELL: &[&str] =
&["neobrute", "oxocarbon-light", "rosepine-dawn"];
let mut invisible: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let (Some(raised), Some(well)) = (
t.hex("surface-raised").and_then(Rgb::from_hex),
t.hex("surface-well").and_then(Rgb::from_hex),
) else {
continue;
};
if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_DELTA_L {
invisible.push(id.to_string());
}
}
invisible.sort();
assert_eq!(
invisible, CANNOT_HOLD_A_VISIBLE_WELL,
"themes whose well is too close to its face to read as one"
);
}
#[test]
fn raised_is_distinct_from_page() {
const MIN_DELTA_L: f32 = 0.05;
const CANNOT_LIFT_OFF_THE_PAGE: &[&str] = &[
"akari-dawn",
"akari-night",
"ayu-light",
"ayu-mirage",
"catppuccin-latte",
"catppuccin-mocha",
"dawnfox",
"dracula",
"everforest",
"flatwhite",
"gruvbox-light",
"neobrute",
"one-dark",
"oxocarbon-dark",
"oxocarbon-light",
"poimandres",
"rosepine",
"rosepine-dawn",
"solarized-dark",
];
let mut flat: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let (Some(page), Some(raised)) = (
t.hex("surface-page").and_then(Rgb::from_hex),
t.hex("surface-raised").and_then(Rgb::from_hex),
) else {
continue;
};
if (raised.to_oklab().l - page.to_oklab().l).abs() < MIN_DELTA_L {
flat.push(id.to_string());
}
}
flat.sort();
assert_eq!(
flat, CANNOT_LIFT_OFF_THE_PAGE,
"themes whose raised surface is too close to the page to lift off it"
);
}
#[test]
fn a_sixteen_color_terminal_gets_one_bevel_edge_and_not_two() {
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let (Some(face), Some(light), Some(dark)) = (
t.hex("surface-raised").and_then(Rgb::from_hex),
t.hex("bevel-light").and_then(Rgb::from_hex),
t.hex("bevel-dark").and_then(Rgb::from_hex),
) else {
continue;
};
let face_index = quantize(face, &ANSI_16);
let light_survives = quantize(light, &ANSI_16) != face_index;
let dark_survives = quantize(dark, &ANSI_16) != face_index;
assert!(
light_survives != dark_survives,
"{id}: expected exactly one bevel edge to survive 16 colors, \
highlight {light_survives} shadow {dark_survives}"
);
assert_eq!(
quantize_against(light, face, &ANSI_16),
quantize_against(dark, face, &ANSI_16),
"{id}: quantize_against is expected to be unusable for a bevel pair"
);
}
}
#[test]
fn two_hundred_fifty_six_colors_keep_both_bevel_edges() {
const LOSES_AN_EDGE: &[&str] = &[
"gruvbox-light",
"neobrute",
"oxocarbon-light",
"rosepine-dawn",
];
let mut lost: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let (Some(face), Some(light), Some(dark)) = (
t.hex("surface-raised").and_then(Rgb::from_hex),
t.hex("bevel-light").and_then(Rgb::from_hex),
t.hex("bevel-dark").and_then(Rgb::from_hex),
) else {
continue;
};
let f = quantize(face, ANSI_240);
let l = quantize(light, ANSI_240);
let d = quantize(dark, ANSI_240);
if l == f || d == f || l == d {
lost.push(id.to_string());
}
}
lost.sort();
assert_eq!(
lost, LOSES_AN_EDGE,
"themes that cannot hold a two-tone bevel on a 256-color terminal"
);
}
#[test]
fn the_256_table_has_its_three_regions() {
assert_eq!(ANSI_256[..16], ANSI_16);
assert_eq!(ANSI_256[16].tuple(), (0, 0, 0));
assert_eq!(ANSI_256[231].tuple(), (255, 255, 255));
assert_eq!(ANSI_256[16 + 36 * 2 + 6 * 3 + 4].tuple(), (135, 175, 215));
assert_eq!(ANSI_256[232].tuple(), (8, 8, 8));
assert_eq!(ANSI_256[255].tuple(), (238, 238, 238));
assert_eq!(ANSI_240.len(), 240);
assert_eq!(ANSI_240[0], ANSI_256[ANSI_240_OFFSET]);
}
#[test]
fn resolve_overlay_is_dark_translucent_scrim() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
let t = resolve(&theme);
let overlay = t.hex("overlay").unwrap();
assert!(
overlay.starts_with("rgba("),
"overlay is translucent: {overlay}"
);
assert!(overlay.ends_with(", 0.5)"));
let inner = overlay
.trim_start_matches("rgba(")
.trim_end_matches(", 0.5)");
let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
let scrim = Rgb {
r: parts[0],
g: parts[1],
b: parts[2],
};
assert!(scrim.to_oklab().l < 0.2, "scrim must be near-black");
}
#[test]
fn elevation_is_a_near_black_cast_on_every_theme() {
for (id, source) in embedded_themes() {
let theme = parse_theme_str(id, source, false).unwrap();
let t = resolve(&theme);
let Some(elevation) = t.hex("elevation") else {
panic!("{id} derives no elevation");
};
assert!(
elevation.starts_with("rgba(") && elevation.ends_with(", 0.18)"),
"{id}: elevation is translucent: {elevation}"
);
let inner = elevation
.trim_start_matches("rgba(")
.trim_end_matches(", 0.18)");
let parts: Vec<u8> = inner.split(", ").map(|p| p.parse().unwrap()).collect();
let cast = Rgb {
r: parts[0],
g: parts[1],
b: parts[2],
};
assert!(
cast.to_oklab().l < 0.2,
"{id}: a cast shadow must be near-black, got {elevation}"
);
}
}
#[test]
fn elevation_and_the_scrim_are_the_same_tone() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
let t = resolve(&theme);
let scrim = t.hex("overlay").unwrap();
let cast = t.hex("elevation").unwrap();
assert_eq!(
scrim.trim_end_matches(", 0.5)"),
cast.trim_end_matches(", 0.18)"),
);
}
#[test]
fn rgba_reads_both_spellings() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
let t = resolve(&theme);
let (_, _, _, opaque) = t.rgba("surface-page").expect("page is a hex token");
assert_eq!(opaque, 255);
let (r, g, b, alpha) = t.rgba("elevation").expect("elevation is translucent");
assert_eq!(alpha, 46, "0.18 of 255");
assert_eq!(t.rgb("elevation"), None, "rgb declines to drop the alpha");
let (sr, sg, sb, scrim) = t.rgba("overlay").expect("overlay is translucent");
assert_eq!((sr, sg, sb), (r, g, b), "one tone, two weights");
assert_eq!(scrim, 128);
}
#[test]
fn resolve_drops_non_hex_base_intent() {
let theme = parse_theme_str(
"x",
"[surface]\npage = \"</style><script>alert(1)</script>\"\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");
assert!(!t.intents.values().any(|v| v.contains('<')));
}
#[test]
fn resolve_skips_derived_when_source_missing() {
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);
}
#[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('}'));
}
#[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"));
for stack in [FONT_MONO, FONT_SANS] {
assert_eq!(stack.split(',').count(), 2, "{stack} is not one hop");
}
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\");"));
assert_eq!(css.matches("font-weight: 200 800;").count(), 2);
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\")"));
}
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() {
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\")"));
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() {
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 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());
}
#[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")
}
#[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);
}
#[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",
);
}
}
#[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",
);
}
#[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",
);
}
#[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",
);
}
#[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");
}
#[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");
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)]);
}
#[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")); assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); assert_eq!(p.accent.as_deref(), Some("#81a1c1")); assert_eq!(p.border.as_deref(), Some("#4c566a")); }
#[test]
fn bundled_themes_dir_resolves_to_shipped_themes() {
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() {
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() {
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() {
let dir = bundled_themes_dir().unwrap();
let mut on_disk: Vec<String> = 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<String> = 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() {
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() {
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));
}
}
}