use color::{AlphaColor, Oklch, Srgb};
use peniko::Color;
use crate::style::{OpticalSizing, Shadow};
use crate::tokens::{Elevation, RadiusScale, ShadowToken};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Light,
Dark,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaseField {
pub hue: f32,
pub chroma: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Contrast {
Low,
#[default]
Standard,
High,
}
impl Contrast {
fn factor(self) -> f32 {
match self {
Self::Low => 0.92,
Self::Standard => 1.0,
Self::High => 1.10,
}
}
}
#[derive(Debug, Clone)]
pub struct Ramp(pub [Color; 12]);
impl Ramp {
pub fn step(&self, n: usize) -> Color {
self.0[n.clamp(1, 12) - 1]
}
}
#[derive(Debug, Clone, Copy)]
pub struct StatusColors {
pub bg: Color,
pub border: Color,
pub solid: Color,
pub solid_hover: Color,
pub solid_active: Color,
pub text: Color,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContrastViolation {
pub pair: String,
pub measured_lc: f64,
pub required_lc: f64,
}
impl std::fmt::Display for ContrastViolation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: APCA Lc {:.1} < required {:.1}",
self.pair, self.measured_lc, self.required_lc
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WritingDir {
#[default]
Ltr,
Rtl,
}
impl WritingDir {
#[must_use]
pub fn is_rtl(self) -> bool {
matches!(self, Self::Rtl)
}
}
pub const DEFAULT_CORNER_SMOOTHING: f32 = 0.6;
#[derive(Debug, Clone)]
pub struct Theme {
pub mode: Mode,
pub accent_hue: f32,
pub neutral_hue: f32,
pub neutral_chroma_mult: f32,
pub neutrals: Ramp,
pub accents: Ramp,
pub neutral_alpha: Ramp,
pub accent_alpha: Ramp,
pub danger: StatusColors,
pub warning: StatusColors,
pub success: StatusColors,
pub bg: Color,
pub surface: Color,
pub surface_raised: Color,
pub element: Color,
pub element_hover: Color,
pub element_active: Color,
pub border_subtle: Color,
pub border: Color,
pub border_strong: Color,
pub text: Color,
pub text_muted: Color,
pub text_subtle: Color,
pub text_disabled: Color,
pub accent: Color,
pub accent_hover: Color,
pub accent_active: Color,
pub accent_bg: Color,
pub accent_border: Color,
pub accent_text: Color,
pub on_accent: Color,
pub radius: RadiusScale,
pub corner_smoothing: f32,
pub optical_sizing: OpticalSizing,
pub elevation: Elevation,
pub text_scale: f32,
pub direction: WritingDir,
}
type RampTable = [(f32, f32); 12];
const NEUTRAL_LIGHT: RampTable = [
(0.992, 0.002),
(0.978, 0.003),
(0.955, 0.004),
(0.930, 0.005),
(0.905, 0.006),
(0.875, 0.007),
(0.830, 0.008),
(0.730, 0.010),
(0.555, 0.012),
(0.510, 0.012),
(0.435, 0.010),
(0.235, 0.008),
];
const NEUTRAL_DARK: RampTable = [
(0.185, 0.004),
(0.215, 0.005),
(0.250, 0.006),
(0.280, 0.007),
(0.310, 0.008),
(0.345, 0.009),
(0.400, 0.010),
(0.490, 0.012),
(0.560, 0.012),
(0.610, 0.012),
(0.770, 0.008),
(0.945, 0.004),
];
const ACCENT_LIGHT: RampTable = [
(0.975, 0.020),
(0.950, 0.040),
(0.920, 0.060),
(0.880, 0.080),
(0.835, 0.100),
(0.785, 0.120),
(0.725, 0.140),
(0.660, 0.150),
(0.585, 0.160),
(0.545, 0.155),
(0.500, 0.135),
(0.380, 0.100),
];
const ACCENT_DARK: RampTable = [
(0.250, 0.040),
(0.290, 0.055),
(0.330, 0.070),
(0.370, 0.085),
(0.415, 0.100),
(0.465, 0.120),
(0.530, 0.140),
(0.600, 0.150),
(0.585, 0.160),
(0.545, 0.155),
(0.720, 0.140),
(0.880, 0.110),
];
const DANGER_HUE: f32 = 25.0;
const WARNING_HUE: f32 = 80.0;
const SUCCESS_HUE: f32 = 150.0;
const fn shadow_layers(token: ShadowToken) -> &'static [(f32, f32, f32)] {
match token {
ShadowToken::Xs => &[(1.0, 2.0, 0.05)],
ShadowToken::Sm => &[(1.0, 2.0, 0.05), (1.0, 3.0, 0.06)],
ShadowToken::Md => &[(2.0, 4.0, 0.05), (4.0, 12.0, 0.08)],
ShadowToken::Lg => &[(4.0, 10.0, 0.06), (16.0, 32.0, 0.12)],
ShadowToken::Xl => &[(2.0, 6.0, 0.04), (8.0, 16.0, 0.08), (24.0, 48.0, 0.16)],
}
}
const DARK_SHADOW_ALPHA_FACTOR: f32 = 1.6;
const DARK_ELEVATION_TINT: f32 = 0.025;
const ACTIVE_DL: f32 = 0.045;
const PRIMARY_TEXT_MIN: f64 = 75.0;
const SECONDARY_TEXT_MIN: f64 = 55.0;
const CONTROL_LABEL_MIN: f64 = 60.0;
const COMPONENT_TEXT_MIN: f64 = 40.0;
impl Theme {
pub fn from_accent(hue_deg: f32, mode: Mode) -> Self {
let hue = hue_deg.rem_euclid(360.0);
let (neutral_table, accent_table) = match mode {
Mode::Light => (&NEUTRAL_LIGHT, &ACCENT_LIGHT),
Mode::Dark => (&NEUTRAL_DARK, &ACCENT_DARK),
};
let neutrals = make_ramp(neutral_table, hue);
let accents = make_ramp(accent_table, hue);
let status = |status_hue: f32| StatusColors {
bg: ramp_color(accent_table, 3, status_hue),
border: ramp_color(accent_table, 7, status_hue),
solid: ramp_color(accent_table, 9, status_hue),
solid_hover: ramp_color(accent_table, 10, status_hue),
solid_active: active_color(accent_table, status_hue),
text: ramp_color(accent_table, 11, status_hue),
};
let surface_raised = match mode {
Mode::Light => Color::new([1.0, 1.0, 1.0, 1.0]),
Mode::Dark => neutrals.step(3),
};
let on_accent = if accent_table[8].0 < 0.65 {
Color::new([1.0, 1.0, 1.0, 1.0])
} else {
neutrals.step(12)
};
Self {
mode,
accent_hue: hue,
neutral_hue: hue,
neutral_chroma_mult: 1.0,
danger: status(DANGER_HUE),
warning: status(WARNING_HUE),
success: status(SUCCESS_HUE),
bg: neutrals.step(1),
surface: neutrals.step(2),
surface_raised,
element: neutrals.step(3),
element_hover: neutrals.step(4),
element_active: neutrals.step(5),
border_subtle: neutrals.step(5),
border: neutrals.step(6),
border_strong: neutrals.step(7),
text: neutrals.step(12),
text_muted: neutrals.step(11),
text_subtle: neutrals.step(9),
text_disabled: neutrals.step(8),
accent: accents.step(9),
accent_hover: accents.step(10),
accent_active: active_color(accent_table, hue),
accent_bg: accents.step(3),
accent_border: accents.step(7),
accent_text: accents.step(11),
on_accent,
neutral_alpha: alpha_ramp(&neutrals, neutrals.step(1)),
accent_alpha: alpha_ramp(&accents, neutrals.step(1)),
neutrals,
accents,
radius: RadiusScale::default(),
corner_smoothing: DEFAULT_CORNER_SMOOTHING,
optical_sizing: OpticalSizing::Auto,
elevation: Elevation::default(),
text_scale: 1.0,
direction: WritingDir::default(),
}
}
#[must_use]
pub fn with_radius(mut self, radius: RadiusScale) -> Self {
self.radius = radius;
self
}
#[must_use]
pub fn with_corner_smoothing(mut self, smoothing: f32) -> Self {
self.corner_smoothing = smoothing.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn with_optical_sizing(mut self, optical: OpticalSizing) -> Self {
self.optical_sizing = optical;
self
}
#[must_use]
pub fn with_elevation(mut self, elevation: Elevation) -> Self {
self.elevation = elevation;
self
}
#[must_use]
pub fn with_text_scale(mut self, scale: f32) -> Self {
self.text_scale = scale;
self
}
#[must_use]
pub fn with_direction(mut self, direction: WritingDir) -> Self {
self.direction = direction;
self
}
#[must_use]
pub fn rtl(self) -> Self {
self.with_direction(WritingDir::Rtl)
}
#[must_use]
pub fn is_rtl(&self) -> bool {
self.direction.is_rtl()
}
pub fn duotone(neutral_hue: f32, neutral_chroma: f32, accent_hue: f32, mode: Mode) -> Self {
let mut theme = Self::from_accent(accent_hue, mode);
let neutral_table = match mode {
Mode::Light => &NEUTRAL_LIGHT,
Mode::Dark => &NEUTRAL_DARK,
};
let hue = neutral_hue.rem_euclid(360.0);
let boost = neutral_chroma.clamp(0.0, 40.0);
let neutrals = Ramp(std::array::from_fn(|i| {
let (l, c) = neutral_table[i];
oklch(l, c * boost, hue)
}));
theme.apply_neutral_field(neutrals, hue, boost);
theme
}
pub fn derive(base: BaseField, accent_hue: f32, contrast: Contrast, mode: Mode) -> Self {
let mut theme = Self::from_accent(accent_hue, mode);
let neutral_table = match mode {
Mode::Light => &NEUTRAL_LIGHT,
Mode::Dark => &NEUTRAL_DARK,
};
let hue = base.hue.rem_euclid(360.0);
let chroma_mult = base.chroma.clamp(0.0, 40.0);
let k = contrast.factor();
let l_bg = neutral_table[0].0;
let neutrals = Ramp(std::array::from_fn(|i| {
let (l, c) = neutral_table[i];
let l = (l_bg + (l - l_bg) * k).clamp(0.0, 1.0);
oklch(l, c * chroma_mult, hue)
}));
theme.apply_neutral_field(neutrals, hue, chroma_mult);
theme
}
fn apply_neutral_field(&mut self, neutrals: Ramp, hue: f32, chroma_mult: f32) {
let accent_table = match self.mode {
Mode::Light => &ACCENT_LIGHT,
Mode::Dark => &ACCENT_DARK,
};
let bg = neutrals.step(1);
self.bg = bg;
self.surface = neutrals.step(2);
if matches!(self.mode, Mode::Dark) {
self.surface_raised = neutrals.step(3);
}
self.element = neutrals.step(3);
self.element_hover = neutrals.step(4);
self.element_active = neutrals.step(5);
self.border_subtle = neutrals.step(5);
self.border = neutrals.step(6);
self.border_strong = neutrals.step(7);
self.text = neutrals.step(12);
self.text_muted = neutrals.step(11);
self.text_subtle = neutrals.step(9);
self.text_disabled = neutrals.step(8);
if accent_table[8].0 >= 0.65 {
self.on_accent = neutrals.step(12);
}
self.neutral_alpha = alpha_ramp(&neutrals, bg);
self.accent_alpha = alpha_ramp(&self.accents, bg);
self.neutral_hue = hue;
self.neutral_chroma_mult = chroma_mult;
self.neutrals = neutrals;
}
pub fn accent_gradient(&self, angle_deg: f32) -> crate::style::Paint {
crate::style::linear_gradient(angle_deg, [self.accents.step(7), self.accents.step(10)])
}
pub fn light() -> Self {
Self::from_accent(262.0, Mode::Light)
}
pub fn dark() -> Self {
Self::from_accent(262.0, Mode::Dark)
}
pub fn shadow(&self, token: ShadowToken) -> Vec<Shadow> {
let factor = match self.mode {
Mode::Light => 1.0,
Mode::Dark => DARK_SHADOW_ALPHA_FACTOR,
};
let tint = self.shadow_tint();
shadow_layers(token)
.iter()
.map(|&(dy, blur, alpha)| Shadow {
dx: 0.0,
dy,
blur,
spread: 0.0,
color: tint.with_alpha((alpha * factor).min(1.0)),
})
.collect()
}
pub fn shadow_tint(&self) -> Color {
let [r, g, b, _] = self.bg.components;
if (r - g).abs() < 1e-4 && (g - b).abs() < 1e-4 {
return oklch(0.13, 0.0, 0.0);
}
let hue = self.bg.convert::<Oklch>().components[2];
let hue = if hue.is_nan() { 0.0 } else { hue };
oklch(0.13, 0.03, hue)
}
pub fn elevated_surface(&self, level: u8) -> Color {
match (self.mode, level) {
(_, 0) => self.surface,
(Mode::Light, _) => self.surface_raised,
(Mode::Dark, n) => {
let (l, c) = NEUTRAL_DARK[2];
oklch(
l + DARK_ELEVATION_TINT * f32::from(n - 1),
c * self.neutral_chroma_mult,
self.neutral_hue,
)
}
}
}
pub fn dump(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let mode = match self.mode {
Mode::Light => "light",
Mode::Dark => "dark",
};
writeln!(out, "theme: accent_hue {} mode {}", self.accent_hue, mode).unwrap();
writeln!(out, "\nneutrals:").unwrap();
for n in 1..=12 {
writeln!(out, " N{n}: {}", hex(self.neutrals.step(n))).unwrap();
}
writeln!(out, "\naccents:").unwrap();
for n in 1..=12 {
writeln!(out, " A{n}: {}", hex(self.accents.step(n))).unwrap();
}
writeln!(out, "\nneutral alpha twins (over bg):").unwrap();
for n in 1..=12 {
writeln!(out, " NA{n}: {}", hex(self.neutral_alpha.step(n))).unwrap();
}
writeln!(out, "\naccent alpha twins (over bg):").unwrap();
for n in 1..=12 {
writeln!(out, " AA{n}: {}", hex(self.accent_alpha.step(n))).unwrap();
}
for (name, s) in [
("danger", &self.danger),
("warning", &self.warning),
("success", &self.success),
] {
writeln!(out, "\n{name}:").unwrap();
writeln!(out, " bg: {}", hex(s.bg)).unwrap();
writeln!(out, " border: {}", hex(s.border)).unwrap();
writeln!(out, " solid: {}", hex(s.solid)).unwrap();
writeln!(out, " solid_hover: {}", hex(s.solid_hover)).unwrap();
writeln!(out, " solid_active: {}", hex(s.solid_active)).unwrap();
writeln!(out, " text: {}", hex(s.text)).unwrap();
}
writeln!(out, "\nroles:").unwrap();
for (name, c) in [
("bg", self.bg),
("surface", self.surface),
("surface_raised", self.surface_raised),
("element", self.element),
("element_hover", self.element_hover),
("element_active", self.element_active),
("border_subtle", self.border_subtle),
("border", self.border),
("border_strong", self.border_strong),
("text", self.text),
("text_muted", self.text_muted),
("text_subtle", self.text_subtle),
("text_disabled", self.text_disabled),
("accent", self.accent),
("accent_hover", self.accent_hover),
("accent_active", self.accent_active),
("accent_bg", self.accent_bg),
("accent_border", self.accent_border),
("accent_text", self.accent_text),
("on_accent", self.on_accent),
] {
writeln!(out, " {name}: {}", hex(c)).unwrap();
}
writeln!(out, "\nelevation:").unwrap();
for level in 0..=2 {
writeln!(
out,
" level {level}: {}",
hex(self.elevated_surface(level))
)
.unwrap();
}
writeln!(out, "\nshadows (dx dy blur spread color):").unwrap();
for (name, token) in [
("xs", ShadowToken::Xs),
("sm", ShadowToken::Sm),
("md", ShadowToken::Md),
("lg", ShadowToken::Lg),
("xl", ShadowToken::Xl),
] {
let layers: Vec<String> = self
.shadow(token)
.iter()
.map(|s| {
format!(
"({} {} {} {} {})",
s.dx,
s.dy,
s.blur,
s.spread,
hex(s.color)
)
})
.collect();
writeln!(out, " {name}: {}", layers.join(" + ")).unwrap();
}
out
}
pub fn contrast_report(&self) -> Vec<ContrastViolation> {
let mut out = Vec::new();
check_pair(&mut out, "text on bg", self.text, self.bg, PRIMARY_TEXT_MIN);
check_pair(
&mut out,
"text on surface",
self.text,
self.surface,
PRIMARY_TEXT_MIN,
);
check_pair(
&mut out,
"text on surface_raised",
self.text,
self.surface_raised,
PRIMARY_TEXT_MIN,
);
check_pair(
&mut out,
"text_muted on bg",
self.text_muted,
self.bg,
SECONDARY_TEXT_MIN,
);
check_pair(
&mut out,
"text_muted on surface",
self.text_muted,
self.surface,
SECONDARY_TEXT_MIN,
);
check_pair(
&mut out,
"text_muted on surface_raised",
self.text_muted,
self.surface_raised,
SECONDARY_TEXT_MIN,
);
check_pair(
&mut out,
"on_accent on accent",
self.on_accent,
self.accent,
CONTROL_LABEL_MIN,
);
check_pair(
&mut out,
"on_accent on accent_hover",
self.on_accent,
self.accent_hover,
CONTROL_LABEL_MIN,
);
check_pair(
&mut out,
"on_accent on accent_active",
self.on_accent,
self.accent_active,
CONTROL_LABEL_MIN,
);
check_pair(
&mut out,
"accent_text on bg",
self.accent_text,
self.bg,
COMPONENT_TEXT_MIN,
);
check_pair(
&mut out,
"accent_text on accent_bg",
self.accent_text,
self.accent_bg,
COMPONENT_TEXT_MIN,
);
for (name, s) in [
("danger", &self.danger),
("warning", &self.warning),
("success", &self.success),
] {
check_pair(
&mut out,
format!("{name}.text on {name}.bg"),
s.text,
s.bg,
COMPONENT_TEXT_MIN,
);
check_pair(
&mut out,
format!("{name}.text on bg"),
s.text,
self.bg,
COMPONENT_TEXT_MIN,
);
check_pair(
&mut out,
format!("on_accent on {name}.solid"),
self.on_accent,
s.solid,
CONTROL_LABEL_MIN,
);
}
out
}
pub fn validate_contrast(&self) -> Result<(), Vec<ContrastViolation>> {
let report = self.contrast_report();
if report.is_empty() {
Ok(())
} else {
Err(report)
}
}
#[must_use]
pub fn text_on(&self, bg: Color) -> Color {
let ink = self.neutrals.step(12);
let paper = self.neutrals.step(1);
if crate::apca::lc_abs(paper, bg) > crate::apca::lc_abs(ink, bg) {
paper
} else {
ink
}
}
#[must_use]
pub fn contrast_ok(&self, text: Color, bg: Color, size_px: f32, weight: f32) -> bool {
crate::apca::lc_abs(text, bg) >= crate::apca::required_lc(size_px, weight)
}
}
fn make_ramp(table: &RampTable, hue: f32) -> Ramp {
Ramp(std::array::from_fn(|i| {
let (l, c) = table[i];
oklch(l, c, hue)
}))
}
fn ramp_color(table: &RampTable, step: usize, hue: f32) -> Color {
let (l, c) = table[step - 1];
oklch(l, c, hue)
}
fn active_color(table: &RampTable, hue: f32) -> Color {
let (l, c) = table[9];
oklch((l - ACTIVE_DL).max(0.0), c, hue)
}
fn alpha_ramp(solid: &Ramp, bg: Color) -> Ramp {
Ramp(std::array::from_fn(|i| alpha_twin(solid.0[i], bg)))
}
fn alpha_twin(target: Color, bg: Color) -> Color {
let t = target.components;
let b = bg.components;
let mut a = 0.0_f32;
for ch in 0..3 {
let (tc, bc) = (t[ch], b[ch]);
let bound = if tc < bc {
if bc > 0.0 { 1.0 - tc / bc } else { 0.0 }
} else if tc > bc {
if bc < 1.0 {
(tc - bc) / (1.0 - bc)
} else {
1.0
}
} else {
0.0
};
a = a.max(bound);
}
let a = a.clamp(0.0, 1.0);
if a <= f32::EPSILON {
return Color::new([b[0], b[1], b[2], 0.0]);
}
let solve = |tc: f32, bc: f32| ((tc - bc * (1.0 - a)) / a).clamp(0.0, 1.0);
Color::new([solve(t[0], b[0]), solve(t[1], b[1]), solve(t[2], b[2]), a])
}
fn check_pair(
out: &mut Vec<ContrastViolation>,
pair: impl Into<String>,
text: Color,
bg: Color,
floor: f64,
) {
let measured_lc = crate::apca::lc_abs(text, bg);
if measured_lc < floor {
out.push(ContrastViolation {
pair: pair.into(),
measured_lc,
required_lc: floor,
});
}
}
pub fn oklch(l: f32, c: f32, h: f32) -> Color {
let convert = |chroma: f32| AlphaColor::<Oklch>::new([l, chroma, h, 1.0]).convert::<Srgb>();
let mut srgb = convert(c);
if !in_gamut(srgb) {
let (mut lo, mut hi) = (0.0_f32, c);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if in_gamut(convert(mid)) {
lo = mid;
} else {
hi = mid;
}
}
srgb = convert(lo);
}
let [r, g, b, a] = srgb.components;
Color::new([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0), a])
}
pub fn oklch_of(color: Color) -> [f32; 3] {
let [l, c, h, _] = color.convert::<Oklch>().components;
[l, c, if h.is_nan() { 0.0 } else { h }]
}
fn in_gamut(c: AlphaColor<Srgb>) -> bool {
c.components[..3]
.iter()
.all(|&v| (-1e-4..=1.0 + 1e-4).contains(&v))
}
fn hex(c: Color) -> String {
let rgba = c.to_rgba8();
if rgba.a == 255 {
format!("#{:02x}{:02x}{:02x}", rgba.r, rgba.g, rgba.b)
} else {
format!("#{:02x}{:02x}{:02x}{:02x}", rgba.r, rgba.g, rgba.b, rgba.a)
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThemeSpec {
pub mode: Mode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent_hue: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duotone: Option<DuotoneSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derive: Option<DeriveSpec>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DuotoneSpec {
pub neutral_hue: f32,
pub chroma: f32,
pub accent_hue: f32,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeriveSpec {
pub base_hue: f32,
pub base_chroma: f32,
pub accent_hue: f32,
#[serde(default)]
pub contrast: Contrast,
}
impl ThemeSpec {
pub fn theme(&self) -> Theme {
if let Some(d) = &self.derive {
return Theme::derive(
BaseField {
hue: d.base_hue,
chroma: d.base_chroma,
},
d.accent_hue,
d.contrast,
self.mode,
);
}
if let Some(d) = &self.duotone {
return Theme::duotone(d.neutral_hue, d.chroma, d.accent_hue, self.mode);
}
if let Some(hue) = self.accent_hue {
return Theme::from_accent(hue, self.mode);
}
match self.mode {
Mode::Light => Theme::light(),
Mode::Dark => Theme::dark(),
}
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("ThemeSpec serializes")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lightness(c: Color) -> f32 {
c.convert::<Oklch>().components[0]
}
fn lch_hue(c: Color) -> f32 {
c.convert::<Oklch>().components[2]
}
fn hue_delta(a: f32, b: f32) -> f32 {
let d = (a - b).rem_euclid(360.0);
d.min(360.0 - d)
}
fn composite_over(fg: Color, bg: Color) -> [f32; 3] {
let f = fg.components;
let b = bg.components;
let a = f[3];
std::array::from_fn(|i| f[i] * a + b[i] * (1.0 - a))
}
fn representative_themes() -> Vec<(String, Theme)> {
let mut v = Vec::new();
for mode in [Mode::Light, Mode::Dark] {
v.push((format!("base {mode:?}"), Theme::from_accent(262.0, mode)));
v.push((
format!("editorial {mode:?}"),
Theme::duotone(152.0, 6.0, 72.0, mode),
));
v.push((
format!("terminal {mode:?}"),
Theme::from_accent(145.0, mode),
));
v.push((
format!("warm-editorial {mode:?}"),
Theme::derive(
BaseField {
hue: 80.0,
chroma: 2.5,
},
40.0,
Contrast::High,
mode,
),
));
v.push((
format!("playful {mode:?}"),
Theme::derive(
BaseField {
hue: 280.0,
chroma: 2.0,
},
330.0,
Contrast::Standard,
mode,
),
));
}
v
}
fn representative_surfaces(t: &Theme) -> [(&'static str, Color); 7] {
[
("bg", t.bg),
("surface", t.surface),
("surface_raised", t.surface_raised),
("accent", t.accent),
("danger.solid", t.danger.solid),
("warning.solid", t.warning.solid),
("success.solid", t.success.solid),
]
}
#[test]
fn text_on_is_legible_on_every_surface() {
for (name, t) in representative_themes() {
for (bn, bg) in representative_surfaces(&t) {
let lc = crate::apca::lc_abs(t.text_on(bg), bg);
assert!(
lc >= SECONDARY_TEXT_MIN,
"{name}: text_on({bn}) only reached Lc {lc:.2}"
);
}
}
}
#[test]
fn text_on_picks_the_winning_extreme() {
for (name, t) in representative_themes() {
let ink = t.neutrals.step(12);
let paper = t.neutrals.step(1);
for (bn, bg) in representative_surfaces(&t) {
let want = if crate::apca::lc_abs(paper, bg) > crate::apca::lc_abs(ink, bg) {
paper
} else {
ink
};
assert_eq!(
t.text_on(bg).to_rgba8(),
want.to_rgba8(),
"{name}: text_on({bn}) did not pick the winning extreme"
);
}
}
}
#[test]
fn contrast_ok_tracks_required_lc() {
let t = Theme::light();
assert!(t.contrast_ok(t.text, t.bg, 16.0, 400.0));
assert!(!t.contrast_ok(t.neutrals.step(6), t.bg, 16.0, 400.0));
assert!(t.contrast_ok(t.text_subtle, t.surface, 30.0, 400.0));
assert!(!t.contrast_ok(t.text_subtle, t.surface, 12.0, 400.0));
}
#[test]
fn role_floors_agree_with_required_lc() {
assert_eq!(PRIMARY_TEXT_MIN, 75.0);
assert_eq!(CONTROL_LABEL_MIN, 60.0);
assert_eq!(SECONDARY_TEXT_MIN, 55.0);
assert_eq!(COMPONENT_TEXT_MIN, 40.0);
assert!((crate::apca::required_lc(16.0, 400.0) - 75.0).abs() <= 2.0);
assert!(PRIMARY_TEXT_MIN >= crate::apca::required_lc(16.0, 400.0));
assert!(CONTROL_LABEL_MIN >= crate::apca::required_lc(25.0, 400.0)); assert!(SECONDARY_TEXT_MIN >= crate::apca::required_lc(31.0, 400.0)); assert!(COMPONENT_TEXT_MIN >= crate::apca::required_lc(50.0, 400.0));
const {
assert!(
PRIMARY_TEXT_MIN > CONTROL_LABEL_MIN
&& CONTROL_LABEL_MIN > SECONDARY_TEXT_MIN
&& SECONDARY_TEXT_MIN > COMPONENT_TEXT_MIN
);
}
for floor in [
PRIMARY_TEXT_MIN,
CONTROL_LABEL_MIN,
SECONDARY_TEXT_MIN,
COMPONENT_TEXT_MIN,
] {
assert!(floor <= crate::apca::required_lc(16.0, 400.0));
assert!(floor >= 15.0); }
}
#[test]
fn alpha_twins_composite_back_to_solid_steps() {
for theme in [Theme::light(), Theme::dark()] {
let bg = theme.bg;
for n in 1..=12 {
for (solid, twin) in [
(theme.neutrals.step(n), theme.neutral_alpha.step(n)),
(theme.accents.step(n), theme.accent_alpha.step(n)),
] {
let got = composite_over(twin, bg);
let want = solid.components;
for ch in 0..3 {
assert!(
(got[ch] - want[ch]).abs() < 1e-4,
"step {n} channel {ch}: twin over bg = {got:?}, want {want:?}"
);
}
}
}
}
}
#[test]
fn element_roles_are_neutral_steps_3_4_5() {
for theme in [Theme::light(), Theme::dark()] {
assert_eq!(theme.element.to_rgba8(), theme.neutrals.step(3).to_rgba8());
assert_eq!(
theme.element_hover.to_rgba8(),
theme.neutrals.step(4).to_rgba8()
);
assert_eq!(
theme.element_active.to_rgba8(),
theme.neutrals.step(5).to_rgba8()
);
}
}
#[test]
fn pressed_states_are_darker_than_hover() {
for theme in [Theme::light(), Theme::dark()] {
assert!(
lightness(theme.accent_active) < lightness(theme.accent_hover),
"accent_active must be darker than accent_hover"
);
for status in [theme.danger, theme.warning, theme.success] {
assert!(
lightness(status.solid_active) < lightness(status.solid_hover),
"solid_active must be darker than solid_hover"
);
}
}
}
#[test]
fn light_accent_active_lands_on_a11_lightness() {
let active_l = lightness(Theme::light().accent_active);
assert!(
(active_l - 0.500).abs() < 0.01,
"light accent_active L = {active_l}, expected ~0.500"
);
}
#[test]
fn pressed_states_are_mode_invariant() {
let (l, d) = (Theme::light(), Theme::dark());
assert_eq!(l.accent_active.to_rgba8(), d.accent_active.to_rgba8());
assert_eq!(
l.danger.solid_active.to_rgba8(),
d.danger.solid_active.to_rgba8()
);
}
#[test]
fn elevated_surface_level_1_equals_surface_raised() {
for theme in [Theme::dark(), Theme::duotone(152.0, 6.0, 72.0, Mode::Dark)] {
assert_eq!(
theme.elevated_surface(1).to_rgba8(),
theme.surface_raised.to_rgba8()
);
}
}
#[test]
fn duotone_dark_elevation_tracks_the_field_not_the_accent() {
let t = Theme::duotone(152.0, 6.0, 72.0, Mode::Dark);
let field = lch_hue(t.surface_raised);
let lifted = lch_hue(t.elevated_surface(2));
assert!(
hue_delta(lifted, field) < 25.0,
"elev(2) hue {lifted} vs field {field}"
);
assert!(
hue_delta(lifted, 72.0) > 40.0,
"elev(2) must not be the accent hue 72"
);
}
#[test]
fn shadow_tint_is_neutral_for_gray_themes_and_hued_otherwise() {
let [r, g, b, _] = Theme::duotone(152.0, 0.0, 72.0, Mode::Dark)
.shadow_tint()
.components;
assert!(
(r - g).abs() < 1e-3 && (g - b).abs() < 1e-3,
"gray theme shadow must be neutral, got {:?}",
[r, g, b]
);
let [r2, _, b2, _] = Theme::dark().shadow_tint().components;
assert!(
(r2 - b2).abs() > 1e-3,
"stock shadow tint should carry a hue"
);
}
#[test]
fn derive_reproduces_from_accent_and_duotone() {
for mode in [Mode::Light, Mode::Dark] {
let as_accent = Theme::derive(
BaseField {
hue: 262.0,
chroma: 1.0,
},
262.0,
Contrast::Standard,
mode,
);
assert_eq!(as_accent.dump(), Theme::from_accent(262.0, mode).dump());
let as_duotone = Theme::derive(
BaseField {
hue: 152.0,
chroma: 6.0,
},
72.0,
Contrast::Standard,
mode,
);
assert_eq!(
as_duotone.dump(),
Theme::duotone(152.0, 6.0, 72.0, mode).dump()
);
}
}
#[test]
fn derive_contrast_orders_legibility_and_stays_legible() {
let base = BaseField {
hue: 262.0,
chroma: 1.0,
};
let low = Theme::derive(base, 262.0, Contrast::Low, Mode::Light);
let std = Theme::derive(base, 262.0, Contrast::Standard, Mode::Light);
let high = Theme::derive(base, 262.0, Contrast::High, Mode::Light);
let lc = |t: &Theme| crate::apca::lc_abs(t.text, t.bg);
assert!(lc(&high) > lc(&std), "High must be crisper than Standard");
assert!(lc(&std) > lc(&low), "Standard must be crisper than Low");
for t in [&low, &std, &high] {
assert!(
t.validate_contrast().is_ok(),
"derived theme failed contrast: {:?}",
t.validate_contrast()
);
}
}
#[test]
fn derive_recipe_round_trips() {
let spec = ThemeSpec {
mode: Mode::Dark,
accent_hue: None,
duotone: None,
derive: Some(DeriveSpec {
base_hue: 90.0,
base_chroma: 2.0,
accent_hue: 40.0,
contrast: Contrast::High,
}),
};
let json = spec.to_json();
let back = ThemeSpec::from_json(&json).expect("round-trip");
assert_eq!(spec, back);
let direct = Theme::derive(
BaseField {
hue: 90.0,
chroma: 2.0,
},
40.0,
Contrast::High,
Mode::Dark,
);
assert_eq!(spec.theme().dump(), direct.dump());
}
}