Skip to main content

max / audiofiles

Theme picker: contrast tiers + stronger focus stroke Rather than overriding curated palettes' deliberately-subtle muted colors to force WCAG AA (which would alter their identity), classify and surface contrast in the picker: - Add WCAG contrast helpers and a ContrastTier (High/Standard/Low) from fg_muted vs the theme's darkest/lightest panel surfaces. - The theme picker sorts each Dark/Light group most-accessible-first and shows an AA / OK / low badge per theme, so users choose informed while the themes keep their character. - Wire the previously-unused stroke::FOCUS (1.5) into the selection/focus outline with the saturated accent, so keyboard focus is clearly ringed instead of egui's near-invisible default. 806 tests green, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-06 01:32 UTC
Commit: b4e681d67580fe5a4263a03f11b31bf28e067393
Parent: d934fb4
2 files changed, +102 insertions, -3 deletions
@@ -423,12 +423,21 @@ fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) {
423 423 .width(200.0)
424 424 .show_ui(ui, |ui| {
425 425 for (label, variant) in [("Dark", "dark"), ("Light", "light"), ("High Contrast", "high-contrast")] {
426 - let group: Vec<&theme::ThemeMeta> = themes.iter().filter(|t| t.variant == variant).collect();
426 + // Pair each theme with its muted-text contrast tier and
427 + // sort most-accessible-first, so readable themes surface
428 + // at the top of each group and low-contrast curated
429 + // palettes are clearly badged rather than silently mixed in.
430 + let mut group: Vec<(&theme::ThemeMeta, theme::ContrastTier)> = themes
431 + .iter()
432 + .filter(|t| t.variant == variant)
433 + .map(|t| (t, theme::theme_contrast_tier(&t.id)))
434 + .collect();
427 435 if group.is_empty() {
428 436 continue;
429 437 }
438 + group.sort_by_key(|(_, tier)| std::cmp::Reverse(*tier));
430 439 ui.label(egui::RichText::new(label).small().strong());
431 - for t in group {
440 + for (t, tier) in group {
432 441 let is_selected = t.id == state.current_theme_id;
433 442 ui.horizontal(|ui| {
434 443 // Color swatch (bg + accent)
@@ -450,6 +459,21 @@ fn draw_appearance_section(ui: &mut egui::Ui, state: &mut BrowserState) {
450 459 if ui.selectable_label(is_selected, display).clicked() {
451 460 new_theme_id = Some(t.id.clone());
452 461 }
462 + // Contrast-tier badge (text legibility, not the
463 + // theme's accent palette).
464 + let badge_color = match tier {
465 + theme::ContrastTier::High => theme::accent_green(),
466 + theme::ContrastTier::Standard => theme::text_muted(),
467 + theme::ContrastTier::Low => theme::accent_yellow(),
468 + };
469 + ui.label(
470 + egui::RichText::new(tier.badge())
471 + .small()
472 + .color(badge_color),
473 + )
474 + .on_hover_text(
475 + "Muted-text legibility: AA = passes WCAG AA, OK = readable, low = subtle",
476 + );
453 477 });
454 478 }
455 479 ui.separator();
@@ -258,6 +258,78 @@ pub fn custom_themes_dir() -> Option<PathBuf> {
258 258 dirs::config_dir().map(|c| c.join("audiofiles").join("themes"))
259 259 }
260 260
261 + /// Accessibility tier for a theme's muted (tertiary) text, by WCAG contrast of
262 + /// `fg_muted` against the theme's darkest and lightest panel backgrounds. We
263 + /// surface this in the picker rather than overriding curated palettes' colors,
264 + /// so users can choose informed while the themes keep their identity.
265 + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
266 + pub enum ContrastTier {
267 + /// Muted text below the 3:1 WCAG floor for large text / UI components.
268 + Low,
269 + /// Muted text meets 3:1 but not the 4.5:1 normal-text bar.
270 + Standard,
271 + /// Muted text meets WCAG AA (>= 4.5:1) on every panel surface.
272 + High,
273 + }
274 +
275 + impl ContrastTier {
276 + /// Short badge shown next to the theme name.
277 + pub fn badge(self) -> &'static str {
278 + match self {
279 + ContrastTier::High => "AA",
280 + ContrastTier::Standard => "OK",
281 + ContrastTier::Low => "low",
282 + }
283 + }
284 + }
285 +
286 + fn relative_luminance(c: Color32) -> f64 {
287 + fn lin(ch: u8) -> f64 {
288 + let c = ch as f64 / 255.0;
289 + if c <= 0.03928 {
290 + c / 12.92
291 + } else {
292 + ((c + 0.055) / 1.055).powf(2.4)
293 + }
294 + }
295 + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
296 + }
297 +
298 + fn contrast_ratio(a: Color32, b: Color32) -> f64 {
299 + let (la, lb) = (relative_luminance(a), relative_luminance(b));
300 + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
301 + (hi + 0.05) / (lo + 0.05)
302 + }
303 +
304 + /// Load a theme's full color set by id (bundled or custom on disk).
305 + fn theme_colors_for(id: &str) -> Option<ThemeColors> {
306 + for (bundled_id, content) in BUNDLED_THEMES {
307 + if *bundled_id == id {
308 + return parse_theme(content).ok();
309 + }
310 + }
311 + let dir = custom_themes_dir()?;
312 + let content = std::fs::read_to_string(dir.join(format!("{id}.toml"))).ok()?;
313 + parse_theme(&content).ok()
314 + }
315 +
316 + /// Compute the muted-text contrast tier for a theme id. Defaults to `Standard`
317 + /// if the theme can't be loaded.
318 + pub fn theme_contrast_tier(id: &str) -> ContrastTier {
319 + let Some(c) = theme_colors_for(id) else {
320 + return ContrastTier::Standard;
321 + };
322 + let worst = contrast_ratio(c.fg_muted, c.bg_primary)
323 + .min(contrast_ratio(c.fg_muted, c.bg_tertiary));
324 + if worst >= 4.5 {
325 + ContrastTier::High
326 + } else if worst >= 3.0 {
327 + ContrastTier::Standard
328 + } else {
329 + ContrastTier::Low
330 + }
331 + }
332 +
261 333 /// List all available themes (bundled + custom). Custom themes override bundled by ID.
262 334 pub fn list_themes() -> Vec<ThemeMeta> {
263 335 let mut themes: Vec<ThemeMeta> = Vec::new();
@@ -529,7 +601,10 @@ pub fn apply_theme(ctx: &egui::Context) {
529 601 visuals.faint_bg_color = lerp_color(t.bg_primary, t.bg_secondary, 0.3);
530 602
531 603 visuals.selection.bg_fill = lerp_color(t.bg_primary, t.accent_blue, 0.3);
532 - visuals.selection.stroke = egui::Stroke::new(1.0, contrast_color(lerp_color(t.bg_primary, t.accent_blue, 0.3)));
604 + // Keyboard-focus / selection outline: use the dedicated FOCUS stroke width
605 + // (1.5) and the saturated accent so focused widgets are clearly ringed,
606 + // rather than egui's near-invisible default 1px contrast hairline.
607 + visuals.selection.stroke = egui::Stroke::new(stroke::FOCUS, t.accent_blue);
533 608
534 609 visuals.widgets.noninteractive.bg_fill = t.bg_secondary;
535 610 visuals.widgets.inactive.bg_fill = lerp_color(t.bg_secondary, t.bg_tertiary, 0.3);