use crate::theme::register::ThemeCandidate;
use crate::theme::registry::{derive_tokens, SeedColors};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Palette {
pub id: String,
pub label: String,
pub dark: bool,
pub bg: String,
pub surface: String,
pub surface_raised: String,
pub text: String,
pub text_muted: String,
pub text_faint: String,
pub accent: String,
pub accent_alt: String,
pub ok: String,
pub warn: String,
pub error: String,
pub info: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BadHex {
pub field: &'static str,
pub value: String,
}
impl std::fmt::Display for BadHex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: '{}' is not hex (#rgb, #rrggbb or #rrggbbaa)",
self.field, self.value
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PaletteError {
BadHex(Vec<BadHex>),
}
impl std::fmt::Display for PaletteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PaletteError::BadHex(bad) => {
write!(f, "palette: {} malformed color(s)", bad.len())?;
for b in bad {
write!(f, "\n {b}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for PaletteError {}
impl Palette {
pub fn new(id: impl Into<String>, label: impl Into<String>, dark: bool) -> Palette {
Palette {
id: id.into(),
label: label.into(),
dark,
..Palette::default()
}
}
pub fn derive(&self) -> Result<ThemeCandidate, PaletteError> {
let mut bad: Vec<BadHex> = Vec::new();
let mut parse = |field: &'static str, value: &str| {
crate::base::Rgba::from_hex(value).unwrap_or_else(|| {
bad.push(BadHex {
field,
value: value.to_string(),
});
crate::base::Rgba::BLACK
})
};
let colors = SeedColors {
bg: parse("bg", &self.bg),
surface: parse("surface", &self.surface),
surface_raised: parse("surface_raised", &self.surface_raised),
text: parse("text", &self.text),
text_muted: parse("text_muted", &self.text_muted),
text_faint: parse("text_faint", &self.text_faint),
accent: parse("accent", &self.accent),
accent_alt: parse("accent_alt", &self.accent_alt),
ok: parse("ok", &self.ok),
warn: parse("warn", &self.warn),
error: parse("error", &self.error),
info: parse("info", &self.info),
};
if !bad.is_empty() {
return Err(PaletteError::BadHex(bad));
}
Ok(ThemeCandidate {
id: self.id.clone(),
label: self.label.clone(),
dark: self.dark,
tokens: derive_tokens(&colors, self.dark),
ground_intent: Vec::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::register::{register, RegisterMode};
use crate::theme::registry::themes;
use crate::theme::seeds::{ThemeSeed, SEEDS};
fn palette_of(seed: &ThemeSeed) -> Palette {
Palette {
id: seed.id.to_string(),
label: seed.label.to_string(),
dark: seed.dark,
bg: seed.bg.to_string(),
surface: seed.surface.to_string(),
surface_raised: seed.surface_raised.to_string(),
text: seed.text.to_string(),
text_muted: seed.text_muted.to_string(),
text_faint: seed.text_faint.to_string(),
accent: seed.accent.to_string(),
accent_alt: seed.accent_alt.to_string(),
ok: seed.ok.to_string(),
warn: seed.warn.to_string(),
error: seed.error.to_string(),
info: seed.info.to_string(),
}
}
#[test]
fn palette_derives_like_the_builtin_table() {
assert_eq!(SEEDS.len(), themes().len(), "seed/theme count drifted");
for (seed, theme) in SEEDS.iter().zip(themes()) {
assert_eq!(seed.id, theme.id, "seed order drifted from theme order");
let candidate = palette_of(seed)
.derive()
.unwrap_or_else(|e| panic!("{}: public path refused its own seed: {e}", seed.id));
assert_eq!(
candidate.tokens, theme.tokens,
"{}: public derivation diverged from registry::build",
seed.id
);
assert_eq!(candidate.dark, theme.dark, "{}: polarity", seed.id);
}
}
#[test]
fn the_identity_guard_fails_when_a_seed_is_perturbed() {
let seed = &SEEDS[0];
let mut p = palette_of(seed);
p.accent = "#e94561".into(); let perturbed = p.derive().expect("valid hex").tokens;
assert_ne!(
perturbed,
themes()[0].tokens,
"a one-unit accent change produced identical tokens: the \
identity test is comparing something other than the derivation"
);
}
#[test]
fn malformed_hex_errors_rather_than_panicking() {
let mut p = palette_of(&SEEDS[0]);
p.surface = "not-a-color".into();
p.warn = "#12345".into(); let err = p.derive().expect_err("malformed hex must not derive");
let PaletteError::BadHex(bad) = &err;
assert_eq!(
bad.iter().map(|b| b.field).collect::<Vec<_>>(),
["surface", "warn"]
);
assert_eq!(bad[0].value, "not-a-color");
assert!(err.to_string().contains("surface"), "{err}");
assert!(err.to_string().contains("warn"), "{err}");
}
#[test]
fn an_empty_palette_is_an_error_not_a_black_theme() {
let err = Palette::new("blank", "Blank", true)
.derive()
.expect_err("empty colors must not derive to black-on-black");
let PaletteError::BadHex(bad) = &err;
assert_eq!(bad.len(), 12, "every unfilled color should be named");
}
#[test]
fn a_derived_palette_registers_through_the_existing_audited_door() {
let mut p = palette_of(&SEEDS[0]);
p.id = "palette-door-probe".into();
p.label = "Door probe".into();
let reg = register(p.derive().expect("valid hex"), RegisterMode::Strict)
.expect("abstract-dark's colors pass the audit they were built against");
assert_eq!(reg.theme.id, "palette-door-probe");
assert!(reg.warnings.is_empty(), "strict mode: {:?}", reg.warnings);
assert_eq!(
reg.theme.tokens,
themes()[0].tokens,
"registration must not alter the derived tokens"
);
}
#[test]
fn the_audit_still_refuses_an_unreadable_palette() {
let mut p = palette_of(&SEEDS[0]);
p.id = "palette-unreadable-probe".into();
p.text = p.bg.clone(); let err = register(p.derive().expect("valid hex"), RegisterMode::Strict)
.expect_err("text == bg must not register in strict mode");
assert!(
matches!(err, crate::theme::RegisterError::Rejected { .. }),
"{err}"
);
}
}