unmagic-color 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ module Gradient
6
+ # A 2D grid of color pixels representing a rasterized gradient.
7
+ #
8
+ # Bitmap represents the output of gradient rasterization as a grid of colors.
9
+ # For linear gradients, this is a single row (height=1). The 2D structure
10
+ # allows for future gradient types that need multiple rows.
11
+ #
12
+ # ## Pixel Storage
13
+ #
14
+ # Pixels are stored as a 2D array: `pixels[y][x] = color`
15
+ #
16
+ # - Linear gradients: `pixels = [[color1, color2, ...]]` (single row)
17
+ # - Multi-row gradients: `pixels = [[row1...], [row2...], ...]`
18
+ #
19
+ # ## Examples
20
+ #
21
+ # # Create a 1D bitmap (from linear gradient)
22
+ # bitmap = Unmagic::Color::Gradient::Bitmap.new(
23
+ # width: 5,
24
+ # height: 1,
25
+ # pixels: [[red, orange, yellow, green, blue]]
26
+ # )
27
+ #
28
+ # # Access pixels
29
+ # bitmap.at(0, 0) # => red (first pixel)
30
+ # bitmap.at(4, 0) # => blue (last pixel)
31
+ # bitmap[] # => red (shortcut for first pixel)
32
+ #
33
+ # # Convert to flat array
34
+ # bitmap.to_a # => [red, orange, yellow, green, blue]
35
+ class Bitmap
36
+ attr_reader :width, :height, :pixels
37
+
38
+ # Create a new bitmap.
39
+ #
40
+ # @param width [Integer] Number of pixels horizontally
41
+ # @param height [Integer] Number of pixels vertically
42
+ # @param pixels [Array<Array<Color>>] 2D array of colors (pixels[y][x])
43
+ def initialize(width:, height:, pixels:)
44
+ @width = width
45
+ @height = height
46
+ @pixels = pixels
47
+ end
48
+
49
+ # Access a pixel at the given coordinates.
50
+ #
51
+ # @param x [Integer] Horizontal position (0 to width-1)
52
+ # @param y [Integer] Vertical position (0 to height-1), defaults to 0
53
+ # @return [Color] The color at the specified position
54
+ def at(x, y = 0)
55
+ @pixels[y][x]
56
+ end
57
+
58
+ # Shortcut to access a pixel.
59
+ #
60
+ # When called without arguments, returns the first pixel (0, 0).
61
+ # When called with arguments, delegates to `at`.
62
+ #
63
+ # @param args [Array] Optional x and y coordinates
64
+ # @return [Color] The color at the specified position
65
+ #
66
+ # @example Get first pixel
67
+ # bitmap[] # => color at (0, 0)
68
+ #
69
+ # @example Get specific pixel
70
+ # bitmap[5, 0] # => color at (5, 0)
71
+ def [](*args)
72
+ if args.empty?
73
+ at(0, 0)
74
+ else
75
+ at(*args)
76
+ end
77
+ end
78
+
79
+ # Convert to a flat 1D array of colors.
80
+ #
81
+ # Flattens the 2D pixel grid into a single array, reading left-to-right,
82
+ # top-to-bottom.
83
+ #
84
+ # @return [Array<Color>] Flattened array of all colors
85
+ def to_a
86
+ @pixels.flatten
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ module Gradient
6
+ # A color stop at a specific position in a gradient.
7
+ #
8
+ # Represents a single color at a position along a gradient. Positions are
9
+ # normalized from 0.0 (start) to 1.0 (end). Multiple stops define the
10
+ # color transitions in a gradient.
11
+ #
12
+ # ## Usage
13
+ #
14
+ # Stop objects are typically created automatically by gradient classes
15
+ # using the `.build()` method, but can be created directly for more control.
16
+ #
17
+ # ## Examples
18
+ #
19
+ # # Create a stop directly
20
+ # stop = Unmagic::Color::Gradient::Stop.new(
21
+ # color: Unmagic::Color::RGB.parse("#FF0000"),
22
+ # position: 0.5
23
+ # )
24
+ #
25
+ # # Access stop properties
26
+ # stop.color # => #<RGB...>
27
+ # stop.position # => 0.5
28
+ class Stop
29
+ attr_reader :color, :position
30
+
31
+ # Create a new color stop.
32
+ #
33
+ # @param color [Color] The color at this stop (must be a Color instance)
34
+ # @param position [Numeric] Position along gradient (0.0-1.0)
35
+ #
36
+ # @raise [ArgumentError] If color is not a Color instance
37
+ # @raise [ArgumentError] If position is not between 0.0 and 1.0
38
+ def initialize(color:, position:)
39
+ raise ArgumentError, "color must be a Color instance" unless color.is_a?(Unmagic::Color)
40
+ raise ArgumentError, "position must be between 0.0 and 1.0" if position < 0.0 || position > 1.0
41
+
42
+ @color = color
43
+ @position = position.to_f
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "units/direction"
4
+
5
+ module Unmagic
6
+ class Color
7
+ # Gradient generation for smooth color transitions.
8
+ #
9
+ # Provides classes for creating linear color gradients in RGB, HSL, or
10
+ # OKLCH color spaces.
11
+ #
12
+ # ## Core Classes
13
+ #
14
+ # - {Stop} - Represents a color at a specific position
15
+ # - {Bitmap} - A 2D grid of colors (output from rasterization)
16
+ # - {Base} - Base class for gradient implementations
17
+ #
18
+ # ## Gradient Types
19
+ #
20
+ # - `RGB::Gradient::Linear` - Linear gradient in RGB space
21
+ # - `HSL::Gradient::Linear` - Linear gradient in HSL space
22
+ # - `OKLCH::Gradient::Linear` - Linear gradient in OKLCH space
23
+ #
24
+ # @example Auto-detect color space, auto-balance positions
25
+ # gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#0000FF"])
26
+ # bitmap = gradient.rasterize(width: 10)
27
+ #
28
+ # @example Use explicit positions
29
+ # gradient = Unmagic::Color::Gradient.linear([["#FF0000", 0.0], ["#0000FF", 1.0]])
30
+ # bitmap = gradient.rasterize(width: 10)
31
+ #
32
+ # @example Works with HSL and OKLCH too
33
+ # gradient = Unmagic::Color::Gradient.linear(["hsl(0, 100%, 50%)", "hsl(240, 100%, 50%)"])
34
+ # bitmap = gradient.rasterize(width: 10)
35
+ #
36
+ # @example Three colors are evenly spaced (0.0, 0.5, 1.0)
37
+ # gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#00FF00", "#0000FF"])
38
+ #
39
+ # @example Rasterize at different resolutions
40
+ # colors = gradient.rasterize(width: 5).pixels[0] # 5 colors
41
+ # colors = gradient.rasterize(width: 100).pixels[0] # 100 colors
42
+ #
43
+ # @example Angled gradients
44
+ # gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#0000FF"], direction: "45deg")
45
+ # bitmap = gradient.rasterize(width: 10, height: 10)
46
+ #
47
+ # @example Direction keywords
48
+ # gradient = Unmagic::Color::Gradient.linear(["#FF0000", "#0000FF"], direction: "to right")
49
+ # bitmap = gradient.rasterize(width: 10, height: 1)
50
+ module Gradient
51
+ # Base error class for gradient-related errors.
52
+ class Error < Color::Error; end
53
+
54
+ class << self
55
+ # Create a linear gradient, auto-detecting color space from input.
56
+ #
57
+ # Examines the first color to determine which color space to use (RGB, HSL, or OKLCH),
58
+ # then delegates to the appropriate Linear gradient class.
59
+ #
60
+ # Works like CSS linear-gradient - you can mix positioned and non-positioned colors.
61
+ # Non-positioned colors auto-balance between their surrounding positioned neighbors.
62
+ #
63
+ # Color space detection:
64
+ # - Strings starting with "hsl(" use HSL color space
65
+ # - Strings starting with "oklch(" use OKLCH color space
66
+ # - All other strings (hex, rgb()) use RGB color space
67
+ # - Color objects use their class directly
68
+ #
69
+ # @param colors [Array] Colors or [color, position] pairs
70
+ # @param direction [String, Numeric, Degrees, Direction, nil] Optional gradient direction
71
+ # - Direction strings: "to top", "from left to right", "45deg", "90°"
72
+ # - Numeric degrees: 45, 90, 180
73
+ # - Degrees/Direction instances
74
+ # - Defaults to "to bottom" (180°) if omitted
75
+ # @return [Linear] A gradient instance in the detected color space
76
+ #
77
+ # @example Simple gradient
78
+ # Gradient.linear(["#FF0000", "#0000FF"])
79
+ #
80
+ # @example With direction keyword
81
+ # Gradient.linear(["#FF0000", "#0000FF"], direction: "to right")
82
+ # Gradient.linear(["blue", "red"], direction: "from left to right")
83
+ #
84
+ # @example With numeric direction
85
+ # Gradient.linear(["#FF0000", "#0000FF"], direction: 45)
86
+ # Gradient.linear(["#FF0000", "#0000FF"], direction: "90°")
87
+ #
88
+ # @example Mixed positions (like CSS linear-gradient)
89
+ # Gradient.linear(["#FF0000", ["#FFFF00", 0.3], "#00FF00", ["#0000FF", 0.9], "#FF00FF"])
90
+ def linear(colors, direction: nil)
91
+ # Convert direction to Direction instance
92
+ direction_instance = if direction.nil?
93
+ # Default to "to bottom" (CSS default)
94
+ Unmagic::Color::Units::Degrees::Direction::TOP_TO_BOTTOM
95
+ elsif direction.is_a?(Unmagic::Color::Units::Degrees::Direction)
96
+ direction
97
+ elsif direction.is_a?(Unmagic::Color::Units::Degrees)
98
+ # Degrees instance - convert to Direction (from opposite to this degree)
99
+ Unmagic::Color::Units::Degrees::Direction.new(from: direction.opposite, to: direction)
100
+ elsif direction.is_a?(::Numeric)
101
+ # Numeric - convert to Degrees, then to Direction
102
+ degrees = Unmagic::Color::Units::Degrees.new(value: direction)
103
+ Unmagic::Color::Units::Degrees::Direction.new(from: degrees.opposite, to: degrees)
104
+ elsif direction.is_a?(::String)
105
+ # String - parse as Direction or Degrees
106
+ if Unmagic::Color::Units::Degrees::Direction.matches?(direction)
107
+ Unmagic::Color::Units::Degrees::Direction.parse(direction)
108
+ else
109
+ degrees = Unmagic::Color::Units::Degrees.parse(direction)
110
+ Unmagic::Color::Units::Degrees::Direction.new(from: degrees.opposite, to: degrees)
111
+ end
112
+ else
113
+ raise Error, "Invalid direction type: #{direction.class}"
114
+ end
115
+
116
+ raise Error, "colors must have at least one color" if colors.empty?
117
+
118
+ # Extract first color for color space detection (handle both positioned and non-positioned)
119
+ first = colors.first
120
+ first_color_or_string = first.is_a?(::Array) ? first.first : first
121
+
122
+ # Determine color class from first color
123
+ color_class = if first_color_or_string.is_a?(Unmagic::Color)
124
+ first_color_or_string.class
125
+ elsif first_color_or_string.is_a?(::String)
126
+ if first_color_or_string.match?(/^\s*hsl\s*\(/)
127
+ Unmagic::Color::HSL
128
+ elsif first_color_or_string.match?(/^\s*oklch\s*\(/)
129
+ Unmagic::Color::OKLCH
130
+ else
131
+ Unmagic::Color::RGB
132
+ end
133
+ else
134
+ raise Error, "First color must be a Color instance or String"
135
+ end
136
+
137
+ # Delegate to appropriate Linear class
138
+ gradient_class = case color_class.name
139
+ when "Unmagic::Color::RGB"
140
+ Unmagic::Color::RGB::Gradient::Linear
141
+ when "Unmagic::Color::HSL"
142
+ Unmagic::Color::HSL::Gradient::Linear
143
+ when "Unmagic::Color::OKLCH"
144
+ Unmagic::Color::OKLCH::Gradient::Linear
145
+ else
146
+ raise Error, "Unsupported color class: #{color_class}"
147
+ end
148
+
149
+ gradient_class.build(colors, direction: direction_instance)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ class Color
5
+ # Color harmony and variations module.
6
+ #
7
+ # Provides methods for generating harmonious color palettes based on
8
+ # color theory principles. All calculations are performed in HSL color space
9
+ # for accurate hue-based relationships.
10
+ #
11
+ # Included in the base Color class, making these methods available to
12
+ # RGB, HSL, and OKLCH color spaces via inheritance.
13
+ #
14
+ # ## Color Harmonies
15
+ #
16
+ # Color harmonies are combinations of colors that are aesthetically pleasing
17
+ # based on their positions on the color wheel:
18
+ #
19
+ # - **Complementary**: Colors opposite on the wheel (180° apart)
20
+ # - **Analogous**: Colors adjacent on the wheel (typically 30° apart)
21
+ # - **Triadic**: Three colors evenly spaced (120° apart)
22
+ # - **Split-complementary**: Base color plus two colors adjacent to its complement
23
+ # - **Tetradic**: Four colors forming a rectangle or square on the wheel
24
+ #
25
+ # ## Color Variations
26
+ #
27
+ # Create related colors by adjusting lightness or saturation:
28
+ #
29
+ # - **Shades**: Darker versions (reducing lightness)
30
+ # - **Tints**: Lighter versions (increasing lightness)
31
+ # - **Tones**: Less saturated versions (reducing saturation)
32
+ #
33
+ # @example Basic harmony usage
34
+ # red = Unmagic::Color.parse("#FF0000")
35
+ # red.complementary # => #<RGB #00ffff>
36
+ # red.triadic # => [#<RGB ...>, #<RGB ...>]
37
+ #
38
+ # @example Color variations
39
+ # blue = Unmagic::Color.parse("#0000FF")
40
+ # blue.shades(steps: 3) # => [darker1, darker2, darker3]
41
+ # blue.tints(steps: 3) # => [lighter1, lighter2, lighter3]
42
+ module Harmony
43
+ # Returns the complementary color (180° opposite on the color wheel).
44
+ #
45
+ # Complementary colors create high contrast and visual tension.
46
+ # They're effective for creating emphasis and drawing attention.
47
+ #
48
+ # @return [RGB, HSL, OKLCH] The complementary color (same type as self)
49
+ #
50
+ # @example
51
+ # red = Unmagic::Color.parse("#FF0000")
52
+ # red.complementary
53
+ # # => #<RGB #00ffff> (cyan)
54
+ def complementary
55
+ rotate_hue(180)
56
+ end
57
+
58
+ # Returns two analogous colors (adjacent on the color wheel).
59
+ #
60
+ # Analogous colors create harmonious, cohesive designs. They're often
61
+ # found in nature and produce a calm, comfortable feel.
62
+ #
63
+ # @param angle [Numeric] Degrees of separation from the base color (default: 30)
64
+ # @return [Array<RGB, HSL, OKLCH>] Two colors [-angle, +angle] from the base
65
+ #
66
+ # @example Default 30° separation
67
+ # red = Unmagic::Color.parse("#FF0000")
68
+ # red.analogous
69
+ # # => [#<RGB ...>, #<RGB ...>] (red-violet, red-orange)
70
+ #
71
+ # @example Custom 15° separation
72
+ # red.analogous(angle: 15)
73
+ def analogous(angle: 30)
74
+ [rotate_hue(-angle), rotate_hue(angle)]
75
+ end
76
+
77
+ # Returns two triadic colors (evenly spaced 120° on the color wheel).
78
+ #
79
+ # Triadic colors offer strong visual contrast while retaining harmony.
80
+ # They tend to be vibrant even when using pale or unsaturated versions.
81
+ #
82
+ # @return [Array<RGB, HSL, OKLCH>] Two colors at +120° and +240°
83
+ #
84
+ # @example
85
+ # red = Unmagic::Color.parse("#FF0000")
86
+ # red.triadic
87
+ # # => [#<RGB ...>, #<RGB ...>] (green-ish, blue-ish)
88
+ def triadic
89
+ [rotate_hue(120), rotate_hue(240)]
90
+ end
91
+
92
+ # Returns two split-complementary colors.
93
+ #
94
+ # Split-complementary uses the two colors adjacent to the complement,
95
+ # providing high contrast with less tension than pure complementary.
96
+ #
97
+ # @param angle [Numeric] Degrees from the complement (default: 30)
98
+ # @return [Array<RGB, HSL, OKLCH>] Two colors at (180-angle)° and (180+angle)°
99
+ #
100
+ # @example
101
+ # red = Unmagic::Color.parse("#FF0000")
102
+ # red.split_complementary
103
+ # # => [#<RGB ...>, #<RGB ...>] (cyan-blue, cyan-green)
104
+ def split_complementary(angle: 30)
105
+ [rotate_hue(180 - angle), rotate_hue(180 + angle)]
106
+ end
107
+
108
+ # Returns three tetradic colors forming a square on the color wheel.
109
+ #
110
+ # Square tetradic uses four colors evenly spaced (90° apart).
111
+ # This creates a rich, bold color scheme with equal visual weight.
112
+ #
113
+ # @return [Array<RGB, HSL, OKLCH>] Three colors at +90°, +180°, +270°
114
+ #
115
+ # @example
116
+ # red = Unmagic::Color.parse("#FF0000")
117
+ # red.tetradic_square
118
+ # # => [#<RGB ...>, #<RGB ...>, #<RGB ...>]
119
+ def tetradic_square
120
+ [rotate_hue(90), rotate_hue(180), rotate_hue(270)]
121
+ end
122
+
123
+ # Returns three tetradic colors forming a rectangle on the color wheel.
124
+ #
125
+ # Rectangular tetradic uses two complementary pairs with configurable
126
+ # spacing. This provides flexibility between harmony and contrast.
127
+ #
128
+ # @param angle [Numeric] Degrees between first pair (default: 60)
129
+ # @return [Array<RGB, HSL, OKLCH>] Three colors at +angle°, +180°, +(180+angle)°
130
+ #
131
+ # @example
132
+ # red = Unmagic::Color.parse("#FF0000")
133
+ # red.tetradic_rectangle(angle: 60)
134
+ def tetradic_rectangle(angle: 60)
135
+ [rotate_hue(angle), rotate_hue(180), rotate_hue(180 + angle)]
136
+ end
137
+
138
+ # Returns an array of colors with varying lightness (same hue).
139
+ #
140
+ # Creates a monochromatic palette by generating colors across a
141
+ # lightness range while preserving hue and saturation.
142
+ #
143
+ # @param steps [Integer] Number of colors to generate (default: 5)
144
+ # @return [Array<RGB, HSL, OKLCH>] Colors with lightness from 15% to 85%
145
+ #
146
+ # @example
147
+ # blue = Unmagic::Color.parse("#0000FF")
148
+ # blue.monochromatic(steps: 5)
149
+ # # => [very dark blue, dark blue, medium blue, light blue, very light blue]
150
+ def monochromatic(steps: 5)
151
+ raise ArgumentError, "steps must be at least 1" if steps < 1
152
+
153
+ hsl = to_hsl
154
+ min_lightness = 15.0
155
+ max_lightness = 85.0
156
+ step_size = (max_lightness - min_lightness) / (steps - 1).to_f
157
+
158
+ (0...steps).map do |i|
159
+ lightness = min_lightness + (i * step_size)
160
+ result = HSL.new(
161
+ hue: hsl.hue.value,
162
+ saturation: hsl.saturation.value,
163
+ lightness: lightness,
164
+ alpha: hsl.alpha.value,
165
+ )
166
+ convert_harmony_result(result)
167
+ end
168
+ end
169
+
170
+ # Returns an array of progressively darker colors (shades).
171
+ #
172
+ # Shades are created by reducing lightness, simulating the effect
173
+ # of adding black to the original color.
174
+ #
175
+ # @param steps [Integer] Number of shades to generate (default: 5)
176
+ # @param amount [Float] Total amount of darkening 0.0-1.0 (default: 0.5)
177
+ # @return [Array<RGB, HSL, OKLCH>] Progressively darker colors
178
+ #
179
+ # @example
180
+ # red = Unmagic::Color.parse("#FF0000")
181
+ # red.shades(steps: 3)
182
+ # # => [slightly darker red, darker red, darkest red]
183
+ def shades(steps: 5, amount: 0.5)
184
+ raise ArgumentError, "steps must be at least 1" if steps < 1
185
+
186
+ hsl = to_hsl
187
+ step_amount = amount / steps.to_f
188
+
189
+ (1..steps).map do |i|
190
+ new_lightness = hsl.lightness.value * (1 - (step_amount * i))
191
+ result = HSL.new(
192
+ hue: hsl.hue.value,
193
+ saturation: hsl.saturation.value,
194
+ lightness: new_lightness.clamp(0, 100),
195
+ alpha: hsl.alpha.value,
196
+ )
197
+ convert_harmony_result(result)
198
+ end
199
+ end
200
+
201
+ # Returns an array of progressively lighter colors (tints).
202
+ #
203
+ # Tints are created by increasing lightness, simulating the effect
204
+ # of adding white to the original color.
205
+ #
206
+ # @param steps [Integer] Number of tints to generate (default: 5)
207
+ # @param amount [Float] Total amount of lightening 0.0-1.0 (default: 0.5)
208
+ # @return [Array<RGB, HSL, OKLCH>] Progressively lighter colors
209
+ #
210
+ # @example
211
+ # blue = Unmagic::Color.parse("#0000FF")
212
+ # blue.tints(steps: 3)
213
+ # # => [slightly lighter blue, lighter blue, lightest blue]
214
+ def tints(steps: 5, amount: 0.5)
215
+ raise ArgumentError, "steps must be at least 1" if steps < 1
216
+
217
+ hsl = to_hsl
218
+ step_amount = amount / steps.to_f
219
+
220
+ (1..steps).map do |i|
221
+ new_lightness = hsl.lightness.value + (100 - hsl.lightness.value) * (step_amount * i)
222
+ result = HSL.new(
223
+ hue: hsl.hue.value,
224
+ saturation: hsl.saturation.value,
225
+ lightness: new_lightness.clamp(0, 100),
226
+ alpha: hsl.alpha.value,
227
+ )
228
+ convert_harmony_result(result)
229
+ end
230
+ end
231
+
232
+ # Returns an array of progressively desaturated colors (tones).
233
+ #
234
+ # Tones are created by reducing saturation, simulating the effect
235
+ # of adding gray to the original color.
236
+ #
237
+ # @param steps [Integer] Number of tones to generate (default: 5)
238
+ # @param amount [Float] Total amount of desaturation 0.0-1.0 (default: 0.5)
239
+ # @return [Array<RGB, HSL, OKLCH>] Progressively less saturated colors
240
+ #
241
+ # @example
242
+ # red = Unmagic::Color.parse("#FF0000")
243
+ # red.tones(steps: 3)
244
+ # # => [slightly muted red, more muted red, grayish red]
245
+ def tones(steps: 5, amount: 0.5)
246
+ raise ArgumentError, "steps must be at least 1" if steps < 1
247
+
248
+ hsl = to_hsl
249
+ step_amount = amount / steps.to_f
250
+
251
+ (1..steps).map do |i|
252
+ new_saturation = hsl.saturation.value * (1 - (step_amount * i))
253
+ result = HSL.new(
254
+ hue: hsl.hue.value,
255
+ saturation: new_saturation.clamp(0, 100),
256
+ lightness: hsl.lightness.value,
257
+ alpha: hsl.alpha.value,
258
+ )
259
+ convert_harmony_result(result)
260
+ end
261
+ end
262
+
263
+ private
264
+
265
+ # Rotate the hue by the specified degrees and return a new color.
266
+ #
267
+ # @param degrees [Numeric] Degrees to rotate (positive = clockwise)
268
+ # @return [RGB, HSL, OKLCH] New color with rotated hue (same type as self)
269
+ def rotate_hue(degrees)
270
+ hsl = to_hsl
271
+ result = HSL.new(
272
+ hue: hsl.hue.value + degrees,
273
+ saturation: hsl.saturation.value,
274
+ lightness: hsl.lightness.value,
275
+ alpha: hsl.alpha.value,
276
+ )
277
+ convert_harmony_result(result)
278
+ end
279
+
280
+ # Convert an HSL result back to the original color space.
281
+ #
282
+ # @param hsl_color [HSL] The HSL color to convert
283
+ # @return [RGB, HSL, OKLCH] Color in the same space as self
284
+ def convert_harmony_result(hsl_color)
285
+ case self
286
+ when RGB then hsl_color.to_rgb
287
+ when OKLCH then hsl_color.to_oklch
288
+ else hsl_color
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end