use crate::{
DISTINCT, Rgb, Rgba, STEP_FLOOR, ThemeColors, ThemeMeta, darken, lighten, mix, readable_on,
wcag_contrast,
};
use serde::Serialize;
use std::collections::BTreeMap;
#[allow(unused_imports)]
use crate::derive_tonal_steps;
macro_rules! tokens {
($($(#[$doc:meta])* $variant:ident => $stem:literal,)*) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Token {
$($(#[$doc])* $variant,)*
}
impl Token {
pub const ALL: &'static [Token] = &[$(Token::$variant,)*];
pub const fn as_str(self) -> &'static str {
match self {
$(Token::$variant => $stem,)*
}
}
}
};
}
tokens! {
SurfacePage => "surface-page",
SurfaceRaised => "surface-raised",
SurfaceSunken => "surface-sunken",
SurfaceOverlay => "surface-overlay",
Content => "content",
ContentSecondary => "content-secondary",
ContentMuted => "content-muted",
Action => "action",
Danger => "danger",
Success => "success",
Warning => "warning",
Info => "info",
Border => "border",
CategoryOne => "category-one",
CategoryTwo => "category-two",
CategoryThree => "category-three",
CategoryFour => "category-four",
CategoryFive => "category-five",
CategorySix => "category-six",
ActionHover => "action-hover",
ContentOnAction => "content-on-action",
FocusRing => "focus-ring",
Overlay => "overlay",
Elevation => "elevation",
BevelLight => "bevel-light",
BevelDark => "bevel-dark",
SurfaceWell => "surface-well",
InfoSurface => "info-surface",
SuccessSurface => "success-surface",
WarningSurface => "warning-surface",
DangerSurface => "danger-surface",
InfoSurfaceHover => "info-surface-hover",
SuccessSurfaceHover => "success-surface-hover",
WarningSurfaceHover => "warning-surface-hover",
DangerSurfaceHover => "danger-surface-hover",
RowHover => "row-hover",
RowRule => "row-rule",
RowSelected => "row-selected",
HoverSurface => "hover-surface",
BorderStrong => "border-strong",
DangerText => "danger-text",
SuccessText => "success-text",
WarningText => "warning-text",
InfoText => "info-text",
CategoryOneText => "category-one-text",
CategoryTwoText => "category-two-text",
CategoryThreeText => "category-three-text",
CategoryFourText => "category-four-text",
CategoryFiveText => "category-five-text",
CategorySixText => "category-six-text",
SyntaxKeyword => "syntax-keyword",
SyntaxString => "syntax-string",
SyntaxComment => "syntax-comment",
SyntaxConstant => "syntax-constant",
SyntaxEntity => "syntax-entity",
SyntaxVariable => "syntax-variable",
SyntaxSupport => "syntax-support",
}
impl Token {
pub fn parse(stem: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|token| token.as_str() == stem)
}
}
impl Ord for Token {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_str().cmp(other.as_str())
}
}
impl PartialOrd for Token {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::fmt::Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for Token {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
pub mod ratio {
pub const ACTION_HOVER: f32 = 0.05;
pub const SHADOW_L: f32 = 0.08;
pub const SCRIM_ALPHA: f32 = 0.5;
pub const ELEVATION_ALPHA: f32 = 0.18;
pub const BEVEL_LIGHT: f32 = 0.14;
pub const BEVEL_DARK: f32 = 0.18;
pub const WELL_LIGHTER: f32 = 0.07;
pub const WELL_DARKER: f32 = 0.09;
pub const TONE_SURFACE: f32 = 0.12;
pub const TONE_CHROMA: f32 = 0.05;
pub const ROW_HOVER: f32 = 0.09;
pub const ROW_RULE: f32 = 0.26;
pub const ROW_SELECTED: f32 = 0.18;
pub const BORDER_STRONG: f32 = 0.05;
}
pub const BASE_INTENTS: &[(&str, Token)] = &[
("surface.page", Token::SurfacePage),
("surface.raised", Token::SurfaceRaised),
("surface.sunken", Token::SurfaceSunken),
("surface.overlay", Token::SurfaceOverlay),
("content.primary", Token::Content),
("content.secondary", Token::ContentSecondary),
("content.muted", Token::ContentMuted),
("action.primary", Token::Action),
("status.danger", Token::Danger),
("status.success", Token::Success),
("status.warning", Token::Warning),
("status.info", Token::Info),
("line.border", Token::Border),
("category.one", Token::CategoryOne),
("category.two", Token::CategoryTwo),
("category.three", Token::CategoryThree),
("category.four", Token::CategoryFour),
("category.five", Token::CategoryFive),
("category.six", Token::CategorySix),
];
const TONES: [(Token, Token, Token); 4] = [
(Token::Info, Token::InfoSurface, Token::InfoSurfaceHover),
(
Token::Success,
Token::SuccessSurface,
Token::SuccessSurfaceHover,
),
(
Token::Warning,
Token::WarningSurface,
Token::WarningSurfaceHover,
),
(
Token::Danger,
Token::DangerSurface,
Token::DangerSurfaceHover,
),
];
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnmetFloor {
pub token: Token,
pub floor: f32,
pub achieved: f32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SemanticTokens {
pub meta: ThemeMeta,
pub intents: BTreeMap<Token, Rgba>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unmet_floors: Vec<UnmetFloor>,
}
impl SemanticTokens {
pub fn get(&self, token: Token) -> Option<Rgba> {
self.intents.get(&token).copied()
}
pub fn color(&self, token: Token) -> Option<Rgb> {
self.get(token).and_then(Rgba::opaque_rgb)
}
pub fn hex(&self, token: Token) -> Option<String> {
self.color(token).map(Rgb::to_hex)
}
pub fn css(&self, token: Token) -> Option<String> {
self.get(token).map(Rgba::css)
}
pub fn rgb(&self, token: Token) -> Option<(u8, u8, u8)> {
self.color(token).map(Rgb::tuple)
}
pub fn rgba(&self, token: Token) -> Option<(u8, u8, u8, u8)> {
self.get(token).map(Rgba::tuple)
}
}
pub fn resolve(theme: &ThemeColors) -> SemanticTokens {
let mut base: BTreeMap<Token, Rgba> = BTreeMap::new();
for (src, token) in BASE_INTENTS {
if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) {
base.insert(*token, Rgba::opaque(rgb));
}
}
let (intents, unmet_floors) = lift_to_floors(base);
SemanticTokens {
meta: theme.meta.clone(),
intents,
unmet_floors,
}
}
fn toned(ground: Rgb, tone: Rgb, step: f32, chroma: f32) -> Rgb {
let (g, t) = (ground.to_oklab(), tone.to_oklab());
let own = t.a.hypot(t.b);
let scale = if own > chroma { chroma / own } else { 1.0 };
Rgb::from_oklab(crate::Oklab {
l: g.l + (t.l - g.l) * step,
a: t.a * scale,
b: t.b * scale,
})
}
fn derive(base: &BTreeMap<Token, Rgba>) -> BTreeMap<Token, Rgba> {
use ratio::{
ACTION_HOVER, BEVEL_DARK, BEVEL_LIGHT, BORDER_STRONG, ELEVATION_ALPHA, ROW_HOVER, ROW_RULE,
ROW_SELECTED, SCRIM_ALPHA, SHADOW_L, TONE_CHROMA, TONE_SURFACE, WELL_DARKER, WELL_LIGHTER,
};
let mut intents = base.clone();
let get = |m: &BTreeMap<Token, Rgba>, token: Token| m.get(&token).and_then(|c| c.opaque_rgb());
let mut derived: Vec<(Token, Rgba)> = Vec::new();
let mut put = |token: Token, rgb: Rgb| derived.push((token, Rgba::opaque(rgb)));
if let Some(action) = get(&intents, Token::Action) {
put(Token::ActionHover, lighten(action, ACTION_HOVER));
put(Token::ContentOnAction, readable_on(action));
let grounds: Vec<Rgb> = [Token::SurfacePage, Token::SurfaceRaised]
.into_iter()
.filter_map(|ground| get(&intents, ground))
.collect();
put(Token::FocusRing, cleared(action, &grounds, DISTINCT));
}
if let Some(page) = get(&intents, Token::SurfacePage) {
let mut o = page.to_oklab();
o.l = SHADOW_L;
let shadow = Rgb::from_oklab(o);
intents.insert(Token::Overlay, Rgba::translucent(shadow, SCRIM_ALPHA));
intents.insert(Token::Elevation, Rgba::translucent(shadow, ELEVATION_ALPHA));
}
if let Some(raised) = get(&intents, Token::SurfaceRaised) {
put(Token::BevelLight, lighten(raised, BEVEL_LIGHT));
put(Token::BevelDark, darken(raised, BEVEL_DARK));
if let Some(content) = get(&intents, Token::Content) {
let content_is_darker = content.to_oklab().l < raised.to_oklab().l;
let well = if content_is_darker {
step_with_room(raised, WELL_LIGHTER)
} else {
step_with_room(raised, -WELL_DARKER)
};
put(Token::SurfaceWell, well);
let sunken = get(&intents, Token::SurfaceSunken);
for (tone, surface, hover) in TONES {
if let Some(color) = get(&intents, tone) {
put(surface, toned(raised, color, TONE_SURFACE, TONE_CHROMA));
if let Some(sunken) = sunken {
put(hover, toned(sunken, color, TONE_SURFACE, TONE_CHROMA));
}
}
}
put(Token::RowHover, mix(raised, content, ROW_HOVER));
put(Token::RowRule, mix(raised, content, ROW_RULE));
put(Token::RowSelected, mix(raised, content, ROW_SELECTED));
}
}
if let Some(sunken) = get(&intents, Token::SurfaceSunken) {
put(Token::HoverSurface, sunken);
}
if let Some(border) = get(&intents, Token::Border) {
let away_from_page = match (
get(&intents, Token::Content),
get(&intents, Token::SurfacePage),
) {
(Some(content), Some(page)) => content.to_oklab().l > page.to_oklab().l,
_ => false,
};
let strong = if away_from_page {
lighten(border, BORDER_STRONG)
} else {
darken(border, BORDER_STRONG)
};
put(Token::BorderStrong, strong);
}
intents.extend(derived);
intents
}
fn with_text_inks(mut tokens: BTreeMap<Token, Rgba>) -> BTreeMap<Token, Rgba> {
let get = |m: &BTreeMap<Token, Rgba>, token: Token| m.get(&token).and_then(|c| c.opaque_rgb());
let mut inks: Vec<(Token, Rgba)> = Vec::new();
for &(ink, source, grounds) in TEXT_INKS {
let Some(color) = get(&tokens, source) else {
continue;
};
let grounds: Vec<Rgb> = grounds.iter().filter_map(|g| get(&tokens, *g)).collect();
inks.push((ink, Rgba::opaque(cleared(color, &grounds, TEXT))));
}
tokens.extend(inks);
tokens
}
const TEXT_GROUNDS: &[Token] = &[
Token::SurfacePage,
Token::SurfaceRaised,
Token::SurfaceSunken,
Token::SurfaceWell,
Token::SurfaceOverlay,
Token::RowHover,
Token::RowSelected,
];
macro_rules! toned_grounds {
($surface:expr) => {
&[
Token::SurfacePage,
Token::SurfaceRaised,
Token::SurfaceSunken,
Token::SurfaceWell,
Token::SurfaceOverlay,
Token::RowHover,
Token::RowSelected,
$surface,
]
};
}
const CODE_GROUNDS: &[Token] = &[
Token::SurfacePage,
Token::SurfaceRaised,
Token::SurfaceSunken,
Token::SurfaceWell,
Token::SurfaceOverlay,
Token::RowHover,
Token::RowSelected,
Token::SuccessSurface,
Token::DangerSurface,
];
const TEXT_INKS: &[(Token, Token, &[Token])] = &[
(
Token::DangerText,
Token::Danger,
toned_grounds!(Token::DangerSurface),
),
(
Token::SuccessText,
Token::Success,
toned_grounds!(Token::SuccessSurface),
),
(
Token::WarningText,
Token::Warning,
toned_grounds!(Token::WarningSurface),
),
(
Token::InfoText,
Token::Info,
toned_grounds!(Token::InfoSurface),
),
(Token::CategoryOneText, Token::CategoryOne, TEXT_GROUNDS),
(Token::CategoryTwoText, Token::CategoryTwo, TEXT_GROUNDS),
(Token::CategoryThreeText, Token::CategoryThree, TEXT_GROUNDS),
(Token::CategoryFourText, Token::CategoryFour, TEXT_GROUNDS),
(Token::CategoryFiveText, Token::CategoryFive, TEXT_GROUNDS),
(Token::CategorySixText, Token::CategorySix, TEXT_GROUNDS),
(Token::SyntaxKeyword, Token::CategoryFive, CODE_GROUNDS),
(Token::SyntaxString, Token::CategoryTwo, CODE_GROUNDS),
(Token::SyntaxComment, Token::ContentMuted, CODE_GROUNDS),
(Token::SyntaxConstant, Token::CategoryFour, CODE_GROUNDS),
(Token::SyntaxEntity, Token::CategoryThree, CODE_GROUNDS),
(Token::SyntaxVariable, Token::CategoryOne, CODE_GROUNDS),
(Token::SyntaxSupport, Token::CategorySix, CODE_GROUNDS),
];
const TEXT: f32 = 4.5;
const QUIET_TEXT: f32 = 3.0;
const INK_FLOORS: &[(Token, &[Token], f32)] = &[
(
Token::Content,
&[
Token::SurfacePage,
Token::SurfaceRaised,
Token::SurfaceSunken,
Token::SurfaceWell,
Token::RowHover,
Token::RowSelected,
Token::InfoSurface,
Token::SuccessSurface,
Token::WarningSurface,
Token::DangerSurface,
],
TEXT,
),
(Token::ContentSecondary, TEXT_GROUNDS, TEXT),
(Token::ContentMuted, TEXT_GROUNDS, QUIET_TEXT),
];
const LIFT: f32 = 0.005;
fn lift_to_floors(mut base: BTreeMap<Token, Rgba>) -> (BTreeMap<Token, Rgba>, Vec<UnmetFloor>) {
const ROUNDS: usize = 400;
let opaque =
|m: &BTreeMap<Token, Rgba>, token: Token| m.get(&token).and_then(|c| c.opaque_rgb());
for _ in 0..ROUNDS {
let tokens = derive(&base);
let mut moved = false;
for &(ink, grounds, floor) in INK_FLOORS {
let Some(color) = opaque(&tokens, ink) else {
continue;
};
let grounds: Vec<Rgb> = grounds.iter().filter_map(|g| opaque(&tokens, *g)).collect();
if worst(color, &grounds) >= floor {
continue;
}
let lifted = match (ink, opaque(&tokens, Token::Content)) {
(Token::Content, _) | (_, None) => away_from(color, &grounds, LIFT),
(_, Some(content)) => {
if wcag_contrast(mix(color, content, 0.05), content) < STEP_FLOOR {
let ink_grounds: Vec<Rgb> = INK_FLOORS[0]
.1
.iter()
.filter_map(|g| opaque(&tokens, *g))
.collect();
let content_lifted = away_from(content, &ink_grounds, LIFT);
if content_lifted != content {
base.insert(Token::Content, Rgba::opaque(content_lifted));
moved = true;
}
continue;
}
mix(color, content, 0.05)
}
};
if lifted != color {
base.insert(ink, Rgba::opaque(lifted));
moved = true;
}
}
if !moved {
let tokens = with_text_inks(tokens);
let unmet = unmet_floors(&tokens);
return (tokens, unmet);
}
}
let tokens = with_text_inks(derive(&base));
let unmet = unmet_floors(&tokens);
(tokens, unmet)
}
fn unmet_floors(tokens: &BTreeMap<Token, Rgba>) -> Vec<UnmetFloor> {
let opaque =
|m: &BTreeMap<Token, Rgba>, token: Token| m.get(&token).and_then(|c| c.opaque_rgb());
INK_FLOORS
.iter()
.copied()
.chain(
TEXT_INKS
.iter()
.map(|&(ink, _, grounds)| (ink, grounds, TEXT)),
)
.filter_map(|(ink, grounds, floor)| {
let color = opaque(tokens, ink)?;
let grounds: Vec<Rgb> = grounds.iter().filter_map(|g| opaque(tokens, *g)).collect();
let achieved = worst(color, &grounds);
(achieved < floor).then_some(UnmetFloor {
token: ink,
floor,
achieved,
})
})
.collect()
}
fn worst(ink: Rgb, grounds: &[Rgb]) -> f32 {
grounds
.iter()
.map(|ground| wcag_contrast(ink, *ground))
.fold(f32::INFINITY, f32::min)
}
fn away_from(color: Rgb, grounds: &[Rgb], step: f32) -> Rgb {
let Some(binding) = grounds
.iter()
.min_by(|a, b| wcag_contrast(color, **a).total_cmp(&wcag_contrast(color, **b)))
else {
return color;
};
let darker = match binding.to_oklab().l.partial_cmp(&color.to_oklab().l) {
Some(std::cmp::Ordering::Greater) => true,
Some(std::cmp::Ordering::Less) => false,
_ => readable_on(*binding) == Rgb { r: 0, g: 0, b: 0 },
};
if darker {
darken(color, step)
} else {
lighten(color, step)
}
}
fn cleared(color: Rgb, grounds: &[Rgb], floor: f32) -> Rgb {
const STEPS: usize = 400;
if worst(color, grounds) >= floor {
return color;
}
let walk = |toward_dark: bool| -> (Rgb, f32, Option<usize>) {
let mut at = color;
let mut best = (color, worst(color, grounds));
for step in 1..=STEPS {
let next = if toward_dark {
darken(at, LIFT)
} else {
lighten(at, LIFT)
};
if next == at {
break;
}
at = next;
let reached = worst(at, grounds);
if reached >= floor {
return (at, reached, Some(step));
}
if reached > best.1 {
best = (at, reached);
}
}
(best.0, best.1, None)
};
let (dark, lit) = (walk(true), walk(false));
match (dark.2, lit.2) {
(Some(d), Some(l)) => {
if d <= l {
dark.0
} else {
lit.0
}
}
(Some(_), None) => dark.0,
(None, Some(_)) => lit.0,
(None, None) => {
if dark.1 >= lit.1 {
dark.0
} else {
lit.0
}
}
}
}
pub(crate) const MIN_VISIBLE_DELTA_L: f32 = 0.02;
fn step_with_room(c: Rgb, delta: f32) -> Rgb {
let target = c.to_oklab().l + delta;
if (MIN_VISIBLE_DELTA_L..=1.0 - MIN_VISIBLE_DELTA_L).contains(&target) {
lighten(c, delta)
} else {
lighten(c, -delta)
}
}
pub fn intent_css_declarations(tokens: &SemanticTokens) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for (token, color) in &tokens.intents {
let _ = writeln!(out, " --{token}: {color};");
}
out
}
pub fn intent_css_vars(tokens: &SemanticTokens) -> String {
format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixture::nord_toml;
#[allow(unused_imports)]
use crate::{ThemeDirs, ThemeId, Token};
use crate::{embedded_themes, parse_theme_str};
#[test]
fn away_from_moves_away_from_the_binding_ground_not_the_mean() {
let color = Rgb::from_hex("#808080").unwrap();
let grounds = [
Rgb::from_hex("#ffffff").unwrap(),
Rgb::from_hex("#202020").unwrap(),
Rgb::from_hex("#202020").unwrap(),
Rgb::from_hex("#202020").unwrap(),
];
let moved = away_from(color, &grounds, 0.05);
assert!(
moved.to_oklab().l < color.to_oklab().l,
"moved {moved:?} lighter than {color:?}"
);
}
#[test]
fn away_from_darkens_when_the_binding_ground_is_the_ink() {
let color = Rgb::from_hex("#808080").unwrap();
let moved = away_from(color, &[color], 0.05);
assert!(
moved.to_oklab().l < color.to_oklab().l,
"an all-#808080 ink must darken, got {moved:?}"
);
}
#[test]
fn resolve_base_intents_passthrough() {
let theme = parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap();
let t = resolve(&theme);
assert_eq!(t.hex(Token::SurfacePage).as_deref(), Some("#2e3440"));
let authored = Rgb::from_hex("#d8dee9").unwrap().to_oklab();
let content = t.color(Token::Content).unwrap().to_oklab();
assert!(content.l > authored.l, "{content:?}");
let page = Rgb::from_hex("#2e3440").unwrap().to_oklab();
let muted = t.color(Token::ContentMuted).unwrap().to_oklab();
assert!(page.l < muted.l && muted.l < content.l, "{muted:?}");
assert_eq!(t.hex(Token::Action).as_deref(), Some("#81a1c1"));
assert_eq!(t.hex(Token::Danger).as_deref(), Some("#bf616a"));
assert_eq!(t.hex(Token::Border).as_deref(), Some("#4c566a"));
assert_eq!(t.hex(Token::CategoryFive).as_deref(), Some("#b48ead"));
}
#[test]
fn a_toned_surface_carries_its_tones_hue() {
let hue = |c: Rgb| {
let lab = c.to_oklab();
(lab.b.atan2(lab.a).to_degrees(), lab.a.hypot(lab.b))
};
let mut off = Vec::new();
for (id, toml) in embedded_themes() {
let theme = parse_theme_str(&ThemeId::from_static(id), toml, false).unwrap();
let t = resolve(&theme);
for (tone, surface, _) in TONES {
let (Some(tone_hex), Some(surface_hex)) = (t.hex(tone), t.hex(surface)) else {
continue;
};
let (want, own) = hue(Rgb::from_hex(&tone_hex).unwrap());
if own < 0.02 {
continue;
}
let (got, chroma) = hue(Rgb::from_hex(&surface_hex).unwrap());
let apart = ((got - want + 540.0) % 360.0 - 180.0).abs();
if apart > 20.0 || chroma < 0.02 {
off.push(format!(
"{id} {surface:?}: {apart:.0} degrees off, chroma {chroma:.3}"
));
}
}
}
assert!(off.is_empty(), "{off:#?}");
}
#[test]
fn resolve_derived_intents() {
let theme = parse_theme_str(&ThemeId::from_static("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(Token::ActionHover).unwrap(),
lighten(action, 0.05).to_hex()
);
assert_eq!(
t.hex(Token::ContentOnAction).unwrap(),
readable_on(action).to_hex()
);
assert_eq!(t.hex(Token::FocusRing).as_deref(), Some("#81a1c1"));
assert_eq!(t.hex(Token::HoverSurface).as_deref(), Some("#434c5e")); let raised = Rgb::from_hex("#3b4252").unwrap();
let sunken = Rgb::from_hex("#434c5e").unwrap();
let danger = Rgb::from_hex("#bf616a").unwrap();
assert_eq!(
t.hex(Token::DangerSurface).unwrap(),
toned(raised, danger, 0.12, 0.05).to_hex()
);
assert_eq!(
t.hex(Token::DangerSurfaceHover).unwrap(),
toned(sunken, danger, 0.12, 0.05).to_hex()
);
assert_ne!(
t.hex(Token::DangerSurface),
t.hex(Token::DangerSurfaceHover)
);
assert!(Token::parse("action-active").is_none());
assert!(Token::parse("selection").is_none());
}
#[test]
fn resolve_bevel_intents() {
let theme = parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap();
let t = resolve(&theme);
let raised = Rgb::from_hex("#3b4252").unwrap();
assert_eq!(
t.hex(Token::BevelLight).unwrap(),
lighten(raised, 0.14).to_hex()
);
assert_eq!(
t.hex(Token::BevelDark).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(&ThemeId::new(id).unwrap(), source, false).unwrap();
let t = resolve(&theme);
let Some(raised) = t.hex(Token::SurfaceRaised) else {
continue;
};
let light = t
.hex(Token::BevelLight)
.expect("raised implies bevel-light");
let dark = t.hex(Token::BevelDark).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(&ThemeId::from_static("nord"), nord_toml(), false).unwrap());
let dark_raised = Rgb::from_hex("#3b4252").unwrap();
assert_eq!(
dark.hex(Token::SurfaceWell).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(&ThemeId::from_static("goingson"), goingson, false).unwrap());
let light_raised = light
.color(Token::SurfaceRaised)
.expect("goingson authors a raised surface");
assert_eq!(
light.hex(Token::SurfaceWell).unwrap(),
lighten(light_raised, 0.07).to_hex()
);
}
#[test]
fn well_is_distinct_from_its_face() {
const CANNOT_WELL: &[&str] = &[];
let mut degenerate: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(&ThemeId::new(id).unwrap(), source, false).unwrap();
let t = resolve(&theme);
let Some(raised) = t.hex(Token::SurfaceRaised) else {
continue;
};
let well = t
.hex(Token::SurfaceWell)
.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 CANNOT_HOLD_A_VISIBLE_WELL: &[&str] = &[];
let mut invisible: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(&ThemeId::new(id).unwrap(), source, false).unwrap();
let t = resolve(&theme);
let (Some(raised), Some(well)) =
(t.color(Token::SurfaceRaised), t.color(Token::SurfaceWell))
else {
continue;
};
if (well.to_oklab().l - raised.to_oklab().l).abs() < MIN_VISIBLE_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 well_is_visible_against_the_highlight() {
let mut collided: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(&ThemeId::new(id).unwrap(), source, false).unwrap();
let t = resolve(&theme);
let (Some(well), Some(light)) =
(t.color(Token::SurfaceWell), t.color(Token::BevelLight))
else {
continue;
};
if (well.to_oklab().l - light.to_oklab().l).abs() < MIN_VISIBLE_DELTA_L {
collided.push(id.to_string());
}
}
collided.sort();
assert!(
collided.is_empty(),
"themes whose well reads as the lit edge around it: {collided:?}"
);
}
#[test]
fn row_tones_reproduce_the_chosen_table_specimen() {
let source = embedded_themes()
.into_iter()
.find(|(id, _)| *id == "akari-dawn")
.expect("akari-dawn is embedded")
.1;
let t =
resolve(&parse_theme_str(&ThemeId::from_static("akari-dawn"), source, false).unwrap());
assert_eq!(
t.hex(Token::RowHover)
.map(|h| h.to_ascii_lowercase())
.as_deref(),
Some("#dedad5")
);
assert_eq!(
t.hex(Token::RowRule)
.map(|h| h.to_ascii_lowercase())
.as_deref(),
Some("#b0ada9")
);
}
#[test]
fn well_without_room_travels_the_other_way() {
let source = embedded_themes()
.into_iter()
.find(|(id, _)| *id == "akari-dawn")
.expect("akari-dawn is embedded")
.1;
let t =
resolve(&parse_theme_str(&ThemeId::from_static("akari-dawn"), source, false).unwrap());
let raised = t
.color(Token::SurfaceRaised)
.expect("akari-dawn authors a raised surface");
assert_eq!(
t.hex(Token::SurfaceWell).unwrap(),
darken(raised, 0.07).to_hex()
);
}
#[test]
fn raised_is_distinct_from_page() {
const MIN_DELTA_L: f32 = 0.05;
const CANNOT_LIFT_OFF_THE_PAGE: &[&str] = &[
"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",
"solarized-light",
];
let mut flat: Vec<String> = Vec::new();
for (id, source) in embedded_themes() {
let theme = parse_theme_str(&ThemeId::new(id).unwrap(), source, false).unwrap();
let t = resolve(&theme);
let (Some(page), Some(raised)) =
(t.color(Token::SurfacePage), t.color(Token::SurfaceRaised))
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 resolve_overlay_is_dark_translucent_scrim() {
let theme = parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap();
let t = resolve(&theme);
let overlay = t.css(Token::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(&ThemeId::new(id).unwrap(), source, false).unwrap();
let t = resolve(&theme);
let Some(elevation) = t.css(Token::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(&ThemeId::from_static("nord"), nord_toml(), false).unwrap();
let t = resolve(&theme);
let scrim = t.css(Token::Overlay).unwrap();
let cast = t.css(Token::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(&ThemeId::from_static("nord"), nord_toml(), false).unwrap();
let t = resolve(&theme);
let (_, _, _, opaque) = t.rgba(Token::SurfacePage).expect("page is a hex token");
assert_eq!(opaque, 255);
let (r, g, b, alpha) = t.rgba(Token::Elevation).expect("elevation is translucent");
assert_eq!(alpha, 46, "0.18 of 255");
assert_eq!(
t.rgb(Token::Elevation),
None,
"rgb declines to drop the alpha"
);
let (sr, sg, sb, scrim) = t.rgba(Token::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(&ThemeId::from_static("x"),
"[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n",
false,
)
.unwrap();
let t = resolve(&theme);
assert!(
t.hex(Token::SurfacePage).is_none(),
"non-hex base intent leaked"
);
assert_eq!(t.hex(Token::Content).unwrap(), "#111111");
assert!(!t.intents.values().any(|v| v.css().contains('<')));
}
#[test]
fn resolve_skips_derived_when_source_missing() {
let theme = parse_theme_str(
&ThemeId::from_static("x"),
"[surface]\npage = \"#000000\"\n[line]\nborder = \"#222222\"\n",
false,
)
.unwrap();
let t = resolve(&theme);
assert!(t.hex(Token::Action).is_none());
assert!(t.hex(Token::ActionHover).is_none());
assert_eq!(
t.hex(Token::BorderStrong).unwrap(),
darken(Rgb::from_hex("#222222").unwrap(), 0.05).to_hex()
);
}
#[test]
fn rgb_accessor_for_native_consumers() {
let theme = parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap();
let t = resolve(&theme);
assert_eq!(t.rgb(Token::Action), Some((0x81, 0xa1, 0xc1)));
assert_eq!(Token::parse("nonexistent"), None);
}
#[test]
fn every_token_has_one_stem_and_reads_back_from_it() {
let mut stems: Vec<&str> = Token::ALL.iter().map(|t| t.as_str()).collect();
for token in Token::ALL {
assert_eq!(Token::parse(token.as_str()), Some(*token));
}
let count = stems.len();
stems.sort_unstable();
stems.dedup();
assert_eq!(stems.len(), count, "a stem is spelled twice");
}
#[test]
fn tokens_order_by_stem_so_emission_is_alphabetical() {
let t =
resolve(&parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap());
let order: Vec<&str> = t.intents.keys().map(|k| k.as_str()).collect();
let mut sorted = order.clone();
sorted.sort_unstable();
assert_eq!(order, sorted);
}
#[test]
fn a_full_theme_resolves_every_token() {
let t =
resolve(&parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap());
let missing: Vec<&str> = Token::ALL
.iter()
.filter(|token| t.get(**token).is_none())
.map(|token| token.as_str())
.collect();
assert!(missing.is_empty(), "{missing:?}");
}
#[test]
fn serialised_tokens_keep_the_css_spelling() {
let t =
resolve(&parse_theme_str(&ThemeId::from_static("nord"), nord_toml(), false).unwrap());
let text = toml::to_string(&t.intents).unwrap();
assert!(text.contains("surface-page = \"#2e3440\""), "{text}");
assert!(text.contains("overlay = \"rgba("), "{text}");
}
#[test]
fn intent_css_vars_wraps_root_and_includes_tokens() {
let theme = parse_theme_str(&ThemeId::from_static("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('}'));
}
}