Module: Unmagic::Color::Harmony

Included in:
Unmagic::Color
Defined in:
lib/unmagic/color/harmony.rb

Overview

Color harmony and variations module.

Provides methods for generating harmonious color palettes based on color theory principles. Harmony and the #shades/#tints/#tones variations are computed in HSL color space for accurate hue-based relationships; #scale is computed in OKLCH for perceptual uniformity.

Included in the base Color class, making these methods available to RGB, HSL, and OKLCH color spaces via inheritance.

## Color Harmonies

Color harmonies are combinations of colors that are aesthetically pleasing based on their positions on the color wheel:

  • Complementary: Colors opposite on the wheel (180° apart)

  • Analogous: Colors adjacent on the wheel (typically 30° apart)

  • Triadic: Three colors evenly spaced (120° apart)

  • Split-complementary: Base color plus two colors adjacent to its complement

  • Tetradic: Four colors forming a rectangle or square on the wheel

## Color Variations

Create related colors by adjusting lightness or saturation:

  • Shades: Darker versions (reducing lightness)

  • Tints: Lighter versions (increasing lightness)

  • Tones: Less saturated versions (reducing saturation)

Examples:

Basic harmony usage

red = Unmagic::Color.parse("#FF0000")
red.complementary          # => #<RGB #00ffff>
red.triadic                # => [#<RGB ...>, #<RGB ...>]

Color variations

blue = Unmagic::Color.parse("#0000FF")
blue.shades(steps: 3)      # => [darker1, darker2, darker3]
blue.tints(steps: 3)       # => [lighter1, lighter2, lighter3]

Constant Summary collapse

SCALE_LIGHTNESS_SHAPE =

Default lightness curve for #scale: 11 control points describing the shape of the light→dark sweep (1.0 = lightest, 0.0 = darkest), sampled with linear interpolation. Derived from the average of several hand-tuned production color ramps.

[
  1.0, 0.948, 0.876, 0.769, 0.622, 0.514, 0.416, 0.323, 0.234, 0.168, 0.0,
].freeze
SCALE_CHROMA_CURVE =

Default chroma curve for #scale: 11 control points (peak normalized to 1.0). Chroma rises into the mid-tones and tapers toward both ends —a constant chroma reads as muddy near white and neon near black, and the sRGB gamut itself narrows at the extremes.

[
  0.055, 0.131, 0.247, 0.447, 0.727, 0.920, 1.0, 0.931, 0.767, 0.586, 0.374,
].freeze
SCALE_LIGHTNESS_DEFAULT =

Default lightness endpoints ‘[lightest, darkest]` for #scale.

[0.971, 0.270].freeze

Instance Method Summary collapse

Instance Method Details

#analogous(angle: 30) ⇒ Array<RGB, HSL, OKLCH>

Returns two analogous colors (adjacent on the color wheel).

Analogous colors create harmonious, cohesive designs. They’re often found in nature and produce a calm, comfortable feel.

Examples:

Default 30° separation

red = Unmagic::Color.parse("#FF0000")
red.analogous
# => [#<RGB ...>, #<RGB ...>] (red-violet, red-orange)

Custom 15° separation

red.analogous(angle: 15)

Parameters:

  • angle (Numeric) (defaults to: 30)

    Degrees of separation from the base color (default: 30)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Two colors [-angle, +angle] from the base



93
94
95
# File 'lib/unmagic/color/harmony.rb', line 93

def analogous(angle: 30)
  [rotate_hue(-angle), rotate_hue(angle)]
end

#complementaryRGB, ...

Returns the complementary color (180° opposite on the color wheel).

Complementary colors create high contrast and visual tension. They’re effective for creating emphasis and drawing attention.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.complementary
# => #<RGB #00ffff> (cyan)

Returns:

  • (RGB, HSL, OKLCH)

    The complementary color (same type as self)



74
75
76
# File 'lib/unmagic/color/harmony.rb', line 74

def complementary
  rotate_hue(180)
end

#monochromatic(steps: 5) ⇒ Array<RGB, HSL, OKLCH>

Returns an array of colors with varying lightness (same hue).

Creates a monochromatic palette by generating colors across a lightness range while preserving hue and saturation.

Examples:

blue = Unmagic::Color.parse("#0000FF")
blue.monochromatic(steps: 5)
# => [very dark blue, dark blue, medium blue, light blue, very light blue]

Parameters:

  • steps (Integer) (defaults to: 5)

    Number of colors to generate (default: 5)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Colors with lightness from 15% to 85%

Raises:

  • (ArgumentError)


170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/unmagic/color/harmony.rb', line 170

def monochromatic(steps: 5)
  raise ArgumentError, "steps must be at least 1" if steps < 1

  hsl = to_hsl
  min_lightness = 15.0
  max_lightness = 85.0
  step_size = (max_lightness - min_lightness) / (steps - 1).to_f

  (0...steps).map do |i|
    lightness = min_lightness + (i * step_size)
    result = HSL.new(
      hue: hsl.hue.value,
      saturation: hsl.saturation.value,
      lightness: lightness,
      alpha: hsl.alpha.value,
    )
    convert_harmony_result(result)
  end
end

#scale(steps: 11, lightness: nil, chroma: :peak, hue_shift: nil, anchor: nil, gamut: :srgb) ⇒ Array<OKLCH>

Generate a perceptually-uniform tonal scale from this color.

Produces an ordered sequence of colors from light to dark, computed in the OKLCH color space so each step sits an even perceptual distance from the last. Unlike #shades/#tints (which blend toward black or white in HSL), ‘scale` controls lightness, chroma, and hue independently and gamut-maps every result.

