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)
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
-
#analogous(angle: 30) ⇒ Array<RGB, HSL, OKLCH>
Returns two analogous colors (adjacent on the color wheel).
-
#complementary ⇒ RGB, ...
Returns the complementary color (180° opposite on the color wheel).
-
#monochromatic(steps: 5) ⇒ Array<RGB, HSL, OKLCH>
Returns an array of colors with varying lightness (same hue).
-
#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.
-
#shades(steps: 5, amount: 0.5) ⇒ Array<RGB, HSL, OKLCH>
Returns an array of progressively darker colors (shades).
-
#split_complementary(angle: 30) ⇒ Array<RGB, HSL, OKLCH>
Returns two split-complementary colors.
-
#tetradic_rectangle(angle: 60) ⇒ Array<RGB, HSL, OKLCH>
Returns three tetradic colors forming a rectangle on the color wheel.
-
#tetradic_square ⇒ Array<RGB, HSL, OKLCH>
Returns three tetradic colors forming a square on the color wheel.
-
#tints(steps: 5, amount: 0.5) ⇒ Array<RGB, HSL, OKLCH>
Returns an array of progressively lighter colors (tints).
-
#tones(steps: 5, amount: 0.5) ⇒ Array<RGB, HSL, OKLCH>
Returns an array of progressively desaturated colors (tones).
-
#triadic ⇒ Array<RGB, HSL, OKLCH>
Returns two triadic colors (evenly spaced 120° on the color wheel).
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.
93 94 95 |
# File 'lib/unmagic/color/harmony.rb', line 93 def analogous(angle: 30) [rotate_hue(-angle), rotate_hue(angle)] end |
#complementary ⇒ RGB, ...
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.
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.
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.
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.
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.
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.
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_square ⇒ Array<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.
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.
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.
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 |
#triadic ⇒ Array<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.
108 109 110 |
# File 'lib/unmagic/color/harmony.rb', line 108 def triadic [rotate_hue(120), rotate_hue(240)] end |