Skip to content

Color Palette Generator — Ruby source

Generate harmonious color palettes - complementary, analogous, triadic, tetradic, and monochromatic - from any base color. Export to CSS, Tailwind, or JSON.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# palette-generator — harmonious palettes from a base color.
# Ruby 3.1+, stdlib only. CosmoDev polyglot port of src/lib/colorPalette.ts.
# All inputs clamped; nothing raises — bad hex falls back to black.

module PaletteGenerator
  HEX_RE = /\A[0-9a-fA-F]+\z/.freeze
  module_function

  # Parse #rgb / #rrggbb (leading # optional); fallback black.
  def hex_to_rgb(hex)
    h = hex.to_s.strip.sub(/\A#/, '')
    return [0, 0, 0] unless (h.length == 3 || h.length == 6) && HEX_RE.match?(h)
    h = h.chars.map { |c| c * 2 }.join if h.length == 3
    [h[0, 2].to_i(16), h[2, 2].to_i(16), h[4, 2].to_i(16)]
  end

  # Round half up (TS Math.round), then clamp to [0, 255].
  def clamp_byte(n) = n.round(half: :up).clamp(0, 255)

  def rgb_to_hex(r, g, b) = format('#%02x%02x%02x', clamp_byte(r), clamp_byte(g), clamp_byte(b))

  # hex -> HSL. h in [0,360), s/l in [0,100]; achromatic gives h = 0.
  def hex_to_hsl(hex)
    r, g, b = hex_to_rgb(hex).map { |c| c / 255.0 }
    mx = [r, g, b].max
    mn = [r, g, b].min
    l = (mx + mn) / 2
    h = 0.0
    s = 0.0
    if mx != mn
      d = mx - mn
      s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn)
      h = mx == r ? (g - b) / d + (g < b ? 6 : 0) : mx == g ? (b - r) / d + 2 : (r - g) / d + 4
      h /= 6
    end
    [h * 360, s * 100, l * 100]
  end

  # HSL -> hex. Ruby % is a true modulo, so the wrap needs no double-mod.
  def hsl_to_hex(h, s, l)
    big = h % 360
    sat = s.clamp(0.0, 100.0) / 100
    lig = l.clamp(0.0, 100.0) / 100
    c = (1 - (2 * lig - 1).abs) * sat
    x = c * (1 - ((big / 60) % 2 - 1).abs)
    m = lig - c / 2
    # sextant table: channel weights per 60-degree hue sector
    r, g, b = [[c, x, 0.0], [x, c, 0.0], [0.0, c, x], [0.0, x, c], [x, 0.0, c], [c, 0.0, x]][(big / 60).floor]
    rgb_to_hex((r + m) * 255, (g + m) * 255, (b + m) * 255)
  end

  # Generate a harmony. Counts: complement 2, split-complement 3, analogous 3,
  # triadic 3, tetradic 4; count steers only monochromatic.
  def generate_palette(base_hex, scheme, count = 5)
    h, s, l = hex_to_hsl(base_hex)
    rot = ->(deg) { hsl_to_hex(h + deg, s, l) }
    base = hsl_to_hex(h, s, l)
    case scheme
    when 'complement' then [base, rot.call(180)]
    when 'split-complement' then [base, rot.call(150), rot.call(210)]
    when 'analogous' then [rot.call(-30), base, rot.call(30)]
    when 'triadic' then [base, rot.call(120), rot.call(240)]
    when 'tetradic' then [base, rot.call(90), rot.call(180), rot.call(270)]
    when 'monochromatic'
      n = [1, count.floor].max
      lo = [10.0, l - 32].max
      hi = [90.0, l + 32].min
      (0...n).map { |i| hsl_to_hex(h, s, n == 1 ? l : lo + (hi - lo) * i / (n - 1)) }
    else [base]
    end
  end

  # n shades / tints — RGB lerp toward black / white; i runs 1..n so the base
  # color itself is never returned, only intermediate steps.
  def shades(base_hex, n) = mix(base_hex, n) { |c, f| c * (1 - f) }
  def tints(base_hex, n) = mix(base_hex, n) { |c, f| c + (255 - c) * f }

  def mix(base_hex, n)
    r, g, b = hex_to_rgb(base_hex)
    steps = [1, n.floor].max
    (1..steps).map { |i| f = i.to_f / (steps + 1); rgb_to_hex(yield(r, f), yield(g, f), yield(b, f)) }
  end
end

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 →