Class: Unmagic::Color::HSL

Inherits:
Unmagic::Color show all
Defined in:
lib/unmagic/color/hsl.rb,
lib/unmagic/color/hsl/gradient/linear.rb

Overview

‘HSL` (Hue, Saturation, Lightness) color representation.

## Understanding HSL

While RGB describes colors as mixing light, HSL describes colors in a way that’s more intuitive to humans. It separates the “what color” from “how vibrant” and “how bright.”

## The Three Components

  1. Hue (‘0-360°`): The actual color on the color wheel

    • ‘0°/360°` = Red

    • ‘60°` = Yellow

    • ‘120°` = Green

    • ‘180°` = Cyan

    • ‘240°` = Blue

    • ‘300°` = Magenta

    Think of it as rotating around a circle of colors.

  2. Saturation (‘0-100%`): How pure/intense the color is

    • ‘0%` = Gray (no color, just brightness)

    • ‘50%` = Moderate color

    • ‘100%` = Full, vivid color

    Think of it as “how much color” vs “how much gray.”

  3. Lightness (‘0-100%`): How bright the color is

    • ‘0%` = Black (no light)

    • ‘50%` = Pure color

    • ‘100%` = White (full light)

    Think of it as a dimmer switch.

## Why HSL is Useful

HSL makes it easy to:

  • Create color variations (keep hue, adjust saturation/lightness)

  • Generate color schemes (change hue by fixed amounts)

  • Make colors lighter/darker without changing their “color-ness”

## Common Patterns

  • **Pastel colors**: High lightness, medium-low saturation (‘70-80% L`, `30-50% S`)

  • **Vibrant colors**: Medium lightness, high saturation (‘50% L`, `80-100% S`)

  • **Dark colors**: Low lightness, any saturation (‘20-30% L`)

  • **Muted colors**: Medium lightness and saturation (‘40-60% L`, `30-50% S`)

## Examples

# Parse HSL colors
color = Unmagic::Color::HSL.parse("hsl(120, 100%, 50%)")  # Pure green
color = Unmagic::Color::HSL.parse("240, 50%, 75%")        # Light blue

# Create directly
red = Unmagic::Color::HSL.new(hue: 0, saturation: 100, lightness: 50)
pastel = Unmagic::Color::HSL.new(hue: 180, saturation: 40, lightness: 80)

# Access components
color.hue.value         #=> 120 (degrees)
color.saturation.value  #=> 100 (percent)
color.lightness.value   #=> 50 (percent)

# Easy color variations
lighter = color.lighten(0.2)    # Increase lightness
muted = color.desaturate(0.3)   # Reduce saturation

# Generate color from text
Unmagic::Color::HSL.derive("user@example.com".hash)  # Consistent 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

Unmagic::Color::Harmony::SCALE_CHROMA_CURVE, Unmagic::Color::Harmony::SCALE_LIGHTNESS_DEFAULT, Unmagic::Color::Harmony::SCALE_LIGHTNESS_SHAPE

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Unmagic::Color

[], #dark?, #light?

Methods included from Harmony

#analogous, #complementary, #monochromatic, #scale, #shades, #split_complementary, #tetradic_rectangle, #tetradic_square, #tints, #tones, #triadic

Constructor Details

#initialize(hue:, saturation:, lightness:, alpha: nil) ⇒ HSL

Create a new HSL color.

Examples:

Create a pure red

HSL.new(hue: 0, saturation: 100, lightness: 50)

Create a semi-transparent blue

HSL.new(hue: 240, saturation: 40, lightness: 80, alpha: 50)

Create a pastel blue

HSL.new(hue: 240, saturation: 40, lightness: 80)

Parameters:

  • hue (Numeric)

    Hue in degrees (0-360), wraps around if outside range

  • saturation (Numeric)

    Saturation percentage (0-100), clamped to range

  • lightness (Numeric)

    Lightness percentage (0-100), clamped to range

  • alpha (Numeric, Color::Alpha, nil) (defaults to: nil)

    Alpha channel (0-100%), defaults to 100 (fully opaque)



95
96
97
98
99
100
101
# File 'lib/unmagic/color/hsl.rb', line 95

def initialize(hue:, saturation:, lightness:, alpha: nil)
  super()
  @hue = Color::Hue.new(value: hue)
  @saturation = Color::Saturation.new(value: saturation)
  @lightness = Color::Lightness.new(value: lightness)
  @alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT
end

Instance Attribute Details

#alphaObject (readonly)

Returns the value of attribute alpha.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def alpha
  @alpha
end

#hueObject (readonly)

Returns the value of attribute hue.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def hue
  @hue
end

#lightnessObject (readonly)

Returns the value of attribute lightness.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def lightness
  @lightness
end

#saturationObject (readonly)

Returns the value of attribute saturation.



78
79
80
# File 'lib/unmagic/color/hsl.rb', line 78

def saturation
  @saturation
end

Class Method Details

.build(*args, **kwargs) ⇒ HSL

Build an HSL color from a string, positional values, or keyword arguments.

Examples:

From string

HSL.build("hsl(120, 100%, 50%)")

From positional values

HSL.build(120, 100, 50)

From keyword arguments

HSL.build(hue: 120, saturation: 100, lightness: 50)

Parameters:

  • args (String, Numeric)

    Either a color string or 3 component values

  • kwargs (Hash)

    a customizable set of options

Options Hash (**kwargs):

  • :hue (Numeric)

    Hue in degrees (0-360)

  • :saturation (Numeric)

    Saturation percentage (0-100)

  • :lightness (Numeric)

    Lightness percentage (0-100)

Returns:

  • (HSL)

    The constructed HSL color



220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/unmagic/color/hsl.rb', line 220

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(hue: values[0], saturation: values[1], lightness: values[2])
  else
    raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}"
  end
end

.derive(seed, lightness: 50, saturation_range: (40..80)) ⇒ HSL

Generate a deterministic HSL color from an integer seed.

Creates visually distinct, consistent colors from hash values. Particularly useful because HSL naturally spreads colors evenly around the color wheel.

Examples:

Generate user avatar color

user_color = HSL.derive("alice@example.com".hash)

Generate lighter colors

HSL.derive(12345, lightness: 70)

Generate muted colors

HSL.derive(12345, saturation_range: (20..40))

Parameters:

  • seed (Integer)

    The seed value (typically from a hash function)

  • lightness (Numeric) (defaults to: 50)

    Fixed lightness percentage (0-100, default 50)

  • saturation_range (Range) (defaults to: (40..80))

    Range for saturation variation (default 40..80)

Returns:

  • (HSL)

    A deterministic color based on the seed

Raises:

  • (ArgumentError)

    If seed is not an integer



252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/unmagic/color/hsl.rb', line 252

def derive(seed, lightness: 50, saturation_range: (40..80))
  raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer)

  h32 = seed & 0xFFFFFFFF # Ensure 32-bit

  # Hue: distribute evenly across the color wheel
  h = (h32 % 360).to_f

  # Saturation: map a byte into the provided range
  s = saturation_range.begin + ((h32 >> 8) & 0xFF) / 255.0 * (saturation_range.end - saturation_range.begin)

  new(hue: h, saturation: s, lightness: lightness)
