Skip to content

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 →