sai 0.3.0 → 0.3.1

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/README.md +11 -3
  4. data/docs/USAGE.md +57 -9
  5. data/lib/sai/ansi/color_parser.rb +109 -0
  6. data/lib/sai/ansi/sequence_processor.rb +15 -126
  7. data/lib/sai/ansi/style_parser.rb +66 -0
  8. data/lib/sai/ansi.rb +0 -27
  9. data/lib/sai/conversion/color_sequence.rb +4 -4
  10. data/lib/sai/conversion/rgb/color_classifier.rb +209 -0
  11. data/lib/sai/conversion/rgb/color_indexer.rb +48 -0
  12. data/lib/sai/conversion/rgb/color_space.rb +192 -0
  13. data/lib/sai/conversion/rgb/color_transformer.rb +140 -0
  14. data/lib/sai/conversion/rgb.rb +23 -269
  15. data/lib/sai/decorator/color_manipulations.rb +157 -0
  16. data/lib/sai/decorator/delegation.rb +84 -0
  17. data/lib/sai/decorator/gradients.rb +363 -0
  18. data/lib/sai/decorator/hex_colors.rb +56 -0
  19. data/lib/sai/decorator/named_colors.rb +780 -0
  20. data/lib/sai/decorator/named_styles.rb +276 -0
  21. data/lib/sai/decorator/rgb_colors.rb +64 -0
  22. data/lib/sai/decorator.rb +29 -775
  23. data/lib/sai/named_colors.rb +437 -0
  24. data/lib/sai.rb +731 -23
  25. data/sig/sai/ansi/color_parser.rbs +77 -0
  26. data/sig/sai/ansi/sequence_processor.rbs +0 -75
  27. data/sig/sai/ansi/style_parser.rbs +59 -0
  28. data/sig/sai/ansi.rbs +0 -10
  29. data/sig/sai/conversion/rgb/color_classifier.rbs +165 -0
  30. data/sig/sai/conversion/rgb/color_indexer.rbs +41 -0
  31. data/sig/sai/conversion/rgb/color_space.rbs +129 -0
  32. data/sig/sai/conversion/rgb/color_transformer.rbs +99 -0
  33. data/sig/sai/conversion/rgb.rbs +15 -198
  34. data/sig/sai/decorator/color_manipulations.rbs +125 -0
  35. data/sig/sai/decorator/delegation.rbs +47 -0
  36. data/sig/sai/decorator/gradients.rbs +267 -0
  37. data/sig/sai/decorator/hex_colors.rbs +48 -0
  38. data/sig/sai/decorator/named_colors.rbs +1491 -0
  39. data/sig/sai/decorator/named_styles.rbs +72 -0
  40. data/sig/sai/decorator/rgb_colors.rbs +52 -0
  41. data/sig/sai/decorator.rbs +21 -195
  42. data/sig/sai/named_colors.rbs +65 -0
  43. data/sig/sai.rbs +1468 -44
  44. metadata +32 -4
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ module Conversion
5
+ module RGB
6
+ # Classify color characteristics
7
+ #
8
+ # @author {https://aaronmallen.me Aaron Allen}
9
+ # @since 0.3.1
10
+ #
11
+ # @api private
12
+ module ColorClassifier
13
+ class << self
14
+ # Get closest ANSI color for RGB values
15
+ #
16
+ # @author {https://aaronmallen.me Aaron Allen}
17
+ # @since 0.1.0
18
+ #
19
+ # @api private
20
+ #
21
+ # @param red [Float] the red component (0-1)
22
+ # @param green [Float] the green component (0-1)
23
+ # @param blue [Float] the blue component (0-1)
24
+ #
25
+ # @return [Symbol] the closest ANSI color name
26
+ # @rbs (Float red, Float green, Float blue) -> Symbol
27
+ def closest_ansi_color(red, green, blue)
28
+ return :black if dark?(red, green, blue)
29
+ return :white if grayscale?(red, green, blue)
30
+ return primary_color(red, green, blue) if primary?(red, green, blue)
31
+ return secondary_color(red, green, blue) if secondary?(red, green, blue)
32
+
33
+ :white
34
+ end
35
+
36
+ # Determine if a color is dark
37
+ #
38
+ # @author {https://aaronmallen.me Aaron Allen}
39
+ # @since 0.1.0
40
+ #
41
+ # @api private
42
+ #
43
+ # @param red [Float] the red component (0-1)
44
+ # @param green [Float] the green component (0-1)
45
+ # @param blue [Float] the blue component (0-1)
46
+ #
47
+ # @return [Boolean] true if color is dark
48
+ # @rbs (Float red, Float green, Float blue) -> bool
49
+ def dark?(red, green, blue)
50
+ [red, green, blue].max < 0.3
51
+ end
52
+
53
+ # Determine if a color is grayscale
54
+ #
55
+ # @author {https://aaronmallen.me Aaron Allen}
56
+ # @since 0.1.0
57
+ #
58
+ # @api private
59
+ #
60
+ # @param red [Float] the red component (0-1)
61
+ # @param green [Float] the green component (0-1)
62
+ # @param blue [Float] the blue component (0-1)
63
+ #
64
+ # @return [Boolean] true if color is grayscale
65
+ # @rbs (Float red, Float green, Float blue) -> bool
66
+ def grayscale?(red, green, blue)
67
+ red == green && green == blue
68
+ end
69
+
70
+ # Determine if RGB values represent a primary color
71
+ #
72
+ # @author {https://aaronmallen.me Aaron Allen}
73
+ # @since 0.1.0
74
+ #
75
+ # @api private
76
+ #
77
+ # @param red [Float] the red component (0-1)
78
+ # @param green [Float] the green component (0-1)
79
+ # @param blue [Float] the blue component (0-1)
80
+ #
81
+ # @return [Boolean] true if color is primary
82
+ # @rbs (Float red, Float green, Float blue) -> bool
83
+ def primary?(red, green, blue)
84
+ max = [red, green, blue].max
85
+ mid = [red, green, blue].sort[1]
86
+ (max - mid) > 0.3
87
+ end
88
+
89
+ # Get the closest primary color
90
+ #
91
+ # @author {https://aaronmallen.me Aaron Allen}
92
+ # @since 0.1.0
93
+ #
94
+ # @api private
95
+ #
96
+ # @param red [Float] the red component (0-1)
97
+ # @param green [Float] the green component (0-1)
98
+ # @param blue [Float] the blue component (0-1)
99
+ #
100
+ # @return [Symbol] the primary color name
101
+ # @rbs (Float red, Float green, Float blue) -> Symbol
102
+ def primary_color(red, green, blue)
103
+ max = [red, green, blue].max
104
+ case max
105
+ when red then :red
106
+ when green then :green
107
+ else :blue
108
+ end
109
+ end
110
+
111
+ # Determine if RGB values represent a secondary color
112
+ #
113
+ # @author {https://aaronmallen.me Aaron Allen}
114
+ # @since 0.1.0
115
+ #
116
+ # @api private
117
+ #
118
+ # @param red [Float] the red component (0-1)
119
+ # @param green [Float] the green component (0-1)
120
+ # @param blue [Float] the blue component (0-1)
121
+ #
122
+ # @return [Boolean] true if color is secondary
123
+ # @rbs (Float red, Float green, Float blue) -> bool
124
+ def secondary?(red, green, blue)
125
+ return true if yellow?(red, green, blue)
126
+ return true if magenta?(red, green, blue)
127
+ return true if cyan?(red, green, blue)
128
+
129
+ false
130
+ end
131
+
132
+ # Get the closest secondary color
133
+ #
134
+ # @author {https://aaronmallen.me Aaron Allen}
135
+ # @since 0.1.0
136
+ #
137
+ # @api private
138
+ #
139
+ # @param red [Float] the red component (0-1)
140
+ # @param green [Float] the green component (0-1)
141
+ # @param blue [Float] the blue component (0-1)
142
+ #
143
+ # @return [Symbol] the secondary color name
144
+ # @rbs (Float red, Float green, Float blue) -> Symbol
145
+ def secondary_color(red, green, blue)
146
+ return :yellow if yellow?(red, green, blue)
147
+ return :magenta if magenta?(red, green, blue)
148
+ return :cyan if cyan?(red, green, blue)
149
+
150
+ :white
151
+ end
152
+
153
+ private
154
+
155
+ # Check if RGB values represent cyan
156
+ #
157
+ # @author {https://aaronmallen.me Aaron Allen}
158
+ # @since 0.1.0
159
+ #
160
+ # @api private
161
+ #
162
+ # @param red [Float] the red component (0-1)
163
+ # @param green [Float] the green component (0-1)
164
+ # @param blue [Float] the blue component (0-1)
165
+ #
166
+ # @return [Boolean] true if color is cyan
167
+ # @rbs (Float red, Float green, Float blue) -> bool
168
+ def cyan?(red, green, blue)
169
+ green > red && blue > red
170
+ end
171
+
172
+ # Check if RGB values represent magenta
173
+ #
174
+ # @author {https://aaronmallen.me Aaron Allen}
175
+ # @since 0.1.0
176
+ #
177
+ # @api private
178
+ #
179
+ # @param red [Float] the red component (0-1)
180
+ # @param green [Float] the green component (0-1)
181
+ # @param blue [Float] the blue component (0-1)
182
+ #
183
+ # @return [Boolean] true if color is magenta
184
+ # @rbs (Float red, Float green, Float blue) -> bool
185
+ def magenta?(red, green, blue)
186
+ red > green && blue > green
187
+ end
188
+
189
+ # Check if RGB values represent yellow
190
+ #
191
+ # @author {https://aaronmallen.me Aaron Allen}
192
+ # @since 0.1.0
193
+ #
194
+ # @api private
195
+ #
196
+ # @param red [Float] the red component (0-1)
197
+ # @param green [Float] the green component (0-1)
198
+ # @param blue [Float] the blue component (0-1)
199
+ #
200
+ # @return [Boolean] true if color is yellow
201
+ # @rbs (Float red, Float green, Float blue) -> bool
202
+ def yellow?(red, green, blue)
203
+ red > blue && green > blue && (red - green).abs < 0.3
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sai
4
+ module Conversion
5
+ module RGB
6
+ # Color indexing utilities
7
+ #
8
+ # @author {https://aaronmallen.me Aaron Allen}
9
+ # @since 0.3.1
10
+ #
11
+ # @api private
12
+ module ColorIndexer
13
+ class << self
14
+ # Convert RGB values to 256-color cube index
15
+ #
16
+ # @author {https://aaronmallen.me Aaron Allen}
17
+ # @since 0.1.0
18
+ #
19
+ # @api private
20
+ #
21
+ # @param rgb [Array<Integer>] RGB values (0-255)
22
+ #
23
+ # @return [Integer] the color cube index
24
+ # @rbs (Array[Integer] rgb) -> Integer
25
+ def color_cube(rgb)
26
+ r, g, b = rgb.map { |c| ((c / 255.0) * 5).round } #: [Integer, Integer, Integer]
27
+ 16 + (r * 36) + (g * 6) + b
28
+ end
29
+
30
+ # Convert RGB values to grayscale index
31
+ #
32
+ # @author {https://aaronmallen.me Aaron Allen}
33
+ # @since 0.1.0
34
+ #
35
+ # @api private
36
+ #
37
+ # @param rgb [Array<Integer>] RGB values
38
+ #
39
+ # @return [Integer] the grayscale index
40
+ # @rbs (Array[Integer] rgb) -> Integer
41
+ def grayscale(rgb)
42
+ 232 + ((rgb[0] / 255.0) * 23).round
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/named_colors'
4
+
5
+ module Sai
6
+ module Conversion
7
+ module RGB
8
+ # Convert colors between different color space formats
9
+ #
10
+ # @author {https://aaronmallen.me Aaron Allen}
11
+ # @since 0.3.1
12
+ #
13
+ # @api private
14
+ module ColorSpace
15
+ class << self
16
+ # Convert HSV values to RGB
17
+ #
18
+ # @author {https://aaronmallen.me Aaron Allen}
19
+ # @since 0.3.1
20
+ #
21
+ # @api private
22
+ #
23
+ # @param hue [Float] the hue component (0-360)
24
+ # @param saturation [Float] the saturation component (0-1)
25
+ # @param value [Float] the value component (0-1)
26
+ #
27
+ # @return [Array<Integer>] the RGB values
28
+ # @rbs (Float hue, Float saturation, Float value) -> Array[Integer]
29
+ def hsv_to_rgb(hue, saturation, value)
30
+ hue_sector = (hue / 60.0).floor.to_i
31
+ hue_remainder = (hue / 60.0) - hue_sector
32
+
33
+ components = calculate_hsv_components(value, saturation, hue_remainder)
34
+ primary, secondary, tertiary = *components
35
+
36
+ return [0, 0, 0] unless primary && secondary && tertiary
37
+
38
+ rgb = select_rgb_values(hue_sector, value, primary, secondary, tertiary)
39
+ normalize_rgb(rgb)
40
+ end
41
+
42
+ # Convert a color value to RGB components
43
+ #
44
+ # @author {https://aaronmallen.me Aaron Allen}
45
+ # @since 0.1.0
46
+ #
47
+ # @api private
48
+ #
49
+ # @param color [String, Array<Integer>] the color to convert
50
+ #
51
+ # @raise [ArgumentError] if the color format is invalid
52
+ # @return [Array<Integer>] the RGB components
53
+ # @rbs (Array[Integer] | String | Symbol color) -> Array[Integer]
54
+ def resolve(color)
55
+ case color
56
+ when Array then validate_rgb(color)
57
+ when /^#?([A-Fa-f0-9]{6})$/
58
+ hex_to_rgb(
59
+ Regexp.last_match(1) # steep:ignore ArgumentTypeMismatch
60
+ )
61
+ when String, Symbol then named_to_rgb(color.to_s.downcase)
62
+ else
63
+ raise ArgumentError, "Invalid color format: #{color}"
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Calculate the intermediate HSV components
70
+ #
71
+ # @author {https://aaronmallen.me Aaron Allen}
72
+ # @since 0.3.1
73
+ #
74
+ # @api private
75
+ #
76
+ # @param value [Float] the value component
77
+ # @param saturation [Float] the saturation component
78
+ # @param hue_remainder [Float] the remainder of hue / 60
79
+ #
80
+ # @return [Array<Float>] the primary, secondary, and tertiary components
81
+ # @rbs (Float value, Float saturation, Float hue_remainder) -> [Float, Float, Float]
82
+ def calculate_hsv_components(value, saturation, hue_remainder)
83
+ primary = value * (1 - saturation)
84
+ secondary = value * (1 - (saturation * hue_remainder))
85
+ tertiary = value * (1 - (saturation * (1 - hue_remainder)))
86
+
87
+ [primary, secondary, tertiary]
88
+ end
89
+
90
+ # Convert a hex string to RGB values
91
+ #
92
+ # @author {https://aaronmallen.me Aaron Allen}
93
+ # @since 0.1.0
94
+ #
95
+ # @api private
96
+ #
97
+ # @param hex [String] the hex color code
98
+ #
99
+ # @return [Array<Integer>] the RGB components
100
+ # @rbs (String hex) -> Array[Integer]
101
+ def hex_to_rgb(hex)
102
+ hex = hex.delete_prefix('#')
103
+ [
104
+ hex[0..1].to_i(16), # steep:ignore UnexpectedPositionalArgument
105
+ hex[2..3].to_i(16), # steep:ignore UnexpectedPositionalArgument
106
+ hex[4..5].to_i(16) # steep:ignore UnexpectedPositionalArgument
107
+ ]
108
+ end
109
+
110
+ # Convert a named color to RGB values
111
+ #
112
+ # @author {https://aaronmallen.me Aaron Allen}
113
+ # @since 0.1.0
114
+ #
115
+ # @api private
116
+ #
117
+ # @param color_name [String] the color name
118
+ #
119
+ # @raise [ArgumentError] if the color name is unknown
120
+ # @return [Array<Integer>] the RGB components
121
+ # @rbs (String color_name) -> Array[Integer]
122
+ def named_to_rgb(color_name)
123
+ color = NamedColors[color_name.to_sym]
124
+ raise ArgumentError, "Unknown color name: #{color_name}" unless color
125
+
126
+ color
127
+ end
128
+
129
+ # Convert RGB values from 0-1 range to 0-255 range
130
+ #
131
+ # @author {https://aaronmallen.me Aaron Allen}
132
+ # @since 0.3.1
133
+ #
134
+ # @api private
135
+ #
136
+ # @param rgb [Array<Float>] RGB values in 0-1 range
137
+ #
138
+ # @return [Array<Integer>] RGB values in 0-255 range
139
+ # @rbs (Array[Float] rgb) -> Array[Integer]
140
+ def normalize_rgb(rgb)
141
+ rgb.map { |c| (c * 255).round.clamp(0, 255) }
142
+ end
143
+
144
+ # Select RGB values based on the hue sector
145
+ #
146
+ # @author {https://aaronmallen.me Aaron Allen}
147
+ # @since 0.3.1
148
+ #
149
+ # @api private
150
+ #
151
+ # @param sector [Integer] the hue sector (0-5)
152
+ # @param value [Float] the value component
153
+ # @param primary [Float] primary component from HSV calculation
154
+ # @param secondary [Float] secondary component from HSV calculation
155
+ # @param tertiary [Float] tertiary component from HSV calculation
156
+ #
157
+ # @return [Array<Float>] the RGB values before normalization
158
+ # @rbs (Integer sector, Float value, Float primary, Float secondary, Float tertiary) -> Array[Float]
159
+ def select_rgb_values(sector, value, primary, secondary, tertiary)
160
+ case sector % 6
161
+ when 0 then [value, tertiary, primary]
162
+ when 1 then [secondary, value, primary]
163
+ when 2 then [primary, value, tertiary]
164
+ when 3 then [primary, secondary, value]
165
+ when 4 then [tertiary, primary, value]
166
+ else [value, primary, secondary]
167
+ end
168
+ end
169
+
170
+ # Validate RGB values
171
+ #
172
+ # @author {https://aaronmallen.me Aaron Allen}
173
+ # @since 0.1.0
174
+ #
175
+ # @api private
176
+ #
177
+ # @param color [Array<Integer>] the RGB components to validate
178
+ # @return [Array<Integer>] the validated RGB components
179
+ # @raise [ArgumentError] if the RGB values are invalid
180
+ # @rbs (Array[Integer] color) -> Array[Integer]
181
+ def validate_rgb(color)
182
+ unless color.size == 3 && color.all? { |c| c.is_a?(Integer) && c.between?(0, 255) }
183
+ raise ArgumentError, "Invalid RGB values: #{color}"
184
+ end
185
+
186
+ color
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sai/conversion/rgb/color_space'
4
+
5
+ module Sai
6
+ module Conversion
7
+ module RGB
8
+ # Perform color transformations
9
+ #
10
+ # @author {https://aaronmallen.me Aaron Allen}
11
+ # @since 0.3.1
12
+ #
13
+ # @api private
14
+ module ColorTransformer
15
+ class << self
16
+ # Darken an RGB color by a percentage
17
+ #
18
+ # @author {https://aaronmallen.me Aaron Allen}
19
+ # @since 0.3.1
20
+ #
21
+ # @api private
22
+ #
23
+ # @param color [Array<Integer>, String, Symbol] the color to darken
24
+ # @param amount [Float] amount to darken by (0.0-1.0)
25
+ #
26
+ # @raise [ArgumentError] if amount is not between 0.0 and 1.0
27
+ # @return [Array<Integer>] the darkened RGB values
28
+ # @rbs ((Array[Integer] | String | Symbol) color, Float amount) -> Array[Integer]
29
+ def darken(color, amount)
30
+ raise ArgumentError, "Invalid amount: #{amount}" unless amount.between?(0.0, 1.0)
31
+
32
+ rgb = ColorSpace.resolve(color)
33
+ rgb.map { |c| [0, (c * (1 - amount)).round].max }
34
+ end
35
+
36
+ # Generate a gradient between two colors with a specified number of steps
37
+ #
38
+ # @author {https://aaronmallen.me Aaron Allen}
39
+ # @since 0.3.1
40
+ #
41
+ # @api private
42
+ #
43
+ # @param start_color [Array<Integer>, String, Symbol] the starting color
44
+ # @param end_color [Array<Integer>, String, Symbol] the ending color
45
+ # @param steps [Integer] the number of colors to generate (minimum 2)
46
+ #
47
+ # @raise [ArgumentError] if steps is less than 2
48
+ # @return [Array<Array<Integer>>] the gradient colors as RGB values
49
+ # @rbs (
50
+ # (Array[Integer] | String | Symbol) start_color,
51
+ # (Array[Integer] | String | Symbol) end_color,
52
+ # Integer steps
53
+ # ) -> Array[Array[Integer]]
54
+ def gradient(start_color, end_color, steps)
55
+ raise ArgumentError, "Steps must be at least 2, got: #{steps}" if steps < 2
56
+
57
+ (0...steps).map do |i|
58
+ step = i.to_f / (steps - 1)
59
+ interpolate_color(start_color, end_color, step)
60
+ end
61
+ end
62
+
63
+ # Interpolate between two colors to create a gradient step
64
+ #
65
+ # @author {https://aaronmallen.me Aaron Allen}
66
+ # @since 0.3.1
67
+ #
68
+ # @api private
69
+ #
70
+ # @param start_color [Array<Integer>, String, Symbol] the starting color
71
+ # @param end_color [Array<Integer>, String, Symbol] the ending color
72
+ # @param step [Float] the interpolation step (0.0-1.0)
73
+ #
74
+ # @raise [ArgumentError] if step is not between 0.0 and 1.0
75
+ # @return [Array<Integer>] the interpolated RGB values
76
+ # @rbs (
77
+ # (Array[Integer] | String | Symbol) start_color,
78
+ # (Array[Integer] | String | Symbol) end_color,
79
+ # Float step
80
+ # ) -> Array[Integer]
81
+ def interpolate_color(start_color, end_color, step)
82
+ raise ArgumentError, "Invalid step: #{step}" unless step.between?(0.0, 1.0)
83
+
84
+ start_rgb = ColorSpace.resolve(start_color)
85
+ end_rgb = ColorSpace.resolve(end_color)
86
+
87
+ start_rgb.zip(end_rgb).map do |values|
88
+ start_val, end_val = values
89
+ next 0 unless start_val && end_val # Handle potential nil values
90
+
91
+ (start_val + ((end_val - start_val) * step)).round.clamp(0, 255)
92
+ end
93
+ end
94
+
95
+ # Lighten an RGB color by a percentage
96
+ #
97
+ # @author {https://aaronmallen.me Aaron Allen}
98
+ # @since 0.3.1
99
+ #
100
+ # @api private
101
+ #
102
+ # @param color [Array<Integer>, String, Symbol] the color to lighten
103
+ # @param amount [Float] amount to lighten by (0.0-1.0)
104
+ #
105
+ # @raise [ArgumentError] if amount is not between 0.0 and 1.0
106
+ # @return [Array<Integer>] the lightened RGB values
107
+ # @rbs ((Array[Integer] | String | Symbol) color, Float amount) -> Array[Integer]
108
+ def lighten(color, amount)
109
+ raise ArgumentError, "Invalid amount: #{amount}" unless amount.between?(0.0, 1.0)
110
+
111
+ rgb = ColorSpace.resolve(color)
112
+ rgb.map { |c| [255, (c * (1 + amount)).round].min }
113
+ end
114
+
115
+ # Generate a rainbow gradient with a specified number of steps
116
+ #
117
+ # @author {https://aaronmallen.me Aaron Allen}
118
+ # @since 0.3.1
119
+ #
120
+ # @api private
121
+ #
122
+ # @param steps [Integer] the number of colors to generate (minimum 2)
123
+ #
124
+ # @raise [ArgumentError] if steps is less than 2
125
+ # @return [Array<Array<Integer>>] the rainbow gradient colors as RGB values
126
+ # @rbs (Integer steps) -> Array[Array[Integer]]
127
+ def rainbow_gradient(steps)
128
+ raise ArgumentError, "Steps must be at least 2, got: #{steps}" if steps < 2
129
+
130
+ hue_step = 360.0 / steps
131
+ (0...steps).map do |i|
132
+ hue = (i * hue_step) % 360
133
+ ColorSpace.hsv_to_rgb(hue, 1.0, 1.0)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end