//! Colour generation for notebook themes. //! //! Generates a full 16-colour palette from 4 user-specified colours //! (background, text, primary, link) using perceptual colour operations. use palette::{Clamp, FromColor, IntoColor, LinSrgb, Oklch, Srgb}; use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours; use crate::themes::BUILTIN_COLOUR_SCHEMES; /// Parse any CSS colour string to Oklch. /// /// Supports: hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), hsla(), /// hwb(), lab(), lch(), oklab(), oklch(), and named colours. pub fn css_to_oklch(css: &str) -> Result { let css = css.trim(); let css = if !css.starts_with('#') && !css.contains('(') && css.chars().all(|c| c.is_ascii_hexdigit()) { format!("#{}", css) } else { css.to_string() }; let color = csscolorparser::parse(&css) .map_err(|e| ColourGenError::InvalidColour(format!("{}: {}", css, e)))?; let [r, g, b, _a] = color.to_array(); let srgb = Srgb::new(r as f32, g as f32, b as f32); Ok(Oklch::from_color(srgb.into_linear())) } /// Convert Oklch to hex string (without #, uppercase). pub fn oklch_to_hex(oklch: Oklch) -> String { let linear: LinSrgb = oklch.into_color(); let clamped = linear.clamp(); let rgb: Srgb = Srgb::from_linear(clamped); format!("{:02X}{:02X}{:02X}", rgb.red, rgb.green, rgb.blue) } /// Determine if a colour is "dark" based on Oklch lightness. pub fn is_dark(oklch: &Oklch) -> bool { oklch.l < 0.5 } /// Apply the hue and chroma from `source` to the lightness of `target`. /// /// The chroma is scaled proportionally to maintain the relative colourfulness /// while respecting the target lightness. pub fn transfer_hue_chroma(source: Oklch, target: Oklch) -> Oklch { Oklch::new(target.l, source.chroma, source.hue) } /// Apply hue/chroma with lightness clamping for dark themes. /// Ensures text is lightest, base is darkest. pub fn transfer_for_dark_theme( source: Oklch, target: Oklch, is_text: bool, is_base: bool, ) -> Oklch { let mut result = transfer_hue_chroma(source, target); if is_base { result.l = result.l.min(0.25); } else if is_text { result.l = result.l.max(0.85); } result } /// Apply hue/chroma with lightness clamping for light themes. /// Ensures text is darkest, base is lightest. pub fn transfer_for_light_theme( source: Oklch, target: Oklch, is_text: bool, is_base: bool, ) -> Oklch { let mut result = transfer_hue_chroma(source, target); if is_base { result.l = result.l.max(0.92); } else if is_text { result.l = result.l.min(0.25); } result } /// Semantic colour configuration. mod semantic { /// Hues in Oklch degrees. pub mod hue { pub const ERROR: f32 = 30.0; // Red pub const WARNING: f32 = 80.0; // Gold-amber with high saturation pub const SUCCESS: f32 = 165.0; // Teal-green (shifted from pure green to allow more cyan) } /// Minimum chroma values to keep semantic colours recognizable. pub mod min_chroma { pub const ERROR: f32 = 0.16; // Must stay clearly red pub const WARNING: f32 = 0.19; // Needs high saturation to avoid looking muddy pub const SUCCESS: f32 = 0.08; // Can be quite muted, teal reads well } /// Lightness values per variant. Warning needs to be brighter to avoid going brown. pub mod lightness { pub const ERROR_DARK: f32 = 0.70; pub const ERROR_LIGHT: f32 = 0.55; pub const WARNING_DARK: f32 = 0.78; // Brighter to stay amber pub const WARNING_LIGHT: f32 = 0.78; // Brighter to avoid brown pub const SUCCESS_DARK: f32 = 0.70; pub const SUCCESS_LIGHT: f32 = 0.50; // Can be slightly darker, teal handles it } } /// Generate a semantic colour derived from user's primary. /// /// Takes the user's chroma (clamped to minimum) and applies a fixed semantic hue. fn generate_semantic_colour( user_primary: Oklch, hue: f32, min_chroma: f32, lightness: f32, ) -> Oklch { let chroma = user_primary.chroma.max(min_chroma); Oklch::new(lightness, chroma, hue) } #[derive(Debug, Clone, thiserror::Error)] pub enum ColourGenError { #[error("invalid colour: {0}")] InvalidColour(String), } /// The 4 user-provided colours for theme generation. #[derive(Debug, Clone)] pub struct ThemeInputs { pub background: String, pub text: String, pub primary: String, pub link: String, } /// Which variant to generate. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThemeVariant { Light, Dark, } /// Find the most neutral base theme for a variant. fn get_neutral_base(variant: ThemeVariant) -> &'static ColourSchemeColours<'static> { let target_variant = match variant { ThemeVariant::Light => "light", ThemeVariant::Dark => "dark", }; BUILTIN_COLOUR_SCHEMES .iter() .find(|s| s.variant == target_variant) .map(|s| &s.colours) .expect("should have at least one builtin theme per variant") } /// Determine theme variant from background colour luminance. pub fn detect_variant(background: &str) -> Result { let oklch = css_to_oklch(background)?; Ok(if is_dark(&oklch) { ThemeVariant::Dark } else { ThemeVariant::Light }) } /// Generate a full 16-colour palette from 4 user inputs. pub fn generate_palette( inputs: &ThemeInputs, variant: ThemeVariant, ) -> Result, ColourGenError> { let user_base = css_to_oklch(&inputs.background)?; let user_text = css_to_oklch(&inputs.text)?; let user_primary = css_to_oklch(&inputs.primary)?; let user_link = css_to_oklch(&inputs.link)?; let base_theme = get_neutral_base(variant); let base_base = css_to_oklch(base_theme.base.as_ref())?; let base_surface = css_to_oklch(base_theme.surface.as_ref())?; let base_overlay = css_to_oklch(base_theme.overlay.as_ref())?; let base_text = css_to_oklch(base_theme.text.as_ref())?; let base_muted = css_to_oklch(base_theme.muted.as_ref())?; let base_subtle = css_to_oklch(base_theme.subtle.as_ref())?; let base_emphasis = css_to_oklch(base_theme.emphasis.as_ref())?; let base_primary = css_to_oklch(base_theme.primary.as_ref())?; let base_secondary = css_to_oklch(base_theme.secondary.as_ref())?; let base_tertiary = css_to_oklch(base_theme.tertiary.as_ref())?; let base_border = css_to_oklch(base_theme.border.as_ref())?; let base_link = css_to_oklch(base_theme.link.as_ref())?; let base_highlight = css_to_oklch(base_theme.highlight.as_ref())?; let transfer: fn(Oklch, Oklch, bool, bool) -> Oklch = match variant { ThemeVariant::Dark => transfer_for_dark_theme, ThemeVariant::Light => transfer_for_light_theme, }; // Generate semantic colours derived from user's primary with fixed hues. let error = generate_semantic_colour( user_primary, semantic::hue::ERROR, semantic::min_chroma::ERROR, match variant { ThemeVariant::Dark => semantic::lightness::ERROR_DARK, ThemeVariant::Light => semantic::lightness::ERROR_LIGHT, }, ); let warning = generate_semantic_colour( user_primary, semantic::hue::WARNING, semantic::min_chroma::WARNING, match variant { ThemeVariant::Dark => semantic::lightness::WARNING_DARK, ThemeVariant::Light => semantic::lightness::WARNING_LIGHT, }, ); let success = generate_semantic_colour( user_primary, semantic::hue::SUCCESS, semantic::min_chroma::SUCCESS, match variant { ThemeVariant::Dark => semantic::lightness::SUCCESS_DARK, ThemeVariant::Light => semantic::lightness::SUCCESS_LIGHT, }, ); Ok(ColourSchemeColours { base: oklch_to_hex(transfer(user_base, base_base, false, true)).into(), surface: oklch_to_hex(transfer(user_base, base_surface, false, false)).into(), overlay: oklch_to_hex(transfer(user_base, base_overlay, false, false)).into(), text: oklch_to_hex(transfer(user_text, base_text, true, false)).into(), muted: oklch_to_hex(transfer(user_text, base_muted, false, false)).into(), subtle: oklch_to_hex(transfer(user_text, base_subtle, false, false)).into(), emphasis: oklch_to_hex(transfer(user_text, base_emphasis, false, false)).into(), primary: oklch_to_hex(transfer(user_primary, base_primary, false, false)).into(), secondary: oklch_to_hex(transfer(user_primary, base_secondary, false, false)).into(), tertiary: oklch_to_hex(transfer(user_primary, base_tertiary, false, false)).into(), error: oklch_to_hex(error).into(), warning: oklch_to_hex(warning).into(), success: oklch_to_hex(success).into(), border: oklch_to_hex(transfer(user_base, base_border, false, false)).into(), link: oklch_to_hex(transfer(user_link, base_link, false, false)).into(), highlight: oklch_to_hex(transfer(user_primary, base_highlight, false, false)).into(), extra_data: None, }) } /// Generate the counterpart palette (light for dark, dark for light). /// Uses neutral base for the counterpart and applies user hue/chroma. pub fn generate_counterpart_palette( inputs: &ThemeInputs, primary_variant: ThemeVariant, ) -> Result, ColourGenError> { let counterpart_variant = match primary_variant { ThemeVariant::Light => ThemeVariant::Dark, ThemeVariant::Dark => ThemeVariant::Light, }; let neutral_base = get_neutral_base(counterpart_variant); let user_primary = css_to_oklch(&inputs.primary)?; let user_link = css_to_oklch(&inputs.link)?; let base_primary = css_to_oklch(neutral_base.primary.as_ref())?; let base_secondary = css_to_oklch(neutral_base.secondary.as_ref())?; let base_tertiary = css_to_oklch(neutral_base.tertiary.as_ref())?; let base_link = css_to_oklch(neutral_base.link.as_ref())?; let base_highlight = css_to_oklch(neutral_base.highlight.as_ref())?; // Generate semantic colours for the counterpart variant. let error = generate_semantic_colour( user_primary, semantic::hue::ERROR, semantic::min_chroma::ERROR, match counterpart_variant { ThemeVariant::Dark => semantic::lightness::ERROR_DARK, ThemeVariant::Light => semantic::lightness::ERROR_LIGHT, }, ); let warning = generate_semantic_colour( user_primary, semantic::hue::WARNING, semantic::min_chroma::WARNING, match counterpart_variant { ThemeVariant::Dark => semantic::lightness::WARNING_DARK, ThemeVariant::Light => semantic::lightness::WARNING_LIGHT, }, ); let success = generate_semantic_colour( user_primary, semantic::hue::SUCCESS, semantic::min_chroma::SUCCESS, match counterpart_variant { ThemeVariant::Dark => semantic::lightness::SUCCESS_DARK, ThemeVariant::Light => semantic::lightness::SUCCESS_LIGHT, }, ); Ok(ColourSchemeColours { base: neutral_base.base.clone(), surface: neutral_base.surface.clone(), overlay: neutral_base.overlay.clone(), text: neutral_base.text.clone(), muted: neutral_base.muted.clone(), subtle: neutral_base.subtle.clone(), emphasis: neutral_base.emphasis.clone(), border: neutral_base.border.clone(), error: oklch_to_hex(error).into(), warning: oklch_to_hex(warning).into(), success: oklch_to_hex(success).into(), primary: oklch_to_hex(transfer_hue_chroma(user_primary, base_primary)).into(), secondary: oklch_to_hex(transfer_hue_chroma(user_primary, base_secondary)).into(), tertiary: oklch_to_hex(transfer_hue_chroma(user_primary, base_tertiary)).into(), link: oklch_to_hex(transfer_hue_chroma(user_link, base_link)).into(), highlight: oklch_to_hex(transfer_hue_chroma(user_primary, base_highlight)).into(), extra_data: None, }) } #[cfg(test)] mod tests;