Color Palette Generator — Rust source
Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! palette-generator — polyglot showcase port (Rust).
//!
//! Pure color-theory helpers for the Color Palette Generator. No I/O, no
//! external dependencies — standard library only. All inputs are clamped; the
//! functions return sensible defaults (black) rather than panicking on bad
//! input.
//!
//! Ported from src/lib/colorPalette.ts (TypeScript, the canonical
//! implementation). This is display source — part of CosmoDev's polyglot tool
//! pages (dev.cosmolabs.org), where each tool's pure logic is shown side-by-side
//! in six languages.
/// An 8-bit-per-channel color — the output of hex-to-RGB parsing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rgb {
pub r: u8,
pub g: u8,
pub b: u8,
}
/// Hue in [0, 360) and saturation/lightness in [0, 100].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hsl {
pub h: f64,
pub s: f64,
pub l: f64,
}
/// Selects which color-harmony rule `generate_palette` applies. The TypeScript
/// source models this as a string union; Rust models it as an enum, which makes
/// invalid schemes unrepresentable (so there is no "default" fall-through).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scheme {
Complement,
SplitComplement,
Analogous,
Triadic,
Tetradic,
Monochromatic,
}
/// True when `c` is a hexadecimal digit (case-insensitive). The Rust standard
/// library has no regex crate, so the hex-shape check is done by hand — the
/// equivalent of the TS `/^[0-9a-fA-F]{6}$/` test.
fn is_hex_digit(c: char) -> bool {
c.is_ascii_digit() || ('a'..='f').contains(&c) || ('A'..='F').contains(&c)
}
/// Parse a 6-hex-digit string into an `Rgb`, falling back to black if invalid.
fn parse6(h: &str) -> Rgb {
if h.len() != 6 || !h.chars().all(is_hex_digit) {
return Rgb { r: 0, g: 0, b: 0 };
}
// from_str_radix cannot fail here — length and charset are already checked.
Rgb {
r: u8::from_str_radix(&h[0..2], 16).unwrap_or(0),
g: u8::from_str_radix(&h[2..4], 16).unwrap_or(0),
b: u8::from_str_radix(&h[4..6], 16).unwrap_or(0),
}
}
/// Parse any reasonable hex string (`#rgb` / `#rrggbb`, with or without a
/// leading `#`) into an `Rgb`. Unparseable input falls back to black.
pub fn hex_to_rgb(hex: &str) -> Rgb {
let trimmed = hex.trim();
// Strip at most one leading '#', matching the TS replace(/^#/, "").
let h = trimmed.strip_prefix('#').unwrap_or(trimmed);
if h.len() == 3 && h.chars().all(is_hex_digit) {
// Expand CSS shorthand: each digit doubles (#abc -> #aabbcc).
let doubled: String = h.chars().flat_map(|c| [c, c]).collect();
return parse6(&doubled);
}
parse6(h)
}
/// Round half away from zero, then clamp to [0, 255]. `f64::round` rounds half
/// away from zero, which matches TypeScript's `Math.round` on the non-negative
/// color values these helpers produce.
fn clamp_byte(n: f64) -> u8 {
n.round().clamp(0.0, 255.0) as u8
}
/// Pack three float channels into a lowercase `#rrggbb` string.
fn rgb_to_hex(r: f64, g: f64, b: f64) -> String {
format!("#{:02x}{:02x}{:02x}", clamp_byte(r), clamp_byte(g), clamp_byte(b))
}
/// Convert hex → HSL. `h` ∈ [0, 360), `s`/`l` ∈ [0, 100]. Achromatic colors
/// (gray, white, black) collapse to `h = 0, s = 0`.
pub fn hex_to_hsl(hex: &str) -> Hsl {
let rgb = hex_to_rgb(hex);
let r = rgb.r as f64 / 255.0;
let g = rgb.g as f64 / 255.0;
let b = rgb.b as f64 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let mut h = 0.0;
let mut s = 0.0;
if max != min {
let d = max - min;
s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
// Which channel is max determines the hue sextant.
if max == r {
h = (g - b) / d + if g < b { 6.0 } else { 0.0 };
} else if max == g {
h = (b - r) / d + 2.0;
} else {
h = (r - g) / d + 4.0;
}
h /= 6.0;
}
Hsl { h: h * 360.0, s: s * 100.0, l: l * 100.0 }
}
/// Convert HSL → hex. `h` wraps modulo 360 (so `h + 150` etc. stay in range);
/// `s` and `l` clamp to [0, 100].
pub fn hsl_to_hex(h: f64, s: f64, l: f64) -> String {
// Rust's % is a remainder (keeps the dividend's sign, like JS), so add 360
// and mod again to land negative rotations in [0, 360).
let h = (h % 360.0 + 360.0) % 360.0;
let s = s.clamp(0.0, 100.0) / 100.0;
let l = l.clamp(0.0, 100.0) / 100.0;
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = l - c / 2.0;
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
rgb_to_hex((r + m) * 255.0, (g + m) * 255.0, (b + m) * 255.0)
}
/// Generate a harmonious palette from a base color.
///
/// Counts: `Complement` = 2, `SplitComplement` = 3, `Analogous` = 3,
/// `Triadic` = 3, `Tetradic` = 4. `count` is honored by `Monochromatic`
/// (default 5): the base hue and saturation are held while lightness spreads
/// across `count` steps.
pub fn generate_palette(base_hex: &str, scheme: Scheme, count: usize) -> Vec<String> {
let Hsl { h, s, l } = hex_to_hsl(base_hex);
let base = hsl_to_hex(h, s, l);
// Rotate the hue by `deg` degrees, holding saturation and lightness fixed.
let rot = |deg: f64| -> String { hsl_to_hex(h + deg, s, l) };
match scheme {
Scheme::Complement => vec![base, rot(180.0)],
Scheme::SplitComplement => vec![base, rot(150.0), rot(210.0)],
Scheme::Analogous => vec![rot(-30.0), base, rot(30.0)],
Scheme::Triadic => vec![base, rot(120.0), rot(240.0)],
Scheme::Tetradic => vec![base, rot(90.0), rot(180.0), rot(270.0)],
Scheme::Monochromatic => {
let n = count.max(1);
// Pin the lightness window to [10, 90] so swatches never fully wash
// out or go black, regardless of the base color's own lightness.
let lo = (l - 32.0).max(10.0);
let hi = (l + 32.0).min(90.0);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let ll = if n == 1 {
l
} else {
lo + (hi - lo) * i as f64 / (n - 1) as f64
};
out.push(hsl_to_hex(h, s, ll));
}
out
}
}
}
/// `n` shades — the base color mixed progressively toward black (RGB lerp).
/// `i` runs 1..=n so the fraction `i/(n+1)` never hits 0 or 1: the base itself
/// is never returned, only intermediate steps toward black.
pub fn shades(base_hex: &str, n: usize) -> Vec<String> {
let rgb = hex_to_rgb(base_hex);
let (r, g, b) = (rgb.r as f64, rgb.g as f64, rgb.b as f64);
let steps = n.max(1);
let mut out = Vec::with_capacity(steps);
for i in 1..=steps {
let f = i as f64 / (steps + 1) as f64;
out.push(rgb_to_hex(r * (1.0 - f), g * (1.0 - f), b * (1.0 - f)));
}
out
}
/// `n` tints — the base color mixed progressively toward white (RGB lerp).
/// `i` runs 1..=n so the base itself is never returned.
pub fn tints(base_hex: &str, n: usize) -> Vec<String> {
let rgb = hex_to_rgb(base_hex);
let (r, g, b) = (rgb.r as f64, rgb.g as f64, rgb.b as f64);
let steps = n.max(1);
let mut out = Vec::with_capacity(steps);
for i in 1..=steps {
let f = i as f64 / (steps + 1) as f64;
out.push(rgb_to_hex(
r + (255.0 - r) * f,
g + (255.0 - g) * f,
b + (255.0 - b) * f,
));
}
out
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →