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:
2026-10-12 21:54:24 +02:00
co-authored by Claude Opus 5.5
parent 1a1688d322
commit 9da18a67ac
12 changed files with 760 additions and 11 deletions
+292
View File
@@ -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);
}
}