feat(ui): selectable UI themes (10 palettes + swatch picker)

The UI was hardcoded to one Catppuccin Mocha palette (color literals in
view() + theme() pinned to Dark). It now sources colours from a chosen
theme's palette, selectable in Settings.

- theme.rs (NEW): Palette (13 semantic colour roles) + AppTheme enum
  (Catppuccin Mocha/Macchiato/Frappe/Latte, Dracula, Nord, Tokyo Night,
  Gruvbox Dark, Solarized Light, Gruvbox Light). Pure palette()/label()/
  ALL/base_theme()/is_dark() + WCAG relative_luminance()/contrast_ratio().
- config: theme: AppTheme field (serde-default Mocha) + backward-compat.
- app: theme() returns config.theme.base_theme() (iced widget chrome);
  view() + with_layout_picker() source colours from the palette; new
  ThemeSwatch canvas widget; a Theme section of clickable swatches in
  Settings; SelectTheme applies live + persists. Canvas widgets already
  take colours as data, so they re-theme for free.

Tests written alongside (+7 theme, +2 config; 135 -> 144 lib): every
palette clears WCAG AA text-on-base contrast (4.5:1) with subtext/accent
>= 3:1, is_dark matches luminance direction, variants distinct/labeled,
serde round-trips, default = Mocha.

Verified live: the Settings swatch grid renders all 10 palettes and the
whole UI re-themes (screenshot-checked Latte light + Dracula dark).
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 17:43:13 -04:00
co-authored by Claude Opus 4.8
parent 53c1c112e6
commit c87fa3d45f
5 changed files with 574 additions and 29 deletions
+27
View File
@@ -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<String>,
#[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