end

.parse(input) ⇒ HSL

Parse an HSL color from a string.

Accepts formats:

  • Legacy: “hsl(120, 100%, 50%)” or “hsla(120, 100%, 50%, 0.5)”

  • Modern: “hsl(120 100% 50% / 0.5)” or “hsl(120 100% 50% / 50%)”

  • Raw values: “120, 100%, 50%” or “120, 100, 50”

  • Percentages optional for saturation and lightness

Examples:

Parse CSS format

HSL.parse("hsl(120, 100%, 50%)")

Parse with alpha

HSL.parse("hsl(120 100% 50% / 0.5)")

Parse without function wrapper

HSL.parse("240, 50%, 75%")

Parameters:

  • input (String)

    The HSL color string to parse

Returns:

  • (HSL)

    The parsed HSL color

Raises:

  • (ParseError)

    If the input format is invalid or values are out of range



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/unmagic/color/hsl.rb', line 124

def parse(input)
  raise ParseError, "Input must be a string" unless input.is_a?(::String)

  # Remove hsl() or hsla() wrapper if present
  clean = input.gsub(/^hsla?\s*\(\s*|\s*\)$/, "").strip

  # Check for modern format with slash (space-separated with / for alpha)
  # Example: "120 100% 50% / 0.5"
  # Note: Modern format is only used WITH the hsl() wrapper
  alpha = nil
  has_slash = clean.include?("/")
  if has_slash
    parts = clean.split("/").map(&:strip)
    raise ParseError, "Invalid format with /: expected 'H S% L% / alpha'" unless parts.length == 2

    clean = parts[0]
    alpha = Color::Alpha.parse(parts[1])
  end

  # Split HSL values
  # - Comma-separated: legacy format (with or without hsl() wrapper)
  # - Space-separated: only valid WITH hsl() wrapper (modern format)
  parts = if clean.include?(",")
    # Legacy comma-separated format
    clean.split(/\s*,\s*/)
  elsif has_slash || input.match?(/^hsla?\s*\(/)
    # Modern space-separated format (only with hsl() wrapper or slash)
    clean.split(/\s+/)
  else
    # No commas and no hsl() wrapper - invalid
    raise ParseError, "Space-separated values require hsl() wrapper, use commas for raw values"
  end

  unless [3, 4].include?(parts.length)
    raise ParseError, "Expected 3 or 4 HSL values, got #{parts.length}"
  end

  # Parse alpha from 4th value if present (legacy format)
  if parts.length == 4 && alpha.nil?
    alpha = Color::Alpha.parse(parts[3])
  end

  # Check if hue is numeric
  h_str = parts[0].strip
  unless h_str.match?(/\A\d+(\.\d+)?\z/)
    raise ParseError, "Invalid hue value: #{h_str.inspect} (must be a number)"
  end

  # Check if saturation and lightness are numeric (with optional %)
  s_str = parts[1].gsub("%", "").strip
  l_str = parts[2].gsub("%", "").strip

  unless s_str.match?(/\A\d+(\.\d+)?\z/)
    raise ParseError, "Invalid saturation value: #{parts[1].inspect} (must be a number with optional %)"
  end

  unless l_str.match?(/\A\d+(\.\d+)?\z/)
    raise ParseError, "Invalid lightness value: #{parts[2].inspect} (must be a number with optional %)"
  end

  h = h_str.to_f
  s = s_str.to_f
  l = l_str.to_f

  # Validate ranges
  if h < 0 || h > 360
    raise ParseError, "Hue must be between 0 and 360, got #{h}"
  end

  if s < 0 || s > 100
    raise ParseError, "Saturation must be between 0 and 100, got #{s}"
  end

  if l < 0 || l > 100
    raise ParseError, "Lightness must be between 0 and 100, got #{l}"
  end

  new(hue: h, saturation: s, lightness: l, alpha: alpha)
end

Instance Method Details

#==(other) ⇒ Boolean

Check if two HSL colors are equal.

Parameters:

  • other (Object)

    The object to compare with

Returns:

  • (Boolean)

    true if both colors have the same HSL values



380
381
382
383
384
385
# File 'lib/unmagic/color/hsl.rb', line 380

def ==(other)
  other.is_a?(Unmagic::Color::HSL) &&
    lightness == other.lightness &&
    saturation == other.saturation &&
    hue == other.hue
end

#blend(other, amount = 0.5) ⇒ HSL

Blend this color with another color in HSL space.

Blending in HSL can produce different results than RGB blending, often creating more natural-looking color transitions.

Examples:

Create a color halfway between red and blue

red = HSL.new(hue: 0, saturation: 100, lightness: 50)
blue = HSL.new(hue: 240, saturation: 100, lightness: 50)
purple = red.blend(blue, 0.5)

Parameters:

  • other (Color)

    The color to blend with (automatically converted to HSL)

  • amount (Float) (defaults to: 0.5)

    How much of the other color to mix in (0.0-1.0)

Returns:

  • (HSL)

    A new HSL color that is a blend of the two



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/unmagic/color/hsl.rb', line 325

def blend(other, amount = 0.5)
  amount = amount.to_f.clamp(0, 1)
  other_hsl = other.respond_to?(:to_hsl) ? other.to_hsl : other

  # Blend in HSL space
  new_hue = @hue.value * (1 - amount) + other_hsl.hue.value * amount
  new_saturation = @saturation.value * (1 - amount) + other_hsl.saturation.value * amount
  new_lightness = @lightness.value * (1 - amount) + other_hsl.lightness.value * amount

  Unmagic::Color::HSL.new(
    hue: new_hue,
    saturation: new_saturation,
    lightness: new_lightness,
    alpha: @alpha.value * (1 - amount) + other_hsl.alpha.value * amount,
  )
end

#darken(amount = 0.1) ⇒ HSL

Create a darker version by decreasing lightness.

In HSL, darkening moves the color toward black while preserving the hue. The amount determines how much to reduce the current lightness toward 0%.

Examples:

Make a color 20% darker

bright = HSL.new(hue: 60, saturation: 100, lightness: 70)
subdued = bright.darken(0.2)

Parameters:

  • amount (Float) (defaults to: 0.1)

    How much to darken (0.0-1.0, default 0.1)

Returns:

  • (HSL)

    A darker version of this color



370
371
372
373
374
# File 'lib/unmagic/color/hsl.rb', line 370

def darken(amount = 0.1)
  amount = amount.to_f.clamp(0, 1)
  new_lightness = @lightness.value * (1 - amount)
  Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness, alpha: @alpha.value)
