use serde::{Deserialize, Serialize}; use validator::{Validate, ValidationError}; /// Text is considered readable from this contrast ratio up (WCAG AA for normal text) pub const MIN_TEXT_CONTRAST: f64 = 4.5; /// Buttons and time slots drawn in the primary colour should have at least this contrast (WCAG AA for UI components) pub const MIN_PRIMARY_CONTRAST: f64 = 3.0; const DEFAULT_PRIMARY: &str = "#696cff"; const DEFAULT_BACKGROUND: &str = "#f5f5f9"; const DEFAULT_CARD: &str = "#ffffff"; const DEFAULT_TEXT: &str = "#566a7f"; /// Ready-made colour sets offered in the appearance settings: name, primary, background, card, text. /// The name is a translation key. pub const PALETTES: [(&str, &str, &str, &str, &str); 8] = [ ("Violet", DEFAULT_PRIMARY, DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT), ("Green", "#1a8f6a", "#f1f7f4", "#ffffff", "#33443d"), ("Blue", "#1b66b1", "#eef3f8", "#ffffff", "#2c3e50"), ("Orange", "#c9501f", "#faf3ef", "#ffffff", "#4a3a33"), ("Pink", "#c2416f", "#fbf2f5", "#ffffff", "#4b3640"), ("Sand", "#8a6a2f", "#f4efe6", "#fffdf8", "#4a4235"), ("Graphite", "#ef9f27", "#1f2128", "#2b2e38", "#d8dae3"), ("Night", "#6ea8fe", "#0f1b2d", "#182a44", "#d3dcea"), ]; /// Colours of the public booking page, set by the customer in the appearance settings #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] pub struct Colors { /// Buttons, selected time slots, links #[cfg_attr(feature = "ssr", sqlx(rename = "color_primary"))] #[validate(custom(function = "validate_color", message = "Invalid colour"))] pub primary: String, /// Page background #[cfg_attr(feature = "ssr", sqlx(rename = "color_background"))] #[validate(custom(function = "validate_color", message = "Invalid colour"))] pub background: String, /// Background of cards, inputs and dialogs #[cfg_attr(feature = "ssr", sqlx(rename = "color_card"))] #[validate(custom(function = "validate_color", message = "Invalid colour"))] pub card: String, /// Headings and labels; body text is a slightly softer shade derived in CSS #[cfg_attr(feature = "ssr", sqlx(rename = "color_text"))] #[validate(custom(function = "validate_color", message = "Invalid colour"))] pub text: String, } impl Default for Colors { fn default() -> Self { Colors::new(DEFAULT_PRIMARY, DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT) } } /// What is hard to see with a set of colours #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ContrastIssue { TextOnCard, TextOnBackground, PrimaryOnCard, } impl ContrastIssue { /// Translation key of the warning pub fn message(&self) -> &'static str { match self { ContrastIssue::TextOnCard => "Text is hard to read on the card background", ContrastIssue::TextOnBackground => "Text is hard to read on the page background", ContrastIssue::PrimaryOnCard => "Primary colour is hard to see on the card background", } } } impl Colors { pub fn new(primary: &str, background: &str, card: &str, text: &str) -> Self { Colors { primary: primary.to_string(), background: background.to_string(), card: card.to_string(), text: text.to_string(), } } pub fn palette(index: usize) -> Option { PALETTES.get(index).map(|(_, primary, background, card, text)| Colors::new(primary, background, card, text)) } /// Replaces values that are not colours with the default ones, so the result is safe to put into a style pub fn sanitized(&self) -> Self { let pick = |value: &str, default: &str| { if is_color(value) { value.to_lowercase() } else { default.to_string() } }; Colors { primary: pick(&self.primary, DEFAULT_PRIMARY), background: pick(&self.background, DEFAULT_BACKGROUND), card: pick(&self.card, DEFAULT_CARD), text: pick(&self.text, DEFAULT_TEXT), } } /// CSS variables for the wrapper of the public page, used as its `style` attribute. /// The styles themselves are in `assets/css/public.css`. pub fn style(&self) -> String { let colors = self.sanitized(); format!("--rz-primary: {}; --rz-bg: {}; --rz-card: {}; --rz-text: {}; --rz-on-primary: {}; --rz-scheme: {}", colors.primary, colors.background, colors.card, colors.text, text_color_on(&colors.primary), if is_dark(&colors.card) { "dark" } else { "light" }) } pub fn contrast_issues(&self) -> Vec { let colors = self.sanitized(); let mut issues = vec![]; if contrast(&colors.text, &colors.card) < MIN_TEXT_CONTRAST { issues.push(ContrastIssue::TextOnCard); } if contrast(&colors.text, &colors.background) < MIN_TEXT_CONTRAST { issues.push(ContrastIssue::TextOnBackground); } if contrast(&colors.primary, &colors.card) < MIN_PRIMARY_CONTRAST { issues.push(ContrastIssue::PrimaryOnCard); } issues } } fn validate_color(color: &str) -> Result<(), ValidationError> { if is_color(color) { Ok(()) } else { Err(ValidationError::new("invalid color")) } } /// Only the `#rrggbb` form is a colour here, it is what `` produces pub fn is_color(color: &str) -> bool { parse_color(color).is_some() } fn parse_color(color: &str) -> Option<(u8, u8, u8)> { let hex = color.strip_prefix('#')?; if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { return None } let channel = |from: usize| u8::from_str_radix(&hex[from..from + 2], 16).ok(); Some((channel(0)?, channel(2)?, channel(4)?)) } /// Relative luminance by WCAG 2: 0 for black, 1 for white. An invalid colour counts as black. pub fn luminance(color: &str) -> f64 { let (r, g, b) = parse_color(color).unwrap_or((0, 0, 0)); let linear = |channel: u8| { let c = channel as f64 / 255.0; if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } }; 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) } /// Contrast ratio by WCAG 2, from 1 (same colours) to 21 (black and white) pub fn contrast(first: &str, second: &str) -> f64 { let (first, second) = (luminance(first), luminance(second)); (first.max(second) + 0.05) / (first.min(second) + 0.05) } /// Text colour for a button of the given colour. White is preferred while it is readable enough: /// on saturated mid-tones (like the default violet) the WCAG formula favours black, which looks worse. pub fn text_color_on(color: &str) -> &'static str { if contrast(color, "#ffffff") >= MIN_PRIMARY_CONTRAST { "#ffffff" } else { "#000000" } } /// Closer to black than to white by contrast fn is_dark(color: &str) -> bool { contrast(color, "#ffffff") > contrast(color, "#000000") } #[cfg(test)] mod tests { use super::*; #[test] fn is_color_accepts_only_six_digit_hex() { for color in ["#000000", "#ffffff", "#696cff", "#ABCDEF", "#aBc123"] { assert!(is_color(color), "color: {:?}", color); } for color in ["", "#", "#fff", "#fffff", "#fffffff", "696cff", "#gggggg", "red", "rgb(0,0,0)", " #ffffff", "#ffffff ", "#fff;ff", "#ffffff; color: red", "#ééé", "#ffffé"] { assert!(!is_color(color), "color: {:?}", color); } } #[test] fn luminance_of_black_and_white() { assert_eq!(luminance("#000000"), 0.0); assert!((luminance("#ffffff") - 1.0).abs() < 1e-9); assert_eq!(luminance("nonsense"), 0.0); } #[test] fn contrast_is_symmetric_and_in_range() { assert!((contrast("#000000", "#ffffff") - 21.0).abs() < 1e-9); assert!((contrast("#ffffff", "#000000") - 21.0).abs() < 1e-9); assert!((contrast("#696cff", "#696cff") - 1.0).abs() < 1e-9); // Reference value of a common pair assert!((contrast("#777777", "#ffffff") - 4.48).abs() < 0.01); } #[test] fn text_color_on_picks_readable_one() { assert_eq!(text_color_on("#000000"), "#ffffff"); assert_eq!(text_color_on("#1b66b1"), "#ffffff"); // White stays on the default violet, although black has a slightly higher contrast there assert_eq!(text_color_on("#696cff"), "#ffffff"); assert_eq!(text_color_on("#ffffff"), "#000000"); assert_eq!(text_color_on("#ef9f27"), "#000000"); assert_eq!(text_color_on("#ffff00"), "#000000"); } #[test] fn default_colors_are_valid() { let colors = Colors::default(); assert!(colors.validate().is_ok()); assert_eq!(colors, colors.sanitized()); assert_eq!(Colors::palette(0), Some(colors)); } #[test] fn validation_rejects_other_values() { let mut colors = Colors::default(); colors.card = "white".to_string(); assert!(colors.validate().is_err()); let mut colors = Colors::default(); colors.primary = "#fff; background: url(x)".to_string(); assert!(colors.validate().is_err()); } #[test] fn sanitized_replaces_invalid_values() { let colors = Colors::new("#ABCDEF", "", "red; --x: 1", "#12345").sanitized(); assert_eq!(colors, Colors::new("#abcdef", DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT)); } #[test] fn style_of_default_colors() { assert_eq!(Colors::default().style(), "--rz-primary: #696cff; --rz-bg: #f5f5f9; --rz-card: #ffffff; --rz-text: #566a7f; --rz-on-primary: #ffffff; --rz-scheme: light"); } #[test] fn style_of_dark_colors() { assert_eq!(Colors::new("#ef9f27", "#1f2128", "#2b2e38", "#d8dae3").style(), "--rz-primary: #ef9f27; --rz-bg: #1f2128; --rz-card: #2b2e38; --rz-text: #d8dae3; --rz-on-primary: #000000; --rz-scheme: dark"); } #[test] fn style_never_contains_invalid_values() { let style = Colors::new("red; background: url(x)", "", "\"", "#fff").style(); assert_eq!(style, Colors::default().style()); } #[test] fn contrast_issues_are_reported() { assert_eq!(Colors::default().contrast_issues(), vec![]); assert_eq!(Colors::new("#696cff", "#f5f5f9", "#ffffff", "#bbbbbb").contrast_issues(), vec![ContrastIssue::TextOnCard, ContrastIssue::TextOnBackground]); assert_eq!(Colors::new("#f0f0f0", "#333333", "#ffffff", "#444444").contrast_issues(), vec![ContrastIssue::TextOnBackground, ContrastIssue::PrimaryOnCard]); } #[test] fn palettes_are_valid_and_readable() { for (index, palette) in PALETTES.iter().enumerate() { let colors = Colors::palette(index).unwrap(); assert!(colors.validate().is_ok(), "palette: {}", palette.0); assert_eq!(colors.contrast_issues(), vec![], "palette: {}", palette.0); } assert_eq!(Colors::palette(PALETTES.len()), None); } }