The chroma curve is the important part: chroma rises into the mid-tones and tapers toward both ends, because a constant chroma reads as muddy near white and as neon near black, and because the sRGB gamut itself narrows at the extremes.

This is a general-purpose primitive. An 11-step scale anchored in the middle happens to produce a Tailwind-style 50–950 palette, but the method itself knows nothing about Tailwind.

Examples:

An 11-step palette anchored on the base color

base = Unmagic::Color.parse("oklch(0.62 0.21 260)")
palette = base.scale(steps: 11, anchor: 5)

Label an 11-step scale as Tailwind stops

stops = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
tailwind = stops.zip(base.scale(steps: 11, anchor: 5)).to_h

Parameters:

  • steps (Integer) (defaults to: 11)

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

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

    OKLCH lightness control. A ‘Range` gives the light/dark endpoints; an `Array` gives an explicit value per step; a `Proc` is called with `(t, index)`; `nil` uses the default curve.

  • chroma (Symbol, Array<Numeric>, Proc) (defaults to: :peak)

    OKLCH chroma control. ‘:peak` (default) applies the tapered curve scaled to this color’s chroma; ‘:flat` holds chroma constant; an `Array` or `Proc` supplies values directly.

  • hue_shift (Range, Numeric, Proc, nil) (defaults to: nil)

    Hue drift in degrees across the scale. ‘nil` (default) keeps the hue constant.

  • anchor (Integer, nil) (defaults to: nil)

    Index at which this color is placed exactly — its lightness, chroma, and hue are preserved at that step and the rest of the scale is built around it.

  • gamut (Symbol) (defaults to: :srgb)

    ‘:srgb` (default) gamut-maps every result into sRGB so RGB#to_hex is trustworthy; `:none` returns the raw OKLCH colors, which may be wider than sRGB.

Returns:

  • (Array<OKLCH>)

    ‘steps` colors in OKLCH, ordered light to dark

Raises:

  • (ArgumentError)

    If steps < 2, anchor is out of range, or gamut is not :srgb or :none



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/unmagic/color/harmony.rb', line 328

def scale(steps: 11, lightness: nil, chroma: :peak, hue_shift: nil, anchor: nil, gamut: :srgb)
  raise ArgumentError, "steps must be at least 2" if steps < 2
  if anchor && !(0...steps).cover?(anchor)
    raise ArgumentError, "anchor must be between 0 and #{steps - 1}"
  end
  unless [:srgb, :none].include?(gamut)
    raise ArgumentError, "gamut must be :srgb or :none"
  end

  base = to_oklch
  positions = Array.new(steps) { |i| i.fdiv(steps - 1) }

  lightnesses = scale_lightness(positions, lightness, anchor, base.lightness)
  chromas = scale_chroma(positions, chroma, anchor, base.chroma.value)
  hues = scale_hue(positions, hue_shift, base.hue.value)

  Array.new(steps) do |i|
    color = OKLCH.new(
      lightness: lightnesses[i].clamp(0.0, 1.0),
      chroma: [chromas[i], 0.0].max,
      hue: hues[i],
      alpha: base.alpha.value,
    )
    gamut == :none ? color : color.clamp_to_gamut
  end
end

#shades(steps: 5, amount: 0.5) ⇒ Array<RGB, HSL, OKLCH>

Returns an array of progressively darker colors (shades).

Shades are created by reducing lightness, simulating the effect of adding black to the original color.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.shades(steps: 3)
# => [slightly darker red, darker red, darkest red]

Parameters:

  • steps (Integer) (defaults to: 5)

    Number of shades to generate (default: 5)

  • amount (Float) (defaults to: 0.5)

    Total amount of darkening 0.0-1.0 (default: 0.5)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Progressively darker colors

Raises:

  • (ArgumentError)


203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/unmagic/color/harmony.rb', line 203

def shades(steps: 5, amount: 0.5)
  raise ArgumentError, "steps must be at least 1" if steps < 1

  hsl = to_hsl
  step_amount = amount / steps.to_f

  (1..steps).map do |i|
    new_lightness = hsl.lightness.value * (1 - (step_amount * i))
    result = HSL.new(
      hue: hsl.hue.value,
      saturation: hsl.saturation.value,
      lightness: new_lightness.clamp(0, 100),
      alpha: hsl.alpha.value,
    )
    convert_harmony_result(result)
  end
end

#split_complementary(angle: 30) ⇒ Array<RGB, HSL, OKLCH>

