Color Picker & Converter — Ruby source
Pick a color and convert between HEX, RGB, HSL, HSV, and CMYK with a live preview. Edit any format and copy the rest - runs entirely in your browser.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# color-picker — color-space conversions, WCAG contrast, and named-color lookup.
#
# Language: Ruby 3.2 (core only — no gems).
#
# Source: CosmoDev polyglot showcase port of the color-picker tool, ported
# from src/lib/colorConvert.ts. Functionally equivalent: identical
# outputs for identical inputs, including clamping, NaN/Infinity
# handling, and nil on invalid hex.
#
# RGB is the canonical hub — every other space converts through it — and
# normalize_color() re-derives the hex from its own clamped RGB so the five
# display formats can never disagree.
#
# License: display source — part of CosmoDev's polyglot tool pages.
module ColorPicker
# A color resolved into every supported space, all mutually consistent.
ColorBundle = Data.define(:hex, :rgb, :hsl, :hsv, :cmyk)
# A named CSS color with its hex.
NamedColor = Data.define(:name, :hex)
module_function
# --- Internal helpers ---------------------------------------------------------
# Every public function is total: invalid hex → nil; out-of-range numbers are
# clamped into their valid interval. Ruby's comparison operators return false
# against NaN, so NaN is handled explicitly first — corrupt input degrades to
# the lowest valid value instead of poisoning the result.
def _clamp(n, lo, hi)
n = n.to_f # Integer inputs are coerced, like Python's math.isnan.
return lo if n.nan?
return hi if n.infinite? == 1
return lo if n.infinite? == -1
n.clamp(lo, hi)
end
def _clamp01(n) = _clamp(n, 0.0, 1.0)
# Round half away from zero — mirrors JS Math.round for the non-negative
# values this module rounds. Ruby's Float#round rounds half away from zero
# too, but the explicit floor(n + 0.5) form documents the parity.
def _round_half_up(n) = (n + 0.5).floor.to_f
# Normalize any hue (negative, >360, or NaN) into [0, 360), matching
# JS ((Number(h) || 0) % 360 + 360) % 360. Ruby's % already floors toward
# the divisor, so the double-wrap is a no-op that mirrors the JS shape.
def _mod360(h)
h = h.to_f # Integer hues are coerced, like Python's math.isnan.
h = 0.0 if h.nan?
((h % 360.0) + 360.0) % 360.0
end
# The two legal hex shapes (3 or 6 hex digits). Used in lieu of fuller parsing
# to keep validation fast and explicit.
HEX3_RE = /\A[0-9a-fA-F]{3}\z/
HEX6_RE = /\A[0-9a-fA-F]{6}\z/
# --- HEX ↔ RGB ------------------------------------------------------------------
# Parse "#rgb" / "#rrggbb" (case-insensitive, "#" optional) into [r, g, b],
# or nil for anything that is not a 3- or 6-digit hex color.
def hex_to_rgb(value)
h = value.strip
h = h[1..] if h.start_with?('#') # TS strips exactly one leading '#'.
h = h.chars.flat_map { |ch| [ch, ch] }.join if HEX3_RE.match?(h)
return nil unless HEX6_RE.match?(h)
[h[0, 2].to_i(16).to_f, h[2, 2].to_i(16).to_f, h[4, 2].to_i(16).to_f]
end
# (r, g, b) (clamped to 0–255) → "#rrggbb" (lowercase, zero-padded).
def rgb_to_hex(r, g, b)
bytes = [r, g, b].map { |n| _round_half_up(_clamp(n, 0.0, 255.0)).to_i }
format('#%02x%02x%02x', *bytes)
end
# --- RGB ↔ HSL ------------------------------------------------------------------
# (r, g, b) (0–255) → [h, s, l] with h: 0–360, s/l: 0–100.
def rgb_to_hsl(r, g, b)
rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
mx = [rn, gn, bn].max
mn = [rn, gn, bn].min
d = mx - mn
l = (mx + mn) / 2.0
h = 0.0
s = 0.0
if d != 0.0
# Saturation formula branches on which half of the lightness axis we sit on.
s = l > 0.5 ? d / (2.0 - mx - mn) : d / (mx + mn)
h = if mx == rn
(gn - bn) / d + (gn < bn ? 6.0 : 0.0)
elsif mx == gn
(bn - rn) / d + 2.0
else
(rn - gn) / d + 4.0
end
h *= 60.0
end
[_round_half_up(h), _round_half_up(s * 100.0), _round_half_up(l * 100.0)]
end
# (h, s, l) (h: 0–360, s/l: 0–100) → [r, g, b] (0–255).
def hsl_to_rgb(h, s, l)
hn = _mod360(h)
sn = _clamp01(s / 100.0)
ln = _clamp01(l / 100.0)
c = (1.0 - (2.0 * ln - 1.0).abs) * sn
x = c * (1.0 - ((hn / 60.0) % 2.0 - 1.0).abs)
m = ln - c / 2.0
rr, gg, bb =
if hn < 60.0
[c, x, 0.0]
elsif hn < 120.0
[x, c, 0.0]
elsif hn < 180.0
[0.0, c, x]
elsif hn < 240.0
[0.0, x, c]
elsif hn < 300.0
[x, 0.0, c]
else
[c, 0.0, x]
end
[(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0]
end
# "#hex" → [h, s, l], or nil when the hex is invalid.
def hex_to_hsl(value)
rgb = hex_to_rgb(value)
return nil if rgb.nil?
rgb_to_hsl(*rgb)
end
# (h, s, l) → "#rrggbb".
def hsl_to_hex(h, s, l) = rgb_to_hex(*hsl_to_rgb(h, s, l))
# --- RGB ↔ HSV ------------------------------------------------------------------
# (r, g, b) (0–255) → [h, s, v] with h: 0–360, s/v: 0–100.
def rgb_to_hsv(r, g, b)
rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
mx = [rn, gn, bn].max
mn = [rn, gn, bn].min
d = mx - mn
h = 0.0
if d != 0.0
h = if mx == rn
(gn - bn) / d + (gn < bn ? 6.0 : 0.0)
elsif mx == gn
(bn - rn) / d + 2.0
else
(rn - gn) / d + 4.0
end
h *= 60.0
end
s = mx == 0.0 ? 0.0 : d / mx
[_round_half_up(h), _round_half_up(s * 100.0), _round_half_up(mx * 100.0)]
end
# (h, s, v) (h: 0–360, s/v: 0–100) → [r, g, b] (0–255).
def hsv_to_rgb(h, s, v)
hn = _mod360(h)
sn = _clamp01(s / 100.0)
vn = _clamp01(v / 100.0)
c = vn * sn
x = c * (1.0 - ((hn / 60.0) % 2.0 - 1.0).abs)
m = vn - c
rr, gg, bb =
if hn < 60.0
[c, x, 0.0]
elsif hn < 120.0
[x, c, 0.0]
elsif hn < 180.0
[0.0, c, x]
elsif hn < 240.0
[0.0, x, c]
elsif hn < 300.0
[x, 0.0, c]
else
[c, 0.0, x]
end
[(rr + m) * 255.0, (gg + m) * 255.0, (bb + m) * 255.0]
end
# (h, s, v) → "#rrggbb".
def hsv_to_hex(h, s, v) = rgb_to_hex(*hsv_to_rgb(h, s, v))
# --- RGB ↔ CMYK -----------------------------------------------------------------
# (r, g, b) (0–255) → [c, m, y, k] (0–100 each).
def rgb_to_cmyk(r, g, b)
rn, gn, bn = _clamp01(r / 255.0), _clamp01(g / 255.0), _clamp01(b / 255.0)
k = 1.0 - [rn, gn, bn].max
return [0.0, 0.0, 0.0, 100.0] if k == 1.0 # pure black — avoid divide-by-zero
c = (1.0 - rn - k) / (1.0 - k)
m = (1.0 - gn - k) / (1.0 - k)
y = (1.0 - bn - k) / (1.0 - k)
[_round_half_up(c * 100.0), _round_half_up(m * 100.0), _round_half_up(y * 100.0), _round_half_up(k * 100.0)]
end
# (c, m, y, k) (0–100 each) → [r, g, b] (0–255).
def cmyk_to_rgb(c, m, y, k)
cn, mn, yn, kn = _clamp01(c / 100.0), _clamp01(m / 100.0), _clamp01(y / 100.0), _clamp01(k / 100.0)
[
255.0 * (1.0 - cn) * (1.0 - kn),
255.0 * (1.0 - mn) * (1.0 - kn),
255.0 * (1.0 - yn) * (1.0 - kn),
]
end
# (c, m, y, k) → "#rrggbb".
def cmyk_to_hex(c, m, y, k) = rgb_to_hex(*cmyk_to_rgb(c, m, y, k))
# --- Round-robin normalizer -------------------------------------------------------
# Resolve any hex into one consistent ColorBundle: the hex is re-derived from
# its own clamped RGB, then HSL/HSV/CMYK are all computed from that same RGB.
# This is the single funnel the UI routes every edit through, so the five
# display formats can never disagree. Returns nil for invalid hex.
def normalize_color(value)
rgb = hex_to_rgb(value)
return nil if rgb.nil?
r, g, b = rgb
ColorBundle.new(
hex: rgb_to_hex(r, g, b),
rgb: rgb,
hsl: rgb_to_hsl(r, g, b),
hsv: rgb_to_hsv(r, g, b),
cmyk: rgb_to_cmyk(r, g, b),
)
end
# --- WCAG luminance, contrast & text suggestion -----------------------------------
# Linearize a single sRGB channel (0–255) per the WCAG 2.x definition.
def _srgb_channel(c)
s = _clamp01(c / 255.0)
return s / 12.92 if s <= 0.03928
((s + 0.055) / 1.055)**2.4
end
# WCAG 2.x relative luminance of a hex (0 = black, 1 = white), or nil.
def relative_luminance(value)
rgb = hex_to_rgb(value)
return nil if rgb.nil?
r, g, b = rgb
(0.2126 * _srgb_channel(r)) + (0.7152 * _srgb_channel(g)) + (0.0722 * _srgb_channel(b))
end
# WCAG contrast ratio between two hexes (1–21), or nil if either is invalid.
def contrast_ratio(a, b)
la = relative_luminance(a)
lb = relative_luminance(b)
return nil if la.nil? || lb.nil?
hi, lo = la >= lb ? [la, lb] : [lb, la]
(hi + 0.05) / (lo + 0.05)
end
# Pick black or white text for maximum legibility on value, or nil if invalid.
def suggest_text_hex(value)
l = relative_luminance(value)
return nil if l.nil?
l > 0.179 ? '#000000' : '#ffffff'
end
# --- Closest named CSS color ------------------------------------------------------
# A curated set of well-known CSS named colors. Kept intentionally to entries
# whose hex is verifiable from memory — exhaustive tables typed by hand risk
# shipping wrong data, which unit tests cannot catch.
NAMED_COLORS_RAW = [
['black', '#000000'], ['dim gray', '#696969'],
['gray', '#808080'], ['dark gray', '#a9a9a9'],
['silver', '#c0c0c0'], ['light gray', '#d3d3d3'],
['gainsboro', '#dcdcdc'], ['white smoke', '#f5f5f5'],
['white', '#ffffff'], ['snow', '#fffafa'],
['ivory', '#fffff0'], ['seashell', '#fff5ee'],
['red', '#ff0000'], ['crimson', '#dc143c'],
['dark red', '#8b0000'],
['firebrick', '#b22222'], ['indian red', '#cd5c5c'],
['salmon', '#fa8072'], ['tomato', '#ff6347'],
['coral', '#ff7f50'], ['orange', '#ffa500'],
['dark orange', '#ff8c00'], ['gold', '#ffd700'],
['chocolate', '#d2691e'], ['brown', '#a52a2a'],
['sienna', '#a0522d'], ['tan', '#d2b48c'],
['yellow', '#ffff00'], ['khaki', '#f0e68c'],
['lime', '#00ff00'], ['lime green', '#32cd32'],
['forest green', '#228b22'], ['sea green', '#2e8b57'],
['green', '#008000'], ['dark green', '#006400'],
['spring green', '#00ff7f'], ['olive', '#808000'],
['teal', '#008080'], ['dark cyan', '#008b8b'],
['turquoise', '#40e0d0'], ['cyan', '#00ffff'],
['sky blue', '#87ceeb'],
['deep sky blue', '#00bfff'], ['steel blue', '#4682b4'],
['dodger blue', '#1e90ff'], ['royal blue', '#4169e1'],
['blue', '#0000ff'], ['navy', '#000080'],
['midnight blue', '#191970'], ['indigo', '#4b0082'],
['purple', '#800080'], ['dark violet', '#9400d3'],
['blue violet', '#8a2be2'], ['medium purple', '#9370db'],
['orchid', '#da70d6'], ['violet', '#ee82ee'],
['plum', '#dda0dd'], ['magenta', '#ff00ff'],
['deep pink', '#ff1493'], ['hot pink', '#ff69b4'],
['pink', '#ffc0cb'], ['lavender', '#e6e6fa'],
].freeze
# Precompute each named color's RGB once (lazy on first use), mirroring the
# TS source's eager resolution so nearest-match lookups never re-parse.
def _named_colors
@named_colors ||= NAMED_COLORS_RAW.map do |name, hx|
[name, hx, hex_to_rgb(hx)]
end
end
# Closest entry in the named-color table by squared RGB Euclidean distance.
# Returns nil for invalid input. The table is a curated subset, so "closest"
# is approximate, not a guarantee of identity.
def nearest_named_color(value)
rgb = hex_to_rgb(value)
return nil if rgb.nil?
r, g, b = rgb
best = nil
best_d = Float::INFINITY
_named_colors.each do |name, hx, (nr, ng, nb)|
d = (nr - r)**2 + (ng - g)**2 + (nb - b)**2
next unless d < best_d
best_d = d
best = NamedColor.new(name: name, hex: hx)
end
best
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 →