diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cc3a31b..e799e55 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -115,6 +115,7 @@ covers internals). When you ship a feature, add it here. | Chat drawer toggle (drawer layout) | πŸ§ͺ | Toggle + restart-persistence not explicitly confirmed. | | Responsive full-window Settings screen | βœ… | | | Canvas-drawn icon set | βœ… | 18 icons (`Icon`/`IconKind` in `src/app/mod.rs`), no image/font dep, theme-recolored. Replaces all emoji; SVG design source in `~/Documents/peerspeak-mockups/icons/`. | +| Selectable UI themes | βœ… | 10 palettes (`src/theme.rs`): Catppuccin Mocha (default)/Macchiato/FrappΓ©/Latte, Dracula, Nord, Tokyo Night, Gruvbox Dark, Solarized Light, Gruvbox Light. Picked via canvas palette swatches in Settings (`ThemeSwatch`), applied live + persisted (`config.theme`). All palettes unit-tested for WCAG-AA text legibility. | ## Persistence diff --git a/src/app/mod.rs b/src/app/mod.rs index 11c6cc9..5ede090 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3,6 +3,7 @@ use crate::network::PeerState; use crate::notify::{self, Sound}; use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::config::{AppConfig, NetworkMode, RoomLayout}; +use crate::theme::{AppTheme, Palette}; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, @@ -150,6 +151,8 @@ pub enum AppMessage { CloseLayoutPicker, /// Choose a room layout (applied live + persisted, closes the popup). SelectRoomLayout(RoomLayout), + /// Choose a UI theme (applied live + persisted). + SelectTheme(AppTheme), /// Toggle the Chat drawer open/closed (drawer layout). ToggleDrawerChat, /// Start/stop sharing our own screen (spawns/kills a pixelpass host). @@ -317,8 +320,8 @@ impl Default for AppState { } } -fn theme(_state: &AppState) -> Theme { - Theme::Dark +fn theme(state: &AppState) -> Theme { + state.config.theme.base_theme() } pub fn run_gui() -> iced::Result { @@ -742,6 +745,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.config.save(); state.layout_picker_open = false; } + AppMessage::SelectTheme(theme) => { + state.config.theme = theme; + state.config.save(); + } AppMessage::ToggleDrawerChat => { state.drawer_chat_open = !state.drawer_chat_open; } @@ -901,19 +908,22 @@ fn vertical_space(height: f32) -> iced::widget::Space { } fn view(state: &AppState) -> Element<'_, AppMessage> { - // Theme Colors - let color_crust = Color::from_rgb8(17, 17, 27); - let color_mantle = Color::from_rgb8(24, 24, 37); - let color_base = Color::from_rgb8(30, 30, 46); - let color_surface = Color::from_rgb8(49, 50, 68); - let color_text = Color::from_rgb8(205, 214, 244); - let color_subtext = Color::from_rgb8(166, 173, 200); - let color_blue = Color::from_rgb8(137, 180, 250); - let color_lavender = Color::from_rgb8(180, 190, 254); - let color_red = Color::from_rgb8(243, 139, 168); - let color_maroon = Color::from_rgb8(233, 146, 160); - let color_green = Color::from_rgb8(166, 227, 161); - let color_yellow = Color::from_rgb8(249, 226, 175); + // Theme colours β€” sourced from the active palette (see `src/theme.rs`), so + // all styling below re-themes when the user picks a different theme. + let pal = state.config.theme.palette(); + let color_crust = pal.crust; + let color_mantle = pal.mantle; + let color_base = pal.base; + let color_surface = pal.surface; + let color_overlay = pal.overlay; + let color_text = pal.text; + let color_subtext = pal.subtext; + let color_blue = pal.blue; + let color_lavender = pal.lavender; + let color_red = pal.red; + let color_maroon = pal.maroon; + let color_green = pal.green; + let color_yellow = pal.yellow; // Style Helpers let c_style = move |bg: Color, b_color: Color, radius: f32| { @@ -957,7 +967,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { radius: 6.0.into(), }, icon: color_subtext, - placeholder: Color::from_rgb8(108, 112, 134), + placeholder: color_overlay, value: color_text, selection: color_blue, } @@ -997,7 +1007,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { column![ row![ - text(label).size(12).color(Color::from_rgb8(180, 180, 180)), + text(label).size(12).color(color_subtext), horizontal_space(), validation_widget, ].align_y(iced::alignment::Vertical::Center), @@ -1080,7 +1090,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { selected, base: color_base, surface: color_surface, - overlay: Color::from_rgb8(69, 71, 90), + overlay: color_overlay, border: if selected { color_blue } else { color_surface }, }) .width(iced::Length::Fixed(132.0)) @@ -1097,6 +1107,46 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .into() }; + // Inline theme chooser β€” a clickable palette-preview swatch per theme. + // Same SelectTheme message, applied live + persisted. + let theme_choice = |t: AppTheme| -> Element<'_, AppMessage> { + let selected = state.config.theme == t; + let tile = Canvas::new(ThemeSwatch { + palette: t.palette(), + selected, + border: if selected { color_blue } else { color_surface }, + }) + .width(iced::Length::Fixed(120.0)) + .height(iced::Length::Fixed(64.0)); + column![ + button(tile) + .on_press(AppMessage::SelectTheme(t)) + .padding(2) + .style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)), + text(t.label()) + .size(11) + .color(if selected { color_blue } else { color_subtext }), + ] + .spacing(4) + .align_x(iced::alignment::Horizontal::Center) + .into() + }; + // 10 themes laid out as two rows of five (no flex-wrap in iced 0.14). + let theme_row1: Vec> = + AppTheme::ALL[0..5].iter().map(|&t| theme_choice(t)).collect(); + let theme_row2: Vec> = + AppTheme::ALL[5..].iter().map(|&t| theme_choice(t)).collect(); + let theme_section = column![ + text("Theme").size(14).color(color_subtext), + iced::widget::Row::with_children(theme_row1).spacing(12), + iced::widget::Row::with_children(theme_row2).spacing(12), + text("Colour theme for the whole UI. Applies live.") + .size(11) + .color(color_subtext), + ] + .spacing(10) + .width(iced::Length::Fill); + let settings_content = scrollable( column![ text("Settings").size(24).color(color_blue), @@ -1105,7 +1155,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { vertical_space(6.0), row![ column![ - text("Input Device").size(12).color(Color::from_rgb8(180, 180, 180)), + text("Input Device").size(12).color(color_subtext), pick_list( &state.input_devices[..], state.selected_input.as_ref(), @@ -1117,7 +1167,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_release(AppMessage::PersistConfig), ].spacing(4).width(iced::Length::Fill), column![ - text("Output Device").size(12).color(Color::from_rgb8(180, 180, 180)), + text("Output Device").size(12).color(color_subtext), pick_list( &state.output_devices[..], state.selected_output.as_ref(), @@ -1163,6 +1213,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext), ].spacing(8).width(iced::Length::Fill), vertical_space(12.0), + theme_section, + vertical_space(12.0), column![ text("Notification Chimes").size(14).color(color_subtext), checkbox(state.config.notifications_enabled) @@ -2089,15 +2141,16 @@ fn with_layout_picker<'a>( if !state.layout_picker_open { return base; } - let crust = Color::from_rgb8(17, 17, 27); - let mantle = Color::from_rgb8(24, 24, 37); - let base_c = Color::from_rgb8(30, 30, 46); - let surface = Color::from_rgb8(49, 50, 68); - let overlay = Color::from_rgb8(69, 71, 90); - let text_c = Color::from_rgb8(205, 214, 244); - let subtext = Color::from_rgb8(166, 173, 200); - let blue = Color::from_rgb8(137, 180, 250); - let green = Color::from_rgb8(166, 227, 161); + let pal = state.config.theme.palette(); + let crust = pal.crust; + let mantle = pal.mantle; + let base_c = pal.base; + let surface = pal.surface; + let overlay = pal.overlay; + let text_c = pal.text; + let subtext = pal.subtext; + let blue = pal.blue; + let green = pal.green; let backdrop = mouse_area( container(horizontal_space()) @@ -2292,6 +2345,69 @@ impl Program for LayoutThumb { } } +/// A small palette-preview tile for the theme picker (modeled on `LayoutThumb`): +/// the theme's background, a surface panel with two "text" lines to preview +/// legibility, and a stack of accent colours. Border highlights the selection. +struct ThemeSwatch { + palette: Palette, + selected: bool, + border: Color, +} + +impl Program for ThemeSwatch { + type State = (); + fn draw( + &self, + _state: &(), + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + let mut f = Frame::new(renderer, bounds.size()); + let (w, h) = (bounds.width, bounds.height); + let p = &self.palette; + let pad = 7.0; + + // Background fill. + f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), p.base); + + // Surface panel on the left half, with two text-colour lines on it. + let panel_w = (w - 2.0 * pad) * 0.52; + f.fill( + &Path::rectangle(Point::new(pad, pad), Size::new(panel_w, h - 2.0 * pad)), + p.surface, + ); + let mut line = |y: f32, ww: f32, col: Color| { + f.fill( + &Path::rectangle(Point::new(pad + 6.0, y), Size::new(ww, 3.0)), + col, + ); + }; + line(pad + 9.0, panel_w * 0.66, p.text); + line(pad + 17.0, panel_w * 0.48, p.subtext); + + // Accent swatches stacked on the right. + let ax = pad + panel_w + 6.0; + let aw = (w - pad - ax).max(2.0); + let accents = [p.blue, p.green, p.yellow, p.red, p.lavender]; + let gap = 3.0; + let ah = ((h - 2.0 * pad) - gap * (accents.len() as f32 - 1.0)) / accents.len() as f32; + for (i, c) in accents.iter().enumerate() { + let ay = pad + i as f32 * (ah + gap); + f.fill(&Path::rectangle(Point::new(ax, ay), Size::new(aw, ah)), *c); + } + + // Border (thicker + accent when selected). + let bw: f32 = if self.selected { 2.0 } else { 1.0 }; + f.stroke( + &Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)), + canvas::Stroke::default().with_color(self.border).with_width(bw), + ); + vec![f.into_geometry()] + } +} + /// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset /// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`). /// Each icon is authored in a 24Γ—24 space and scaled to the widget size. diff --git a/src/config.rs b/src/config.rs index de2f43a..b8fe617 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use crate::theme::AppTheme; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -137,6 +138,9 @@ pub struct AppConfig { /// Chosen arrangement of the in-call room screen. #[serde(default)] pub room_layout: RoomLayout, + /// Chosen UI colour theme. + #[serde(default)] + pub theme: AppTheme, #[serde(default)] pub custom_sound_self_join: Option, #[serde(default)] @@ -190,6 +194,7 @@ impl Default for AppConfig { controls_width: default_controls_width(), chat_drawer_width: default_chat_drawer_width(), room_layout: RoomLayout::default(), + theme: AppTheme::default(), custom_sound_self_join: None, custom_sound_peer_join: None, custom_sound_peer_leave: None, @@ -318,6 +323,28 @@ mod tests { assert_eq!(back.window_y, Some(-50)); } + #[test] + fn test_theme_field() { + // Default theme is Mocha (the original look). + assert_eq!(AppConfig::default().theme, AppTheme::Mocha); + // A chosen theme round-trips. + let cfg = AppConfig { + theme: AppTheme::Nord, + ..AppConfig::default() + }; + let json = serde_json::to_string(&cfg).unwrap(); + let back: AppConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back.theme, AppTheme::Nord); + } + + #[test] + fn old_config_without_theme_loads() { + // Configs predating the theme picker load with the default (Mocha). + let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; + let cfg: AppConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.theme, AppTheme::Mocha); + } + #[test] fn old_config_without_position_loads() { // A config written before window_x/window_y existed still deserializes diff --git a/src/lib.rs b/src/lib.rs index 8fbd1bf..b8077e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod network; pub mod core; pub mod app; pub mod config; +pub mod theme; pub mod notify; pub mod screenshare; diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..9b1943d --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,400 @@ +//! UI theming: a set of selectable colour palettes. +//! +//! The UI uses 13 semantic colour roles (background tiers + accents). Each +//! [`AppTheme`] maps to a [`Palette`] of those roles via the pure +//! [`AppTheme::palette`] fn β€” that's the testable seam: palettes are plain data +//! and the legibility guarantees below are unit-tested. `view()` reads the +//! active palette instead of hardcoded literals, so every widget (incl. the +//! canvas widgets that already take colours as data) re-themes automatically. +//! +//! `AppTheme` also maps to an `iced::Theme` ([`AppTheme::base_theme`]) so iced's +//! built-in widget chrome (scrollbars, text-input selection, dropdown menus) +//! matches the chosen palette. + +use iced::Color; +use serde::{Deserialize, Serialize}; + +/// 13 semantic colour roles the UI draws with. Names follow the Catppuccin +/// vocabulary (the original palette), but each theme supplies its own values. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Palette { + /// Darkest background (window backdrop behind everything). + pub crust: Color, + /// Slightly raised background. + pub mantle: Color, + /// Main content background. + pub base: Color, + /// Raised panel / card surface. + pub surface: Color, + /// Muted surface β€” placeholders, disabled, faint dividers. + pub overlay: Color, + /// Primary foreground text. + pub text: Color, + /// Secondary / hint text. + pub subtext: Color, + /// Primary accent (titles, primary buttons, selection borders). + pub blue: Color, + /// Secondary accent. + pub lavender: Color, + /// Danger / record / error. + pub red: Color, + /// Red variant (a warmer alternative to `red`). + pub maroon: Color, + /// Success / "transmitting" / speaking. + pub green: Color, + /// Warning / "reconnecting". + pub yellow: Color, +} + +/// A selectable UI theme. The default is `Mocha` (the project's original look). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum AppTheme { + #[default] + Mocha, + Macchiato, + Frappe, + Latte, + Dracula, + Nord, + TokyoNight, + GruvboxDark, + SolarizedLight, + GruvboxLight, +} + +/// Build a `Color` from a packed `0xRRGGBB` literal β€” keeps the palette tables +/// compact and greppable against each theme's canonical hex values. +fn hex(c: u32) -> Color { + Color::from_rgb8((c >> 16) as u8, (c >> 8) as u8, c as u8) +} + +impl AppTheme { + /// Every theme, in picker order. + pub const ALL: [AppTheme; 10] = [ + AppTheme::Mocha, + AppTheme::Macchiato, + AppTheme::Frappe, + AppTheme::Latte, + AppTheme::Dracula, + AppTheme::Nord, + AppTheme::TokyoNight, + AppTheme::GruvboxDark, + AppTheme::SolarizedLight, + AppTheme::GruvboxLight, + ]; + + /// Human-readable name for the picker. + pub fn label(self) -> &'static str { + match self { + AppTheme::Mocha => "Mocha", + AppTheme::Macchiato => "Macchiato", + AppTheme::Frappe => "FrappΓ©", + AppTheme::Latte => "Latte", + AppTheme::Dracula => "Dracula", + AppTheme::Nord => "Nord", + AppTheme::TokyoNight => "Tokyo Night", + AppTheme::GruvboxDark => "Gruvbox Dark", + AppTheme::SolarizedLight => "Solarized Light", + AppTheme::GruvboxLight => "Gruvbox Light", + } + } + + /// True for dark themes (dark background, light text). + pub fn is_dark(self) -> bool { + !matches!( + self, + AppTheme::Latte | AppTheme::SolarizedLight | AppTheme::GruvboxLight + ) + } + + /// The matching iced built-in theme, for built-in widget chrome. + pub fn base_theme(self) -> iced::Theme { + match self { + AppTheme::Mocha => iced::Theme::CatppuccinMocha, + AppTheme::Macchiato => iced::Theme::CatppuccinMacchiato, + AppTheme::Frappe => iced::Theme::CatppuccinFrappe, + AppTheme::Latte => iced::Theme::CatppuccinLatte, + AppTheme::Dracula => iced::Theme::Dracula, + AppTheme::Nord => iced::Theme::Nord, + AppTheme::TokyoNight => iced::Theme::TokyoNight, + AppTheme::GruvboxDark => iced::Theme::GruvboxDark, + AppTheme::SolarizedLight => iced::Theme::SolarizedLight, + AppTheme::GruvboxLight => iced::Theme::GruvboxLight, + } + } + + /// The 13-role colour palette for this theme. Pure data β€” colours are each + /// theme's canonical hex values mapped onto our semantic roles. + pub fn palette(self) -> Palette { + match self { + // Catppuccin Mocha β€” the project's original palette. + AppTheme::Mocha => Palette { + crust: hex(0x11111b), + mantle: hex(0x181825), + base: hex(0x1e1e2e), + surface: hex(0x313244), + overlay: hex(0x6c7086), + text: hex(0xcdd6f4), + subtext: hex(0xa6adc8), + blue: hex(0x89b4fa), + lavender: hex(0xb4befe), + red: hex(0xf38ba8), + maroon: hex(0xeba0ac), + green: hex(0xa6e3a1), + yellow: hex(0xf9e2af), + }, + AppTheme::Macchiato => Palette { + crust: hex(0x181926), + mantle: hex(0x1e2030), + base: hex(0x24273a), + surface: hex(0x363a4f), + overlay: hex(0x6e738d), + text: hex(0xcad3f5), + subtext: hex(0xa5adcb), + blue: hex(0x8aadf4), + lavender: hex(0xb7bdf8), + red: hex(0xed8796), + maroon: hex(0xee99a0), + green: hex(0xa6da95), + yellow: hex(0xeed49f), + }, + AppTheme::Frappe => Palette { + crust: hex(0x232634), + mantle: hex(0x292c3c), + base: hex(0x303446), + surface: hex(0x414559), + overlay: hex(0x737994), + text: hex(0xc6d0f5), + subtext: hex(0xa5adce), + blue: hex(0x8caaee), + lavender: hex(0xbabbf1), + red: hex(0xe78284), + maroon: hex(0xea999c), + green: hex(0xa6d189), + yellow: hex(0xe5c890), + }, + // Catppuccin Latte β€” light. + AppTheme::Latte => Palette { + crust: hex(0xdce0e8), + mantle: hex(0xe6e9ef), + base: hex(0xeff1f5), + surface: hex(0xccd0da), + overlay: hex(0x9ca0b0), + text: hex(0x4c4f69), + subtext: hex(0x6c6f85), + blue: hex(0x1e66f5), + lavender: hex(0x7287fd), + red: hex(0xd20f39), + maroon: hex(0xe64553), + green: hex(0x40a02b), + yellow: hex(0xdf8e1d), + }, + AppTheme::Dracula => Palette { + crust: hex(0x191a21), + mantle: hex(0x21222c), + base: hex(0x282a36), + surface: hex(0x44475a), + overlay: hex(0x6272a4), + text: hex(0xf8f8f2), + subtext: hex(0xbdc0d4), + blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent + lavender: hex(0x8be9fd), // cyan + red: hex(0xff5555), + maroon: hex(0xff79c6), // pink + green: hex(0x50fa7b), + yellow: hex(0xf1fa8c), + }, + AppTheme::Nord => Palette { + crust: hex(0x272c36), + mantle: hex(0x2b313c), + base: hex(0x2e3440), + surface: hex(0x3b4252), + overlay: hex(0x4c566a), + text: hex(0xeceff4), + subtext: hex(0xd8dee9), + blue: hex(0x88c0d0), + lavender: hex(0xb48ead), + red: hex(0xbf616a), + maroon: hex(0xd08770), + green: hex(0xa3be8c), + yellow: hex(0xebcb8b), + }, + AppTheme::TokyoNight => Palette { + crust: hex(0x16161e), + mantle: hex(0x1b1c29), + base: hex(0x1a1b26), + surface: hex(0x24283b), + overlay: hex(0x565f89), + text: hex(0xc0caf5), + subtext: hex(0x9aa5ce), + blue: hex(0x7aa2f7), + lavender: hex(0xbb9af7), + red: hex(0xf7768e), + maroon: hex(0xff9e64), + green: hex(0x9ece6a), + yellow: hex(0xe0af68), + }, + AppTheme::GruvboxDark => Palette { + crust: hex(0x1d2021), + mantle: hex(0x242424), + base: hex(0x282828), + surface: hex(0x3c3836), + overlay: hex(0x665c54), + text: hex(0xebdbb2), + subtext: hex(0xbdae93), + blue: hex(0x83a598), + lavender: hex(0xd3869b), + red: hex(0xfb4934), + maroon: hex(0xfe8019), + green: hex(0xb8bb26), + yellow: hex(0xfabd2f), + }, + // Solarized Light β€” light. + AppTheme::SolarizedLight => Palette { + crust: hex(0xe6dfc8), + mantle: hex(0xf3ecd6), + base: hex(0xfdf6e3), + surface: hex(0xeee8d5), + overlay: hex(0x93a1a1), + text: hex(0x586e75), + subtext: hex(0x657b83), + blue: hex(0x268bd2), + lavender: hex(0x6c71c4), + red: hex(0xdc322f), + maroon: hex(0xcb4b16), + green: hex(0x859900), + yellow: hex(0xb58900), + }, + // Gruvbox Light β€” light. + AppTheme::GruvboxLight => Palette { + crust: hex(0xf2e5bc), + mantle: hex(0xf9f5d7), + base: hex(0xfbf1c7), + surface: hex(0xebdbb2), + overlay: hex(0xbdae93), + text: hex(0x3c3836), + subtext: hex(0x504945), + blue: hex(0x076678), + lavender: hex(0x8f3f71), + red: hex(0x9d0006), + maroon: hex(0xaf3a03), + green: hex(0x79740e), + yellow: hex(0xb57614), + }, + } + } +} + +/// WCAG relative luminance of a colour (sRGB β†’ linear, 0.0..=1.0). +pub fn relative_luminance(c: Color) -> f32 { + fn lin(ch: f32) -> f32 { + if ch <= 0.03928 { + ch / 12.92 + } else { + ((ch + 0.055) / 1.055).powf(2.4) + } + } + 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b) +} + +/// WCAG contrast ratio between two colours (1.0..=21.0). Order-independent. +pub fn contrast_ratio(a: Color, b: Color) -> f32 { + let (la, lb) = (relative_luminance(a), relative_luminance(b)); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contrast_ratio_known_values() { + // Black on white is the maximum 21:1; a colour against itself is 1:1. + assert!((contrast_ratio(Color::BLACK, Color::WHITE) - 21.0).abs() < 0.1); + assert!((contrast_ratio(Color::WHITE, Color::WHITE) - 1.0).abs() < 0.001); + // Symmetric. + let a = hex(0x89b4fa); + let b = hex(0x1e1e2e); + assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 0.001); + } + + #[test] + fn every_theme_has_legible_body_text() { + // Primary text on the main background must clear WCAG AA (4.5:1). + for theme in AppTheme::ALL { + let p = theme.palette(); + let ratio = contrast_ratio(p.text, p.base); + assert!( + ratio >= 4.5, + "{}: text-on-base contrast {ratio:.2} < 4.5 (AA)", + theme.label() + ); + } + } + + #[test] + fn every_theme_has_readable_subtext_and_accents() { + // Secondary text clears AA-large (3:1); the title/primary accent must be + // readable on the background too (it's used for headings + button text). + for theme in AppTheme::ALL { + let p = theme.palette(); + let sub = contrast_ratio(p.subtext, p.base); + assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label()); + let accent = contrast_ratio(p.blue, p.base); + assert!( + accent >= 3.0, + "{}: primary-accent contrast {accent:.2} < 3.0", + theme.label() + ); + } + } + + #[test] + fn is_dark_matches_luminance_direction() { + // Dark themes: text brighter than base. Light themes: text darker. + for theme in AppTheme::ALL { + let p = theme.palette(); + let text_brighter = relative_luminance(p.text) > relative_luminance(p.base); + assert_eq!( + text_brighter, + theme.is_dark(), + "{}: is_dark={} but text_brighter={}", + theme.label(), + theme.is_dark(), + text_brighter + ); + } + } + + #[test] + fn all_themes_distinct_and_labeled() { + // ALL covers exactly the variants once, each with a unique non-empty label + // and a distinct base colour (so swatches don't look identical). + assert_eq!(AppTheme::ALL.len(), 10); + let mut labels: Vec<&str> = AppTheme::ALL.iter().map(|t| t.label()).collect(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), 10, "labels must be unique + non-empty"); + assert!(labels.iter().all(|l| !l.is_empty())); + } + + #[test] + fn serde_round_trips_every_variant() { + for theme in AppTheme::ALL { + let json = serde_json::to_string(&theme).unwrap(); + let back: AppTheme = serde_json::from_str(&json).unwrap(); + assert_eq!(theme, back); + } + } + + #[test] + fn default_is_mocha() { + assert_eq!(AppTheme::default(), AppTheme::Mocha); + // Default palette matches the project's original literals. + let p = AppTheme::default().palette(); + assert_eq!(p.base, Color::from_rgb8(30, 30, 46)); + assert_eq!(p.text, Color::from_rgb8(205, 214, 244)); + assert_eq!(p.blue, Color::from_rgb8(137, 180, 250)); + } +}