facett-core 0.1.2

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Semantic palette** (§7) — Material-3-style *roles* authored in OKLCH, plus
//! the **derivation** of egui's five widget states by lightness steps (no
//! per-state colour literals). A [`Palette`] maps onto an [`egui::Visuals`] and
//! is the single colour source for every facett component.

use egui::{Stroke, Visuals, style::WidgetVisuals};
use serde::{Deserialize, Serialize};

use super::oklch::{Oklch, contrast_ratio};

/// Semantic colour roles (Material-3 *system* shape). Authored in OKLCH so the
/// WCAG gate and widget-state derivation can reason perceptually. Components never
/// reach for raw colours — they read a role.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Palette {
    /// Light vs dark — drives the direction lightness steps move for states.
    pub dark: bool,

    pub surface: Oklch,
    pub on_surface: Oklch,
    /// A dimmer on-surface for secondary text / hints.
    pub on_surface_dim: Oklch,
    /// A recessed surface (panels, headers).
    pub surface_container: Oklch,
    /// The most-recessed extreme (text edit background, code blocks).
    pub surface_extreme: Oklch,

    pub primary: Oklch,
    pub on_primary: Oklch,
    pub accent: Oklch,

    pub outline: Oklch,
    pub selection: Oklch,

    pub success: Oklch,
    pub on_success: Oklch,
    pub warn: Oklch,
    pub on_warn: Oklch,
    pub error: Oklch,
    pub on_error: Oklch,

    /// The colour decorative effects (glow/bloom) bloom with.
    pub glow: Oklch,
}

impl Palette {
    /// Step that derives a widget state's lightness. On dark themes hover/active
    /// *brighten*; on light themes they *darken* — one rule, both directions.
    fn step(&self, base: Oklch, magnitude: f32) -> Oklch {
        let dir = if self.dark { 1.0 } else { -1.0 };
        base.lighten(dir * magnitude)
    }

    /// Derive a full [`WidgetVisuals`] for one interaction state from the surface
    /// + accent roles, using lightness steps only (COH-1: zero per-state literals).
    fn widget(&self, lighten: f32, stroke_role: Oklch, fg_role: Oklch, radius: u8, expansion: f32) -> WidgetVisuals {
        let bg = self.step(self.surface_container, lighten);
        WidgetVisuals {
            bg_fill: bg.to_color32(),
            weak_bg_fill: self.step(self.surface_container, lighten * 0.5).to_color32(),
            bg_stroke: Stroke::new(1.0, stroke_role.to_color32()),
            fg_stroke: Stroke::new(1.0, fg_role.to_color32()),
            corner_radius: egui::CornerRadius::same(radius),
            expansion,
        }
    }

    /// Build an [`egui::Visuals`] from the roles — themes the standard widgets
    /// (buttons, sliders, scrollbars, text edits) to fit the palette. Radii are
    /// supplied by [`Metrics`](super::Metrics) at apply time, so this takes one.
    pub fn to_visuals(&self, radius: u8) -> Visuals {
        let mut v = if self.dark { Visuals::dark() } else { Visuals::light() };

        // NEVER set override_text_color globally (anti-pattern §27); instead set
        // the per-state fg_stroke + the semantic fg colours below.
        v.dark_mode = self.dark;
        v.hyperlink_color = self.accent.to_color32();
        v.panel_fill = self.surface.to_color32();
        v.window_fill = self.surface_container.to_color32();
        v.extreme_bg_color = self.surface_extreme.to_color32();
        v.faint_bg_color = self.step(self.surface_container, 0.03).to_color32();
        v.code_bg_color = self.surface_extreme.to_color32();
        v.warn_fg_color = self.warn.to_color32();
        v.error_fg_color = self.error.to_color32();
        v.window_stroke = Stroke::new(1.0, self.outline.to_color32());

        v.selection.bg_fill = self.selection.to_color32_alpha(90);
        v.selection.stroke = Stroke::new(1.0, self.selection.to_color32());

        // Five widget states, derived by lightness step from the surface.
        v.widgets.noninteractive = self.widget(0.0, self.outline.with_chroma_scale(0.5), self.on_surface_dim, radius, 0.0);
        v.widgets.inactive = self.widget(0.04, self.outline, self.on_surface, radius, 0.0);
        v.widgets.hovered = self.widget(0.10, self.accent, self.on_surface, radius, 1.0);
        v.widgets.active = self.widget(0.16, self.accent, self.on_surface, radius, 1.0);
        v.widgets.open = self.widget(0.08, self.outline, self.on_surface, radius, 0.0);

        // Window/menu radii get applied by Metrics; default them here coherently.
        v.window_corner_radius = egui::CornerRadius::same(radius);
        v.menu_corner_radius = egui::CornerRadius::same(radius);

        v
    }

    /// Worst-case body-text contrast across the surfaces text sits on
    /// (`surface`, `surface_container`, `surface_extreme`). The WCAG gate (§7,
    /// §25) requires this ≥ 4.5.
    pub fn body_text_contrast(&self) -> f32 {
        let on = self.on_surface.to_color32();
        [self.surface, self.surface_container, self.surface_extreme]
            .iter()
            .map(|s| contrast_ratio(on, s.to_color32()))
            .fold(f32::INFINITY, f32::min)
    }

    /// Worst-case contrast of dim/secondary text on the surfaces. Treated as
    /// "large text / non-text" → ≥ 3:1.
    pub fn dim_text_contrast(&self) -> f32 {
        let on = self.on_surface_dim.to_color32();
        [self.surface, self.surface_container]
            .iter()
            .map(|s| contrast_ratio(on, s.to_color32()))
            .fold(f32::INFINITY, f32::min)
    }

    /// Worst-case contrast of the outline / accent (UI boundary) against the
    /// surface — the WCAG 1.4.11 non-text gate (≥ 3:1).
    pub fn ui_boundary_contrast(&self) -> f32 {
        let s = self.surface.to_color32();
        contrast_ratio(self.outline.to_color32(), s).min(contrast_ratio(self.accent.to_color32(), s))
    }

    /// On-colour contrast for the status roles (text drawn on a filled chip).
    pub fn status_contrast(&self) -> f32 {
        let pairs = [
            (self.on_primary, self.primary),
            (self.on_success, self.success),
            (self.on_warn, self.warn),
            (self.on_error, self.error),
        ];
        pairs
            .iter()
            .map(|(fg, bg)| contrast_ratio(fg.to_color32(), bg.to_color32()))
            .fold(f32::INFINITY, f32::min)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::look::Theme;

    #[test]
    fn widget_states_get_progressively_lighter_on_dark() {
        let v = Theme::windows_dark().palette.to_visuals(6);
        let l = |c: egui::Color32| super::super::oklch::relative_luminance(c);
        assert!(l(v.widgets.inactive.bg_fill) <= l(v.widgets.hovered.bg_fill));
        assert!(l(v.widgets.hovered.bg_fill) <= l(v.widgets.active.bg_fill));
    }

    #[test]
    fn no_global_override_text_color() {
        let v = Theme::windows_light().palette.to_visuals(6);
        assert!(v.override_text_color.is_none(), "§27: never set override_text_color globally");
    }
}