Class: Unmagic::Color::OKLCH
- Inherits:
-
Unmagic::Color
- Object
- Unmagic::Color
- Unmagic::Color::OKLCH
- Defined in:
- lib/unmagic/color/oklch.rb,
lib/unmagic/color/oklch/gradient/linear.rb
Overview
‘OKLCH` (Lightness, Chroma, Hue) color representation.
## Understanding OKLCH
OKLCH is a modern color space designed to match how humans actually perceive colors. Unlike RGB or even HSL, OKLCH ensures that colors with the same lightness value look equally bright to our eyes, regardless of their hue.
## The Problem with RGB and HSL
In RGB and HSL, pure yellow and pure blue can have the same “lightness” value, but yellow looks much brighter to our eyes. This makes it hard to create consistent-looking color palettes.
OKLCH solves this by being “perceptually uniform” - if you change lightness by ‘0.1`, it looks like the same amount of change whether you’re working with red, green, blue, or any other hue.
## The Three Components
-
Lightness (‘0.0-1.0`): How bright the color appears
-
‘0.0` = Black
-
‘0.5` = Medium brightness
-
‘1.0` = White
Unlike HSL, this matches perceived brightness consistently across all hues.
-
-
Chroma (‘0.0-0.5`): How colorful/saturated it is
-
‘0.0` = Gray (no color)
-
‘0.15` = Moderate color (good for UI)
-
‘0.3+` = Very vivid (use sparingly)
Think of it like saturation, but more accurate to perception.
-
-
Hue (‘0-360°`): The color itself (same as HSL)
-
‘0°/360°` = Red
-
‘120°` = Green
-
‘240°` = Blue
-
## Why Use OKLCH?
-
Creating accessible color palettes (ensure consistent contrast)
-
Generating color scales that look evenly spaced
-
Interpolating between colors smoothly
-
Matching colors that “feel” equally bright
## When to Use Each Color Space
-
**RGB**: When working with screens/displays directly
-
**HSL**: When you need intuitive color manipulation
-
OKLCH: When you need perceptually accurate colors (design systems, accessibility)
## Examples
# Parse OKLCH colors
color = Unmagic::Color::OKLCH.parse("oklch(0.65 0.15 240)") # Medium blue
# Create directly
accessible = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
# Access components
color.lightness #=> 0.65 (ratio form)
color.chroma.value #=> 0.15
color.hue.value #=> 240
# Create perceptually uniform variations
lighter = color.lighten(0.05) # Looks 5% brighter
less_colorful = color.desaturate(0.03)
# Generate consistent colors
Unmagic::Color::OKLCH.derive("user@example.com".hash) # Perceptually balanced color
Defined Under Namespace
Modules: Gradient Classes: ParseError
Constant Summary
Constants inherited from Unmagic::Color
Blue, DATA_PATH, Green, Red, VERSION
Constants included from Harmony
Harmony::SCALE_CHROMA_CURVE, Harmony::SCALE_LIGHTNESS_DEFAULT, Harmony::SCALE_LIGHTNESS_SHAPE
Instance Attribute Summary collapse
-
#alpha ⇒ Object
readonly
Returns the value of attribute alpha.
-
#chroma ⇒ Object
readonly
Returns the value of attribute chroma.
-
#hue ⇒ Object
readonly
Returns the value of attribute hue.
Class Method Summary collapse
-
.build(*args, **kwargs) ⇒ OKLCH
Build an OKLCH color from a string, positional values, or keyword arguments.
-
.derive(seed, lightness: 0.58, chroma_range: (0.10..0.18), hue_spread: 997, hue_base: 137.508) ⇒ OKLCH
Generate a deterministic OKLCH color from an integer seed.
-
.parse(input) ⇒ OKLCH
Parse an OKLCH color from a string.
Instance Method Summary collapse
-
#==(other) ⇒ Boolean
Check if two OKLCH colors are equal.
-
#blend(other, amount = 0.5) ⇒ OKLCH
Blend this color with another color in OKLCH space.
-
#clamp_to_gamut ⇒ OKLCH
Pull this color into the sRGB gamut by reducing chroma.
-
#darken(amount = 0.03) ⇒ OKLCH
Create a darker version by decreasing lightness.
-
#desaturate(amount = 0.02) ⇒ OKLCH
Create a less saturated version by decreasing chroma.
-
#in_gamut?(epsilon = 1e-4) ⇒ Boolean
Whether this color can be displayed in the sRGB gamut.
-
#initialize(lightness:, chroma:, hue:, alpha: nil) ⇒ OKLCH
constructor
Create a new OKLCH color.
-
#lighten(amount = 0.03) ⇒ OKLCH
Create a lighter version by increasing lightness.
-
#lightness ⇒ Float
Get the lightness as a ratio (0.0-1.0).
-
#lightness_percentage ⇒ Float
Get the lightness as a percentage (0.0-100.0).
-
#luminance ⇒ Float
Calculate the relative luminance.
-
#pretty_print(pp) ⇒ Object
Pretty print support with colored swatch in class name.
-
#rotate(amount = 10) ⇒ OKLCH
Rotate the hue by a specified amount.
-
#saturate(amount = 0.02) ⇒ OKLCH
Create a more saturated version by increasing chroma.
-
#to_ansi(layer: :foreground, mode: :truecolor) ⇒ String
Convert to ANSI SGR color code.
-
#to_css_color_mix(bg_css = "var(--bg)", a_pct: 72, bg_pct: 28) ⇒ String
Create a CSS color-mix() expression.
-
#to_css_oklch ⇒ String
Convert to CSS oklch() function format.
-
#to_css_vars ⇒ String
Convert to CSS custom properties (variables).
-
#to_hex ⇒ String
Convert to hex string.
-
#to_hsl ⇒ HSL
Convert to HSL color space.
-
#to_oklab ⇒ Array<Float>
Convert to the OKLab color space as ‘[lightness, a, b]`.
-
#to_oklch ⇒ OKLCH
Convert to OKLCH color space.
-
#to_rgb ⇒ RGB
Convert to RGB color space.
-
#to_s ⇒ String
Convert to string representation.
Methods inherited from Unmagic::Color
Methods included from Harmony
#analogous, #complementary, #monochromatic, #scale, #shades, #split_complementary, #tetradic_rectangle, #tetradic_square, #tints, #tones, #triadic
Constructor Details
#initialize(lightness:, chroma:, hue:, alpha: nil) ⇒ OKLCH
Create a new OKLCH color.
97 98 99 100 101 102 103 |
# File 'lib/unmagic/color/oklch.rb', line 97 def initialize(lightness:, chroma:, hue:, alpha: nil) super() @lightness = Color::Lightness.new(value: lightness * 100) # Convert 0-1 to percentage @chroma = Color::Chroma.new(value: chroma) @hue = Color::Hue.new(value: hue) @alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT end |
Instance Attribute Details
#alpha ⇒ Object (readonly)
Returns the value of attribute alpha.
80 81 82 |
# File 'lib/unmagic/color/oklch.rb', line 80 def alpha @alpha end |
#chroma ⇒ Object (readonly)
Returns the value of attribute chroma.
80 81 82 |
# File 'lib/unmagic/color/oklch.rb', line 80 def chroma @chroma end |
#hue ⇒ Object (readonly)
Returns the value of attribute hue.
80 81 82 |
# File 'lib/unmagic/color/oklch.rb', line 80 def hue @hue end |
Class Method Details
.build(*args, **kwargs) ⇒ OKLCH
Build an OKLCH color from a string, positional values, or keyword arguments.
207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/unmagic/color/oklch.rb', line 207 def build(*args, **kwargs) if kwargs.any? new(**kwargs) elsif args.length == 1 parse(args[0]) elsif args.length == 3 values = args.map { |v| v.is_a?(::String) ? v.to_f : v } new(lightness: values[0], chroma: values[1], hue: values[2]) else raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}" end end |
.derive(seed, lightness: 0.58, chroma_range: (0.10..0.18), hue_spread: 997, hue_base: 137.508) ⇒ OKLCH
Generate a deterministic OKLCH color from an integer seed.
Creates perceptually balanced, visually distinct colors. This is particularly effective in OKLCH because the perceptual uniformity ensures all generated colors have consistent perceived brightness and saturation.
The hue distribution uses a golden-angle approach to spread colors evenly and avoid clustering similar hues together.
245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/unmagic/color/oklch.rb', line 245 def derive(seed, lightness: 0.58, chroma_range: (0.10..0.18), hue_spread: 997, hue_base: 137.508) raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer) h32 = seed & 0xFFFFFFFF # Ensure 32-bit # Hue: golden-angle style distribution to avoid clusters h = (hue_base * (h32 % hue_spread)) % 360 # Chroma: map a byte into a safe text-friendly range c = chroma_range.begin + ((h32 >> 8) & 0xFF) / 255.0 * (chroma_range.end - chroma_range.begin) new(lightness: lightness, chroma: c, hue: h) end |
.parse(input) ⇒ OKLCH
Parse an OKLCH color from a string.
Accepts formats:
-
CSS format: “oklch(0.65 0.15 240)” or “oklch(0.65 0.15 240 / 0.5)”
-
Raw values: “0.65 0.15 240”
-
Space-separated values with optional alpha after slash
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/unmagic/color/oklch.rb', line 140 def parse(input) raise ParseError, "Input must be a string" unless input.is_a?(::String) # Remove oklch() wrapper if present clean = input.gsub(/^oklch\s*\(\s*|\s*\)$/, "").strip # Check for alpha with slash separator alpha = nil if clean.include?("/") parts = clean.split("/").map(&:strip) raise ParseError, "Invalid format with /: expected 'L C H / alpha'" unless parts.length == 2 clean = parts[0] alpha = Color::Alpha.parse(parts[1]) end # Split values parts = clean.split(/\s+/) unless parts.length == 3 raise ParseError, "Expected 3 OKLCH values, got #{parts.length}" end # Check if all values are numeric parts.each_with_index do |v, i| unless v.match?(/\A\d+(\.\d+)?\z/) component = ["lightness", "chroma", "hue"][i] raise ParseError, "Invalid #{component} value: #{v.inspect} (must be a number)" end end # Convert to floats l = parts[0].to_f c = parts[1].to_f h = parts[2].to_f # Validate ranges if l < 0 || l > 1 raise ParseError, "Lightness must be between 0 and 1, got #{l}" end if c < 0 || c > 0.5 raise ParseError, "Chroma must be between 0 and 0.5, got #{c}" end if h < 0 || h >= 360 raise ParseError, "Hue must be between 0 and 360, got #{h}" end new(lightness: l, chroma: c, hue: h, alpha: alpha) end |
Instance Method Details
#==(other) ⇒ Boolean
Check if two OKLCH colors are equal.
540 541 542 543 544 545 |
# File 'lib/unmagic/color/oklch.rb', line 540 def ==(other) other.is_a?(Unmagic::Color::OKLCH) && lightness == other.lightness && chroma == other.chroma && hue == other.hue end |
#blend(other, amount = 0.5) ⇒ OKLCH
Blend this color with another color in OKLCH space.
Blending in OKLCH produces perceptually smooth color transitions. Uses shortest-arc hue interpolation to avoid going the long way around the color wheel.
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 |
# File 'lib/unmagic/color/oklch.rb', line 459 def blend(other, amount = 0.5) amount = amount.to_f.clamp(0, 1) other_oklch = other.respond_to?(:to_oklch) ? other.to_oklch : other # Blend in OKLCH space with shortest-arc hue interpolation dh = (((other_oklch.hue.value - @hue.value + 540) % 360) - 180) new_hue = (@hue.value + dh * amount) % 360 new_lightness = lightness + (other_oklch.lightness - lightness) * amount new_chroma = @chroma.value + (other_oklch.chroma.value - @chroma.value) * amount self.class.new( lightness: new_lightness, chroma: new_chroma, hue: new_hue, alpha: @alpha.value * (1 - amount) + other_oklch.alpha.value * amount, ) end |
#clamp_to_gamut ⇒ OKLCH
Pull this color into the sRGB gamut by reducing chroma.
Holds lightness and hue fixed and binary-searches chroma downward until the color is displayable. An already-displayable color is returned unchanged. This is the perceptually correct way to map an out-of-gamut OKLCH color — clipping RGB channels instead shifts hue.
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/unmagic/color/oklch.rb', line 338 def clamp_to_gamut return self if in_gamut? lo = 0.0 hi = @chroma.value 20.times do mid = (lo + hi) / 2.0 candidate = self.class.new(lightness: @lightness.to_ratio, chroma: mid, hue: @hue.value, alpha: @alpha.value) if candidate.in_gamut? lo = mid else hi = mid end end self.class.new(lightness: @lightness.to_ratio, chroma: lo, hue: @hue.value, alpha: @alpha.value) end |
#darken(amount = 0.03) ⇒ OKLCH
Create a darker version by decreasing lightness.
401 402 403 404 405 |
# File 'lib/unmagic/color/oklch.rb', line 401 def darken(amount = 0.03) current_lightness = @lightness.to_ratio new_lightness = clamp01(current_lightness - amount) self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value) end |
#desaturate(amount = 0.02) ⇒ OKLCH
Create a less saturated version by decreasing chroma.
428 429 430 431 |
# File 'lib/unmagic/color/oklch.rb', line 428 def desaturate(amount = 0.02) new_chroma = [@chroma.value - amount, 0.0].max self.class.new(lightness: @lightness.to_ratio, chroma: new_chroma, hue: @hue.value) end |
#in_gamut?(epsilon = 1e-4) ⇒ Boolean
Whether this color can be displayed in the sRGB gamut.
OKLCH can describe colors no sRGB monitor can show — typically high-chroma colors near very light or very dark lightness, where the sRGB gamut narrows. This returns false for those.
322 323 324 |
# File 'lib/unmagic/color/oklch.rb', line 322 def in_gamut?(epsilon = 1e-4) to_linear_srgb.all? { |channel| channel >= -epsilon && channel <= 1.0 + epsilon } end |
#lighten(amount = 0.03) ⇒ OKLCH
Create a lighter version by increasing lightness.
In OKLCH, lightness changes are perceptually uniform, so adding 0.05 will look like the same brightness increase regardless of the hue.
387 388 389 390 391 |
# File 'lib/unmagic/color/oklch.rb', line 387 def lighten(amount = 0.03) current_lightness = @lightness.to_ratio new_lightness = clamp01(current_lightness + amount) self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value, alpha: @alpha.value) end |
#lightness ⇒ Float
Get the lightness as a ratio (0.0-1.0).
This overrides the attr_reader to return the ratio form, which is the standard way to work with OKLCH lightness.
111 |
# File 'lib/unmagic/color/oklch.rb', line 111 def lightness = @lightness.to_ratio |
#lightness_percentage ⇒ Float
Get the lightness as a percentage (0.0-100.0).
Helper method for when you need the percentage form instead of ratio.
118 |
# File 'lib/unmagic/color/oklch.rb', line 118 def lightness_percentage = @lightness.value |
#luminance ⇒ Float
Calculate the relative luminance.
In OKLCH, the lightness value directly represents perceptual luminance, so we can use it as-is.
371 372 373 374 |
# File 'lib/unmagic/color/oklch.rb', line 371 def luminance # OKLCH lightness is perceptually uniform, so we can use it directly @lightness.to_ratio # Return 0-1 range end |
#pretty_print(pp) ⇒ Object
Pretty print support with colored swatch in class name.
Outputs standard Ruby object format with a colored block character embedded in the class name area. Note: @lightness is shown via its inspect method since it’s a Lightness percentage object.
585 586 587 |
# File 'lib/unmagic/color/oklch.rb', line 585 def pretty_print(pp) pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @lightness=#{@lightness.inspect} @chroma=#{@chroma.value.round(2)} @hue=#{@hue.value.round}>") end |
#rotate(amount = 10) ⇒ OKLCH
Rotate the hue by a specified amount.
441 442 443 444 |
# File 'lib/unmagic/color/oklch.rb', line 441 def rotate(amount = 10) new_hue = (@hue.value + amount) % 360 self.class.new(lightness: @lightness.to_ratio, chroma: @chroma.value, hue: new_hue) end |
#saturate(amount = 0.02) ⇒ OKLCH
Create a more saturated version by increasing chroma.
415 416 417 418 |
# File 'lib/unmagic/color/oklch.rb', line 415 def saturate(amount = 0.02) new_chroma = [@chroma.value + amount, 0.4].min self.class.new(lightness: @lightness.to_ratio, chroma: new_chroma, hue: @hue.value) end |
#to_ansi(layer: :foreground, mode: :truecolor) ⇒ String
Convert to ANSI SGR color code.
Converts to RGB first, then generates the ANSI code.
568 569 570 |
# File 'lib/unmagic/color/oklch.rb', line 568 def to_ansi(layer: :foreground, mode: :truecolor) to_rgb.to_ansi(layer: layer, mode: mode) end |
#to_css_color_mix(bg_css = "var(--bg)", a_pct: 72, bg_pct: 28) ⇒ String
Create a CSS color-mix() expression.
Generates a CSS color-mix expression that blends this color with a background color (typically a CSS variable).
532 533 534 |
# File 'lib/unmagic/color/oklch.rb', line 532 def to_css_color_mix(bg_css = "var(--bg)", a_pct: 72, bg_pct: 28) "color-mix(in oklch, #{to_css_oklch} #{a_pct}%, #{bg_css} #{bg_pct}%)" end |
#to_css_oklch ⇒ String
Convert to CSS oklch() function format.
490 491 492 493 494 495 496 |
# File 'lib/unmagic/color/oklch.rb', line 490 def to_css_oklch if @alpha.value < 100 format("oklch(%.4f %.4f %.2f / %s)", @lightness.to_ratio, @chroma.value, @hue.value, @alpha.to_css) else format("oklch(%.4f %.4f %.2f)", @lightness.to_ratio, @chroma.value, @hue.value) end end |
#to_css_vars ⇒ String
Convert to CSS custom properties (variables).
Outputs the color as CSS variables for lightness, chroma, and hue that can be manipulated or mixed at runtime in CSS.
509 510 511 |
# File 'lib/unmagic/color/oklch.rb', line 509 def to_css_vars format("--ul:%.4f;--uc:%.4f;--uh:%.2f;", @lightness.to_ratio, @chroma.value, @hue.value) end |
#to_hex ⇒ String
Convert to hex string.
Converts via RGB as an intermediate step.
361 362 363 |
# File 'lib/unmagic/color/oklch.rb', line 361 def to_hex to_rgb.to_hex end |
#to_hsl ⇒ HSL
Convert to HSL color space.
Converts via RGB as an intermediate step.
274 275 276 |
# File 'lib/unmagic/color/oklch.rb', line 274 def to_hsl to_rgb.to_hsl end |
#to_oklab ⇒ Array<Float>
Convert to the OKLab color space as ‘[lightness, a, b]`.
OKLab is the cartesian form of OKLCH: ‘a` is the green–red axis and `b` is the blue–yellow axis. The Euclidean distance between two OKLab triples is a perceptual color difference (ΔE).
289 290 291 292 293 |
# File 'lib/unmagic/color/oklch.rb', line 289 def to_oklab h_rad = @hue.value * Math::PI / 180.0 c = @chroma.value [@lightness.to_ratio, c * Math.cos(h_rad), c * Math.sin(h_rad)] end |
#to_oklch ⇒ OKLCH
Convert to OKLCH color space.
Since this is already an OKLCH color, returns self.
265 266 267 |
# File 'lib/unmagic/color/oklch.rb', line 265 def to_oklch self end |
#to_rgb ⇒ RGB
Convert to RGB color space.
Uses the full OKLab color-science pipeline: OKLCH → OKLab → linear sRGB → gamma-encoded sRGB. Colors outside the sRGB gamut are clipped per channel; call #clamp_to_gamut first for a perceptual fit.
302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/unmagic/color/oklch.rb', line 302 def to_rgb require_relative "rgb" r, g, b = to_linear_srgb.map { |channel| linear_to_srgb(channel) } Unmagic::Color::RGB.new( red: (r * 255).round.clamp(0, 255), green: (g * 255).round.clamp(0, 255), blue: (b * 255).round.clamp(0, 255), alpha: @alpha, ) end |
#to_s ⇒ String
Convert to string representation.
Returns the CSS oklch() function format.
552 553 554 |
# File 'lib/unmagic/color/oklch.rb', line 552 def to_s to_css_oklch end |