Returns two split-complementary colors.

Split-complementary uses the two colors adjacent to the complement, providing high contrast with less tension than pure complementary.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.split_complementary
# => [#<RGB ...>, #<RGB ...>] (cyan-blue, cyan-green)

Parameters:

  • angle (Numeric) (defaults to: 30)

    Degrees from the complement (default: 30)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Two colors at (180-angle)° and (180+angle)°



124
125
126
# File 'lib/unmagic/color/harmony.rb', line 124

def split_complementary(angle: 30)
  [rotate_hue(180 - angle), rotate_hue(180 + angle)]
end

#tetradic_rectangle(angle: 60) ⇒ Array<RGB, HSL, OKLCH>

Returns three tetradic colors forming a rectangle on the color wheel.

Rectangular tetradic uses two complementary pairs with configurable spacing. This provides flexibility between harmony and contrast.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.tetradic_rectangle(angle: 60)

Parameters:

  • angle (Numeric) (defaults to: 60)

    Degrees between first pair (default: 60)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Three colors at angle°, 180°, (180angle)°



154
155
156
# File 'lib/unmagic/color/harmony.rb', line 154

def tetradic_rectangle(angle: 60)
  [rotate_hue(angle), rotate_hue(180), rotate_hue(180 + angle)]
end

#tetradic_squareArray<RGB, HSL, OKLCH>

Returns three tetradic colors forming a square on the color wheel.

Square tetradic uses four colors evenly spaced (90° apart). This creates a rich, bold color scheme with equal visual weight.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.tetradic_square
# => [#<RGB ...>, #<RGB ...>, #<RGB ...>]

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Three colors at 90°, 180°, +270°



139
140
141
# File 'lib/unmagic/color/harmony.rb', line 139

def tetradic_square
  [rotate_hue(90), rotate_hue(180), rotate_hue(270)]
end

#tints(steps: 5, amount: 0.5) ⇒ Array<RGB, HSL, OKLCH>

Returns an array of progressively lighter colors (tints).

Tints are created by increasing lightness, simulating the effect of adding white to the original color.

Examples:

blue = Unmagic::Color.parse("#0000FF")
blue.tints(steps: 3)
# => [slightly lighter blue, lighter blue, lightest blue]

Parameters:

  • steps (Integer) (defaults to: 5)

    Number of tints to generate (default: 5)

  • amount (Float) (defaults to: 0.5)

    Total amount of lightening 0.0-1.0 (default: 0.5)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Progressively lighter colors

Raises:

  • (ArgumentError)


234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/unmagic/color/harmony.rb', line 234

def tints(steps: 5, amount: 0.5)
  raise ArgumentError, "steps must be at least 1" if steps < 1

  hsl = to_hsl
  step_amount = amount / steps.to_f

  (1..steps).map do |i|
    new_lightness = hsl.lightness.value + (100 - hsl.lightness.value) * (step_amount * i)
    result = HSL.new(
      hue: hsl.hue.value,
      saturation: hsl.saturation.value,
      lightness: new_lightness.clamp(0, 100),
      alpha: hsl.alpha.value,
    )
    convert_harmony_result(result)
  end
end

#tones(steps: 5, amount: 0.5) ⇒ Array<RGB, HSL, OKLCH>

Returns an array of progressively desaturated colors (tones).

Tones are created by reducing saturation, simulating the effect of adding gray to the original color.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.tones(steps: 3)
# => [slightly muted red, more muted red, grayish red]

Parameters:

  • steps (Integer) (defaults to: 5)

    Number of tones to generate (default: 5)

  • amount (Float) (defaults to: 0.5)

    Total amount of desaturation 0.0-1.0 (default: 0.5)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Progressively less saturated colors

Raises:

  • (ArgumentError)


265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/unmagic/color/harmony.rb', line 265

def tones(steps: 5, amount: 0.5)
  raise ArgumentError, "steps must be at least 1" if steps < 1

  hsl = to_hsl
  step_amount = amount / steps.to_f

  (1..steps).map do |i|
    new_saturation = hsl.saturation.value * (1 - (step_amount * i))
    result = HSL.new(
      hue: hsl.hue.value,
      saturation: new_saturation.clamp(0, 100),
      lightness: hsl.lightness.value,
      alpha: hsl.alpha.value,
    )
    convert_harmony_result(result)
  end
end

#triadicArray<RGB, HSL, OKLCH>

Returns two triadic colors (evenly spaced 120° on the color wheel).

Triadic colors offer strong visual contrast while retaining harmony. They tend to be vibrant even when using pale or unsaturated versions.

Examples:

red = Unmagic::Color.parse("#FF0000")
red.triadic
# => [#<RGB ...>, #<RGB ...>] (green-ish, blue-ish)

Returns:

  • (Array<RGB, HSL, OKLCH>)

    Two colors at 120° and 240°



108
109
110
# File 'lib/unmagic/color/harmony.rb', line 108

def triadic
  [rotate_hue(120), rotate_hue(240)]
end