Added customizable colours of the public page with palettes and live preview. Fixes #14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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<Self> {
|
||||
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<ContrastIssue> {
|
||||
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 `<input type="color">` 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)", "</style>", "\"", "#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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user