use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::str::FromStr; use std::sync::{OnceLock, RwLock}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; pub use crate::builtins::builtin_registry; fn custom_families() -> &'static RwLock> { static CUSTOM: OnceLock>> = OnceLock::new(); CUSTOM.get_or_init(|| RwLock::new(Vec::new())) } /// Replace the process-wide custom portion of the runtime registry. pub fn replace_custom_families(families: Vec) { *custom_families() .write() .expect("custom theme registry lock was poisoned") = families; } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Appearance { #[default] Dark, Light, } impl Appearance { pub const ALL: [Self; 2] = [Self::Light, Self::Dark]; pub fn is_dark(self) -> bool { matches!(self, Self::Dark) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SurfaceTreatment { #[default] Opaque, Frosted, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SurfacePreference { #[default] ThemeDefault, Frosted, Opaque, } impl SurfacePreference { pub const ALL: [Self; 3] = [Self::ThemeDefault, Self::Frosted, Self::Opaque]; pub fn resolve(self, recommended: SurfaceTreatment) -> SurfaceTreatment { match self { Self::ThemeDefault => recommended, Self::Frosted => SurfaceTreatment::Frosted, Self::Opaque => SurfaceTreatment::Opaque, } } } /// A color serialized as `#rrggbb` or `#rrggbbaa`. #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct Color { pub r: u8, pub g: u8, pub b: u8, pub a: u8, } impl Color { pub const BLACK: Self = Self::rgb(0, 0, 0); pub const WHITE: Self = Self::rgb(255, 255, 255); pub const fn rgb(r: u8, g: u8, b: u8) -> Self { Self { r, g, b, a: 255 } } pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { Self { r, g, b, a } } pub fn with_alpha(self, alpha: f32) -> Self { Self { a: (alpha.clamp(0.0, 1.0) * 255.0).round() as u8, ..self } } pub fn blend_over(self, background: Self) -> Self { let alpha = self.a as f32 / 255.0; let blend = |front: u8, back: u8| { (front as f32 * alpha + back as f32 * (1.0 - alpha)).round() as u8 }; Self::rgb( blend(self.r, background.r), blend(self.g, background.g), blend(self.b, background.b), ) } pub fn mix(self, other: Self, amount: f32) -> Self { let amount = amount.clamp(0.0, 1.0); let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * amount).round() as u8; Self::rgba( mix(self.r, other.r), mix(self.g, other.g), mix(self.b, other.b), mix(self.a, other.a), ) } pub fn contrast(self, background: Self) -> f32 { let foreground = if self.a == 255 { self } else { self.blend_over(background) }; let (a, b) = (foreground.luminance(), background.luminance()); (a.max(b) + 0.05) / (a.min(b) + 0.05) } pub fn best_on_color(self) -> Self { if Self::WHITE.contrast(self) >= Self::BLACK.contrast(self) { Self::WHITE } else { Self::BLACK } } /// Move toward black or white until the requested contrast is met. pub fn ensure_contrast(self, background: Self, minimum: f32) -> Self { if self.contrast(background) >= minimum { return self; } let target = if Self::BLACK.contrast(background) >= Self::WHITE.contrast(background) { Self::BLACK } else { Self::WHITE }; for step in 1..=20 { let candidate = self.mix(target, step as f32 / 20.0); if candidate.contrast(background) >= minimum { return candidate; } } target } fn luminance(self) -> f32 { let linear = |channel: u8| { let value = channel as f32 / 255.0; if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) } }; 0.2126 * linear(self.r) + 0.7152 * linear(self.g) + 0.0722 * linear(self.b) } } impl fmt::Debug for Color { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self) } } impl fmt::Display for Color { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.a == 255 { write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b) } else { write!( f, "#{:02x}{:02x}{:02x}{:02x}", self.r, self.g, self.b, self.a ) } } } impl FromStr for Color { type Err = ColorParseError; fn from_str(value: &str) -> Result { let value = value.trim().strip_prefix('#').ok_or(ColorParseError)?; let expand = |c: u8| (c << 4) | c; let nibble = |c: u8| match c { b'0'..=b'9' => Ok(c - b'0'), b'a'..=b'f' => Ok(c - b'a' + 10), b'A'..=b'F' => Ok(c - b'A' + 10), _ => Err(ColorParseError), }; let pair = |bytes: &[u8]| -> Result { Ok((nibble(bytes[0])? << 4) | nibble(bytes[1])?) }; let bytes = value.as_bytes(); match bytes.len() { 3 => Ok(Self::rgb( expand(nibble(bytes[0])?), expand(nibble(bytes[1])?), expand(nibble(bytes[2])?), )), 4 => Ok(Self::rgba( expand(nibble(bytes[0])?), expand(nibble(bytes[1])?), expand(nibble(bytes[2])?), expand(nibble(bytes[3])?), )), 6 => Ok(Self::rgb( pair(&bytes[0..2])?, pair(&bytes[2..4])?, pair(&bytes[4..6])?, )), 8 => Ok(Self::rgba( pair(&bytes[0..2])?, pair(&bytes[2..4])?, pair(&bytes[4..6])?, pair(&bytes[6..8])?, )), _ => Err(ColorParseError), } } } impl Serialize for Color { fn serialize(&self, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&self.to_string()) } } impl<'de> Deserialize<'de> for Color { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { String::deserialize(deserializer)? .parse() .map_err(serde::de::Error::custom) } } #[derive(Debug, Clone, Copy, thiserror::Error)] #[error("expected a CSS hex color (#rgb, #rgba, #rrggbb, or #rrggbbaa)")] pub struct ColorParseError; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AccentPreset { #[default] Zeron, Orange, Amber, Green, Cyan, Blue, Pink, } impl AccentPreset { pub const ALL: [Self; 7] = [ Self::Zeron, Self::Orange, Self::Amber, Self::Green, Self::Cyan, Self::Blue, Self::Pink, ]; pub fn label(self) -> &'static str { match self { Self::Zeron => "Zeron", Self::Orange => "Orange", Self::Amber => "Amber", Self::Green => "Green", Self::Cyan => "Cyan", Self::Blue => "Blue", Self::Pink => "Pink", } } pub fn color(self, appearance: Appearance) -> Color { let (dark, light) = match self { Self::Zeron => ("#8b7cf6", "#5b43e8"), Self::Orange => ("#fb923c", "#c2410c"), Self::Amber => ("#fbbf24", "#a16207"), Self::Green => ("#4ade80", "#15803d"), Self::Cyan => ("#22d3ee", "#0e7490"), Self::Blue => ("#60a5fa", "#2563eb"), Self::Pink => ("#f472b6", "#be185d"), }; if appearance.is_dark() { dark } else { light } .parse() .expect("built-in accent colors are valid") } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AccentSelection { #[default] ThemeDefault, Preset(AccentPreset), } impl AccentSelection { pub fn label(self) -> &'static str { match self { Self::ThemeDefault => "Theme default", Self::Preset(preset) => preset.label(), } } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct AccentRoles { pub primary: Color, pub strong: Color, pub wash: Color, pub on: Color, pub selection: Color, pub caret: Color, pub activity: Color, pub glyph: [Color; 3], } impl AccentRoles { pub fn derive(primary: Color, appearance: Appearance, background: Color) -> Self { let primary = primary.ensure_contrast(background, 3.0); let on = primary.best_on_color(); let mut strong = primary; if on.contrast(strong) < 4.5 { strong = strong.ensure_contrast(on, 4.5); } let light = primary.mix( if appearance.is_dark() { Color::WHITE } else { background }, if appearance.is_dark() { 0.28 } else { 0.18 }, ); let deep = primary.mix(Color::BLACK, if appearance.is_dark() { 0.18 } else { 0.26 }); Self { primary, strong, wash: primary.with_alpha(if appearance.is_dark() { 0.22 } else { 0.12 }), on, selection: primary.with_alpha(if appearance.is_dark() { 0.35 } else { 0.24 }), caret: primary, activity: primary, glyph: [light, primary, deep], } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThemeSelection { pub light: String, pub dark: String, } impl Default for ThemeSelection { fn default() -> Self { Self { light: "zeron-light".into(), dark: "zeron-dark".into(), } } } impl ThemeSelection { pub fn variant_id(&self, appearance: Appearance) -> &str { if appearance.is_dark() { &self.dark } else { &self.light } } pub fn set_variant(&mut self, appearance: Appearance, id: impl Into) { if appearance.is_dark() { self.dark = id.into(); } else { self.light = id.into(); } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThemeSource { pub format: String, pub url: String, pub revision: String, pub license: String, pub asset_hash: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThemeColors { pub background: Color, pub shell: Color, pub raised: Color, pub card: Color, pub dialog: Color, pub overlay: Color, pub hover: Color, pub active: Color, pub border: Color, pub border_strong: Color, pub text: Color, pub text_muted: Color, pub text_faint: Color, pub solid: Color, pub on_solid: Color, pub danger: Color, pub danger_muted: Color, pub warning: Color, pub warning_muted: Color, pub success: Color, pub success_muted: Color, pub input: Color, pub cursor: Color, pub diff_add: Color, pub diff_delete: Color, pub diff_hunk: Color, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TerminalPalette { pub background: Color, pub foreground: Color, pub selection: Color, pub ansi: [Color; 16], } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThemeVariant { pub id: String, pub family_id: String, pub name: String, pub appearance: Appearance, /// The author's recommended treatment. #[serde(alias = "surfaceTreatment")] pub recommended_surface_treatment: SurfaceTreatment, pub colors: ThemeColors, pub accent: AccentRoles, pub syntax: BTreeMap, pub terminal: TerminalPalette, pub source: ThemeSource, } impl ThemeVariant { pub fn accent_for(&self, selection: AccentSelection) -> AccentRoles { match selection { AccentSelection::ThemeDefault => self.accent, AccentSelection::Preset(preset) => AccentRoles::derive( preset.color(self.appearance), self.appearance, self.colors.background, ), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThemeFamily { pub id: String, pub name: String, pub variants: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ThemeRegistry { pub families: Vec, } impl ThemeRegistry { pub fn builtin() -> &'static Self { builtin_registry() } /// Built-ins plus the currently installed custom families. pub fn active() -> Self { let mut families = Self::builtin().families.clone(); families.extend( custom_families() .read() .expect("custom theme registry lock was poisoned") .iter() .cloned(), ); Self { families } } pub fn variant(&self, id: &str) -> Option<&ThemeVariant> { self.families .iter() .flat_map(|family| &family.variants) .find(|variant| variant.id == id) } pub fn variants_for(&self, appearance: Appearance) -> impl Iterator { self.families .iter() .flat_map(|family| &family.variants) .filter(move |variant| variant.appearance == appearance) } pub fn resolve(&self, selection: &ThemeSelection, appearance: Appearance) -> &ThemeVariant { self.variant(selection.variant_id(appearance)) .or_else(|| { self.variant(if appearance.is_dark() { "zeron-dark" } else { "zeron-light" }) }) .expect("the built-in registry always contains both Zeron variants") } pub fn validate(&self) -> Vec { let mut issues = Vec::new(); let mut ids = HashSet::new(); for family in &self.families { for variant in &family.variants { if variant.id.trim().is_empty() || !ids.insert(&variant.id) { issues.push(ValidationIssue::structural_error( &variant.id, "variant id must be unique", )); } validate_contrast( &mut issues, variant, "text", variant.colors.text, variant.colors.background, 4.5, ); validate_contrast( &mut issues, variant, "muted text", variant.colors.text_muted, variant.colors.background, 4.5, ); validate_contrast( &mut issues, variant, "accent", variant.accent.primary, variant.colors.background, 3.0, ); validate_contrast( &mut issues, variant, "on-accent", variant.accent.on, variant.accent.strong, 4.5, ); validate_contrast( &mut issues, variant, "terminal foreground", variant.terminal.foreground, variant.terminal.background, 4.5, ); if variant.source.url.is_empty() || variant.source.revision.is_empty() || variant.source.license.is_empty() || variant.source.asset_hash.is_empty() { issues.push(ValidationIssue::structural_error( &variant.id, "source provenance is incomplete", )); } for (index, color) in variant.terminal.ansi.iter().enumerate() { // Slots 0 and 8 are structural black/dim colors. if index % 8 == 0 { continue; } if color.contrast(variant.terminal.background) < 3.0 { issues.push(ValidationIssue::contrast_warning( &variant.id, format!("terminal ANSI slot {index} is below 3:1"), )); } } } } issues } } fn validate_contrast( issues: &mut Vec, variant: &ThemeVariant, role: &str, foreground: Color, background: Color, minimum: f32, ) { let actual = foreground.contrast(background); if actual < minimum { issues.push(ValidationIssue::contrast_error( &variant.id, format!("{role} contrast is {actual:.2}:1; expected {minimum:.1}:1"), )); } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ValidationSeverity { Warning, Error, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ValidationCategory { Structural, #[default] Contrast, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ValidationIssue { pub variant_id: String, #[serde(default)] pub category: ValidationCategory, pub severity: ValidationSeverity, pub message: String, } impl ValidationIssue { fn structural_error(variant_id: &str, message: impl Into) -> Self { Self { variant_id: variant_id.into(), category: ValidationCategory::Structural, severity: ValidationSeverity::Error, message: message.into(), } } fn contrast_error(variant_id: &str, message: impl Into) -> Self { Self { variant_id: variant_id.into(), category: ValidationCategory::Contrast, severity: ValidationSeverity::Error, message: message.into(), } } fn contrast_warning(variant_id: &str, message: impl Into) -> Self { Self { variant_id: variant_id.into(), category: ValidationCategory::Contrast, severity: ValidationSeverity::Warning, message: message.into(), } } pub fn is_blocking(&self) -> bool { self.category == ValidationCategory::Structural && self.severity == ValidationSeverity::Error } } /// Stable visual QA scenes every bundled variant must be reviewed against. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum VisualFixture { Sidebar, TranscriptMarkdown, TranscriptCode, Composer, PickerPopover, AppearanceSettings, Diff, Terminal, EmptyState, Dialog, } impl VisualFixture { pub const ALL: [Self; 10] = [ Self::Sidebar, Self::TranscriptMarkdown, Self::TranscriptCode, Self::Composer, Self::PickerPopover, Self::AppearanceSettings, Self::Diff, Self::Terminal, Self::EmptyState, Self::Dialog, ]; } #[cfg(test)] mod tests { use super::*; #[test] fn colors_round_trip_all_supported_css_hex_lengths() { for source in ["#abc", "#abcd", "#102030", "#10203040"] { let color: Color = source.parse().unwrap(); let json = serde_json::to_string(&color).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), color); } } #[test] fn accent_overlay_meets_interaction_contrast() { for appearance in Appearance::ALL { let background = if appearance.is_dark() { Color::rgb(6, 6, 6) } else { Color::WHITE }; for preset in AccentPreset::ALL { let roles = AccentRoles::derive(preset.color(appearance), appearance, background); assert!( roles.primary.contrast(background) >= 3.0, "{appearance:?} {preset:?}" ); assert!( roles.on.contrast(roles.strong) >= 4.5, "{appearance:?} {preset:?}" ); } } } #[test] fn surface_preference_resolves_independently_from_theme_recommendation() { assert_eq!( SurfacePreference::ThemeDefault.resolve(SurfaceTreatment::Frosted), SurfaceTreatment::Frosted ); assert_eq!( SurfacePreference::ThemeDefault.resolve(SurfaceTreatment::Opaque), SurfaceTreatment::Opaque ); for recommended in [SurfaceTreatment::Frosted, SurfaceTreatment::Opaque] { assert_eq!( SurfacePreference::Frosted.resolve(recommended), SurfaceTreatment::Frosted ); assert_eq!( SurfacePreference::Opaque.resolve(recommended), SurfaceTreatment::Opaque ); } } #[test] fn builtins_have_complete_provenance_and_no_validation_errors() { let registry = ThemeRegistry::builtin(); assert_eq!(registry.families.len(), 19); assert!(registry.variant("zeron-light").is_some()); assert!(registry.variant("zeron-dark").is_some()); let errors: Vec<_> = registry .validate() .into_iter() .filter(|issue| issue.severity == ValidationSeverity::Error) .collect(); assert!(errors.is_empty(), "{errors:#?}"); } #[test] fn visual_fixture_matrix_covers_every_variant_and_scene() { let registry = ThemeRegistry::builtin(); let variants = registry .families .iter() .map(|family| family.variants.len()) .sum::(); assert_eq!(variants, 30); assert_eq!(variants * VisualFixture::ALL.len(), 300); } }