end

#lighten(amount = 0.1) ⇒ HSL

Create a lighter version by increasing lightness.

In HSL, lightening moves the color toward white while preserving the hue. The amount determines how far to move from the current lightness toward 100%.

Examples:

Make a color 30% lighter

dark = HSL.new(hue: 240, saturation: 80, lightness: 30)
light = dark.lighten(0.3)

Parameters:

  • amount (Float) (defaults to: 0.1)

    How much to lighten (0.0-1.0, default 0.1)

Returns:

  • (HSL)

    A lighter version of this color



353
354
355
356
357
# File 'lib/unmagic/color/hsl.rb', line 353

def lighten(amount = 0.1)
  amount = amount.to_f.clamp(0, 1)
  new_lightness = @lightness.value + (100 - @lightness.value) * amount
  Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness, alpha: @alpha.value)
end

#luminanceFloat

Calculate the relative luminance.

Converts to RGB first, then calculates luminance.

Returns:

  • (Float)

    Luminance from 0.0 (black) to 1.0 (white)



308
309
310
# File 'lib/unmagic/color/hsl.rb', line 308

def luminance
  to_rgb.luminance
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.

Examples:

hsl = HSL.new(hue: 9, saturation: 100, lightness: 60)
pp hsl
# Outputs: #<Unmagic::Color::HSL[█] @hue=9 @saturation=100 @lightness=60>
# (with colored █ block)

Parameters:

  • pp (PrettyPrint)

    The pretty printer instance



502
503
504
# File 'lib/unmagic/color/hsl.rb', line 502

def pretty_print(pp)
  pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @hue=#{@hue.value.round} @saturation=#{@saturation.value.round} @lightness=#{@lightness.value.round}>")
end

#progression(steps:, lightness:, saturation: nil) ⇒ Array<HSL>

Generate a progression of colors by varying lightness and saturation.

This creates an array of related colors, useful for color scales in UI design (like shades of blue from light to dark).

The lightness and saturation can be provided as:

  • Array: Specific values for each step (last value repeats if array is shorter)

  • Proc: Dynamic calculation based on the base color and step index

Examples:

Create a 5-step lightness progression

base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
base.progression(steps: 5, lightness: [20, 35, 50, 65, 80])

Dynamic lightness calculation

base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
base.progression(steps: 7, lightness: ->(hsl, i) { 20 + (i * 12) })

Vary both lightness and saturation

base = Unmagic::Color::HSL.new(hue: 240, saturation: 80, lightness: 50)
base.progression(steps: 5, lightness: [30, 45, 60, 75, 90], saturation: [100, 80, 60, 40, 20])

Parameters:

  • steps (Integer)

    Number of colors to generate (must be at least 1)

  • lightness (Array<Numeric>, Proc)

    Lightness values or calculation function

  • saturation (Array<Numeric>, Proc, nil) (defaults to: nil)

    Optional saturation values or function

Returns:

  • (Array<HSL>)

    Array of HSL colors in the progression

Raises:

  • (ArgumentError)

    If steps < 1 or lightness/saturation are invalid types



413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
# File 'lib/unmagic/color/hsl.rb', line 413

def progression(steps:, lightness:, saturation: nil)
  raise ArgumentError, "steps must be at least 1" if steps < 1
  raise ArgumentError, "lightness must be a proc or array" unless lightness.respond_to?(:call) || lightness.is_a?(Array)
  raise ArgumentError, "saturation must be a proc or array" if saturation && !saturation.respond_to?(:call) && !saturation.is_a?(Array)

  colors = []

  (0...steps).each do |i|
    # Calculate new lightness using the provided proc or array
    new_lightness = if lightness.is_a?(Array)
      # Use array value at index i, or last value if beyond array length
      lightness[i] || lightness.last
    else
      lightness.call(self, i)
    end
    new_lightness = new_lightness.to_f.clamp(0, 100)

    # Calculate new saturation using the provided proc/array or keep current
    new_saturation = if saturation
      if saturation.is_a?(Array)
        # Use array value at index i, or last value if beyond array length
        (saturation[i] || saturation.last).to_f.clamp(0, 100)
      else
        saturation.call(self, i).to_f.clamp(0, 100)
      end
    else
      @saturation.value
    end

    # Create new HSL color with computed values
    color = self.class.build(hue: @hue.value, saturation: new_saturation, lightness: new_lightness, alpha: @alpha.value)
    colors << color
  end

  colors
end

#to_ansi(layer: :foreground, mode: :truecolor) ⇒ String

Convert to ANSI SGR color code.

Converts to RGB first, then generates the ANSI code.

Examples:

color = HSL.new(hue: 0, saturation: 100, lightness: 50)
color.to_ansi
# => "31"

Parameters:

  • layer (Symbol) (defaults to: :foreground)

    Whether to generate foreground (:foreground) or background (:background) code

  • mode (Symbol) (defaults to: :truecolor)

    Color format mode (:truecolor, :palette256, :palette16)

Returns:

  • (String)

    ANSI SGR code like “31” or “38;2;255;0;0”



486
487
488
# File 'lib/unmagic/color/hsl.rb', line 486

def to_ansi(layer: :foreground, mode: :truecolor)
  to_rgb.to_ansi(layer: layer, mode: mode)
end

#to_hexString

Convert to hex string.

Converts via RGB as an intermediate step.

Returns:

  • (String)

    The color as a hex string (e.g., “#ff5733”)



299
300
301
# File 'lib/unmagic/color/hsl.rb', line 299

def to_hex
  to_rgb.to_hex
end

#to_hslHSL

Convert to HSL color space.

Since this is already an HSL color, returns self.

Returns:

  • (HSL)

    self



272
273
274
# File 'lib/unmagic/color/hsl.rb', line 272

def to_hsl
  self
end

#to_oklchOKLCH

Convert to OKLCH color space.

Converts via RGB as an intermediate step.

Returns:

  • (OKLCH)

    The color in OKLCH color space



290
291
292
# File 'lib/unmagic/color/hsl.rb', line 290

def to_oklch
  to_rgb.to_oklch
end

#to_rgbRGB

Convert to RGB color space.

Returns:

  • (RGB)

    The color in RGB color space



279
280
281
282
283
# File 'lib/unmagic/color/hsl.rb', line 279

def to_rgb
  rgb = hsl_to_rgb
  require_relative "rgb"
  Unmagic::Color::RGB.new(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: @alpha)
end

#to_sString

Convert to string representation.

Returns the CSS hsl() function format. If alpha is less than 100%, includes alpha value using modern CSS syntax with / separator.

Examples:

Fully opaque

color = HSL.new(hue: 240, saturation: 80, lightness: 50)
color.to_s
# => "hsl(240, 80.0%, 50.0%)"

Semi-transparent

color = HSL.new(hue: 240, saturation: 80, lightness: 50, alpha: 50)
color.to_s
# => "hsl(240, 80.0%, 50.0% / 0.5)"

Returns:

  • (String)

    HSL string like “hsl(240, 80%, 50%)” or “hsl(240, 80%, 50% / 0.5)”



466
467
468
469
470
471
472
# File 'lib/unmagic/color/hsl.rb', line 466

def to_s
  if @alpha.value < 100
    "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}% / #{@alpha.to_css})"
  else
    "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)"
  end
end