unmagic-color 0.1.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.
- checksums.yaml +7 -0
- data/README.md +274 -0
- data/data/rgb.txt +164 -0
- data/lib/unmagic/color/hsl.rb +421 -0
- data/lib/unmagic/color/oklch.rb +433 -0
- data/lib/unmagic/color/rgb/hex.rb +104 -0
- data/lib/unmagic/color/rgb/named.rb +112 -0
- data/lib/unmagic/color/rgb.rb +390 -0
- data/lib/unmagic/color/string/hash_function.rb +316 -0
- data/lib/unmagic/color/util/percentage.rb +143 -0
- data/lib/unmagic/color.rb +402 -0
- data/lib/unmagic_color.rb +3 -0
- metadata +55 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
# `OKLCH` (Lightness, Chroma, Hue) color representation.
|
|
6
|
+
#
|
|
7
|
+
# ## Understanding OKLCH
|
|
8
|
+
#
|
|
9
|
+
# OKLCH is a modern color space designed to match how humans actually perceive colors.
|
|
10
|
+
# Unlike {RGB} or even {HSL}, OKLCH ensures that colors with the same lightness value
|
|
11
|
+
# *look* equally bright to our eyes, regardless of their hue.
|
|
12
|
+
#
|
|
13
|
+
# ## The Problem with RGB and HSL
|
|
14
|
+
#
|
|
15
|
+
# In {RGB} and {HSL}, pure yellow and pure blue can have the same "lightness" value,
|
|
16
|
+
# but yellow looks much brighter to our eyes. This makes it hard to create
|
|
17
|
+
# consistent-looking color palettes.
|
|
18
|
+
#
|
|
19
|
+
# OKLCH solves this by being "perceptually uniform" - if you change lightness
|
|
20
|
+
# by `0.1`, it looks like the same amount of change whether you're working with
|
|
21
|
+
# red, green, blue, or any other hue.
|
|
22
|
+
#
|
|
23
|
+
# ## The Three Components
|
|
24
|
+
#
|
|
25
|
+
# 1. **Lightness** (`0.0-1.0`): How bright the color appears
|
|
26
|
+
# - `0.0` = Black
|
|
27
|
+
# - `0.5` = Medium brightness
|
|
28
|
+
# - `1.0` = White
|
|
29
|
+
#
|
|
30
|
+
# Unlike {HSL}, this matches *perceived* brightness consistently across all hues.
|
|
31
|
+
#
|
|
32
|
+
# 2. **Chroma** (`0.0-0.5`): How colorful/saturated it is
|
|
33
|
+
# - `0.0` = Gray (no color)
|
|
34
|
+
# - `0.15` = Moderate color (good for UI)
|
|
35
|
+
# - `0.3+` = Very vivid (use sparingly)
|
|
36
|
+
#
|
|
37
|
+
# Think of it like saturation, but more accurate to perception.
|
|
38
|
+
#
|
|
39
|
+
# 3. **Hue** (`0-360°`): The color itself (same as {HSL})
|
|
40
|
+
# - `0°/360°` = Red
|
|
41
|
+
# - `120°` = Green
|
|
42
|
+
# - `240°` = Blue
|
|
43
|
+
#
|
|
44
|
+
# ## Why Use OKLCH?
|
|
45
|
+
#
|
|
46
|
+
# - Creating accessible color palettes (ensure consistent contrast)
|
|
47
|
+
# - Generating color scales that look evenly spaced
|
|
48
|
+
# - Interpolating between colors smoothly
|
|
49
|
+
# - Matching colors that "feel" equally bright
|
|
50
|
+
#
|
|
51
|
+
# ## When to Use Each Color Space
|
|
52
|
+
#
|
|
53
|
+
# - **{RGB}**: When working with screens/displays directly
|
|
54
|
+
# - **{HSL}**: When you need intuitive color manipulation
|
|
55
|
+
# - **OKLCH**: When you need perceptually accurate colors (design systems, accessibility)
|
|
56
|
+
#
|
|
57
|
+
# ## Examples
|
|
58
|
+
#
|
|
59
|
+
# # Parse OKLCH colors
|
|
60
|
+
# color = Unmagic::Color::OKLCH.parse("oklch(0.65 0.15 240)") # Medium blue
|
|
61
|
+
#
|
|
62
|
+
# # Create directly
|
|
63
|
+
# accessible = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
64
|
+
#
|
|
65
|
+
# # Access components
|
|
66
|
+
# color.lightness #=> 0.65 (ratio form)
|
|
67
|
+
# color.chroma.value #=> 0.15
|
|
68
|
+
# color.hue.value #=> 240
|
|
69
|
+
#
|
|
70
|
+
# # Create perceptually uniform variations
|
|
71
|
+
# lighter = color.lighten(0.05) # Looks 5% brighter
|
|
72
|
+
# less_colorful = color.desaturate(0.03)
|
|
73
|
+
#
|
|
74
|
+
# # Generate consistent colors
|
|
75
|
+
# Unmagic::Color::OKLCH.derive("user@example.com".hash) # Perceptually balanced color
|
|
76
|
+
class OKLCH < Color
|
|
77
|
+
# Error raised when parsing OKLCH color strings fails
|
|
78
|
+
class ParseError < Color::Error; end
|
|
79
|
+
|
|
80
|
+
attr_reader :chroma, :hue
|
|
81
|
+
|
|
82
|
+
# Create a new OKLCH color.
|
|
83
|
+
#
|
|
84
|
+
# @param lightness [Float] Lightness as a ratio (0.0-1.0), clamped to range
|
|
85
|
+
# @param chroma [Float] Chroma intensity (0.0-0.5), clamped to range
|
|
86
|
+
# @param hue [Numeric] Hue in degrees (0-360), wraps around if outside range
|
|
87
|
+
#
|
|
88
|
+
# @example Create a medium blue
|
|
89
|
+
# OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
90
|
+
#
|
|
91
|
+
# @example Create a vibrant red
|
|
92
|
+
# OKLCH.new(lightness: 0.60, chroma: 0.25, hue: 30)
|
|
93
|
+
def initialize(lightness:, chroma:, hue:)
|
|
94
|
+
super()
|
|
95
|
+
@lightness = Color::Lightness.new(lightness * 100) # Convert 0-1 to percentage
|
|
96
|
+
@chroma = Color::Chroma.new(value: chroma)
|
|
97
|
+
@hue = Color::Hue.new(value: hue)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get the lightness as a ratio (0.0-1.0).
|
|
101
|
+
#
|
|
102
|
+
# This overrides the attr_reader to return the ratio form, which is the
|
|
103
|
+
# standard way to work with OKLCH lightness.
|
|
104
|
+
#
|
|
105
|
+
# @return [Float] Lightness from 0.0 (black) to 1.0 (white)
|
|
106
|
+
def lightness = @lightness.to_ratio
|
|
107
|
+
|
|
108
|
+
# Get the lightness as a percentage (0.0-100.0).
|
|
109
|
+
#
|
|
110
|
+
# Helper method for when you need the percentage form instead of ratio.
|
|
111
|
+
#
|
|
112
|
+
# @return [Float] Lightness from 0.0 to 100.0
|
|
113
|
+
def lightness_percentage = @lightness.value
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# Parse an OKLCH color from a string.
|
|
117
|
+
#
|
|
118
|
+
# Accepts formats:
|
|
119
|
+
# - CSS format: "oklch(0.65 0.15 240)"
|
|
120
|
+
# - Raw values: "0.65 0.15 240"
|
|
121
|
+
# - Space-separated values
|
|
122
|
+
#
|
|
123
|
+
# @param input [String] The OKLCH color string to parse
|
|
124
|
+
# @return [OKLCH] The parsed OKLCH color
|
|
125
|
+
# @raise [ParseError] If the input format is invalid or values are out of range
|
|
126
|
+
#
|
|
127
|
+
# @example Parse CSS format
|
|
128
|
+
# OKLCH.parse("oklch(0.65 0.15 240)")
|
|
129
|
+
#
|
|
130
|
+
# @example Parse without function wrapper
|
|
131
|
+
# OKLCH.parse("0.58 0.12 180")
|
|
132
|
+
def parse(input)
|
|
133
|
+
raise ParseError, "Input must be a string" unless input.is_a?(::String)
|
|
134
|
+
|
|
135
|
+
# Remove oklch() wrapper if present
|
|
136
|
+
clean = input.gsub(/^oklch\s*\(\s*|\s*\)$/, "").strip
|
|
137
|
+
|
|
138
|
+
# Split values
|
|
139
|
+
parts = clean.split(/\s+/)
|
|
140
|
+
unless parts.length == 3
|
|
141
|
+
raise ParseError, "Expected 3 OKLCH values, got #{parts.length}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if all values are numeric
|
|
145
|
+
parts.each_with_index do |v, i|
|
|
146
|
+
unless v.match?(/\A\d+(\.\d+)?\z/)
|
|
147
|
+
component = ["lightness", "chroma", "hue"][i]
|
|
148
|
+
raise ParseError, "Invalid #{component} value: #{v.inspect} (must be a number)"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Convert to floats
|
|
153
|
+
l = parts[0].to_f
|
|
154
|
+
c = parts[1].to_f
|
|
155
|
+
h = parts[2].to_f
|
|
156
|
+
|
|
157
|
+
# Validate ranges
|
|
158
|
+
if l < 0 || l > 1
|
|
159
|
+
raise ParseError, "Lightness must be between 0 and 1, got #{l}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if c < 0 || c > 0.5
|
|
163
|
+
raise ParseError, "Chroma must be between 0 and 0.5, got #{c}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if h < 0 || h >= 360
|
|
167
|
+
raise ParseError, "Hue must be between 0 and 360, got #{h}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
new(lightness: l, chroma: c, hue: h)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Generate a deterministic OKLCH color from an integer seed.
|
|
174
|
+
#
|
|
175
|
+
# Creates perceptually balanced, visually distinct colors. This is particularly
|
|
176
|
+
# effective in OKLCH because the perceptual uniformity ensures all generated
|
|
177
|
+
# colors have consistent perceived brightness and saturation.
|
|
178
|
+
#
|
|
179
|
+
# The hue distribution uses a golden-angle approach to spread colors evenly
|
|
180
|
+
# and avoid clustering similar hues together.
|
|
181
|
+
#
|
|
182
|
+
# @param seed [Integer] The seed value (typically from a hash function)
|
|
183
|
+
# @param lightness [Float] Fixed lightness value (0.0-1.0, default 0.58)
|
|
184
|
+
# @param chroma_range [Range] Range for chroma variation (default 0.10..0.18)
|
|
185
|
+
# @param hue_spread [Integer] Modulo for hue distribution (default 997)
|
|
186
|
+
# @param hue_base [Float] Multiplier for hue calculation (default 137.508 - golden angle)
|
|
187
|
+
# @return [OKLCH] A deterministic, perceptually balanced color
|
|
188
|
+
# @raise [ArgumentError] If seed is not an integer
|
|
189
|
+
#
|
|
190
|
+
# @example Generate avatar color
|
|
191
|
+
# OKLCH.derive("user@example.com".hash)
|
|
192
|
+
#
|
|
193
|
+
# @example Generate lighter UI colors
|
|
194
|
+
# OKLCH.derive(12345, lightness: 0.75)
|
|
195
|
+
#
|
|
196
|
+
# @example Generate more saturated colors
|
|
197
|
+
# OKLCH.derive(12345, chroma_range: (0.15..0.25))
|
|
198
|
+
def derive(seed, lightness: 0.58, chroma_range: (0.10..0.18), hue_spread: 997, hue_base: 137.508)
|
|
199
|
+
raise ArgumentError, "Seed must be an integer" unless seed.is_a?(Integer)
|
|
200
|
+
|
|
201
|
+
h32 = seed & 0xFFFFFFFF # Ensure 32-bit
|
|
202
|
+
|
|
203
|
+
# Hue: golden-angle style distribution to avoid clusters
|
|
204
|
+
h = (hue_base * (h32 % hue_spread)) % 360
|
|
205
|
+
|
|
206
|
+
# Chroma: map a byte into a safe text-friendly range
|
|
207
|
+
c = chroma_range.begin + ((h32 >> 8) & 0xFF) / 255.0 * (chroma_range.end - chroma_range.begin)
|
|
208
|
+
|
|
209
|
+
new(lightness: lightness, chroma: c, hue: h)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Convert to OKLCH color space.
|
|
214
|
+
#
|
|
215
|
+
# Since this is already an OKLCH color, returns self.
|
|
216
|
+
#
|
|
217
|
+
# @return [OKLCH] self
|
|
218
|
+
def to_oklch
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Convert to RGB color space.
|
|
223
|
+
#
|
|
224
|
+
# @return [RGB] The color in RGB color space (approximation)
|
|
225
|
+
# @note This is currently a simplified approximation. A proper OKLCH to sRGB
|
|
226
|
+
# conversion requires more complex color science calculations.
|
|
227
|
+
def to_rgb
|
|
228
|
+
# For now, convert via approximation - would need proper OKLCH->sRGB conversion
|
|
229
|
+
# This is a simplified placeholder that approximates RGB from OKLCH
|
|
230
|
+
require_relative "rgb"
|
|
231
|
+
|
|
232
|
+
# Simple approximation: use lightness and chroma to estimate RGB
|
|
233
|
+
base = (@lightness.to_ratio * 255).round
|
|
234
|
+
saturation = (@chroma * 255).value
|
|
235
|
+
|
|
236
|
+
# Convert hue to RGB ratios (very simplified)
|
|
237
|
+
h_rad = (@hue * Math::PI / 180).value
|
|
238
|
+
r_offset = (Math.cos(h_rad) * saturation).round
|
|
239
|
+
g_offset = (Math.cos(h_rad + 2 * Math::PI / 3) * saturation).round
|
|
240
|
+
b_offset = (Math.cos(h_rad + 4 * Math::PI / 3) * saturation).round
|
|
241
|
+
|
|
242
|
+
r = (base + r_offset).clamp(0, 255)
|
|
243
|
+
g = (base + g_offset).clamp(0, 255)
|
|
244
|
+
b = (base + b_offset).clamp(0, 255)
|
|
245
|
+
|
|
246
|
+
Unmagic::Color::RGB.new(red: r, green: g, blue: b)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Calculate the relative luminance.
|
|
250
|
+
#
|
|
251
|
+
# In OKLCH, the lightness value directly represents perceptual luminance,
|
|
252
|
+
# so we can use it as-is.
|
|
253
|
+
#
|
|
254
|
+
# @return [Float] Luminance from 0.0 (black) to 1.0 (white)
|
|
255
|
+
def luminance
|
|
256
|
+
# OKLCH lightness is perceptually uniform, so we can use it directly
|
|
257
|
+
@lightness.to_ratio # Return 0-1 range
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Create a lighter version by increasing lightness.
|
|
261
|
+
#
|
|
262
|
+
# In OKLCH, lightness changes are perceptually uniform, so adding 0.05
|
|
263
|
+
# will look like the same brightness increase regardless of the hue.
|
|
264
|
+
#
|
|
265
|
+
# @param amount [Float] How much to increase lightness (default 0.03)
|
|
266
|
+
# @return [OKLCH] A lighter version of this color
|
|
267
|
+
#
|
|
268
|
+
# @example Make a color perceptually 5% brighter
|
|
269
|
+
# color = OKLCH.new(lightness: 0.60, chroma: 0.15, hue: 240)
|
|
270
|
+
# lighter = color.lighten(0.05)
|
|
271
|
+
def lighten(amount = 0.03)
|
|
272
|
+
current_lightness = @lightness.to_ratio
|
|
273
|
+
new_lightness = clamp01(current_lightness + amount)
|
|
274
|
+
self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Create a darker version by decreasing lightness.
|
|
278
|
+
#
|
|
279
|
+
# @param amount [Float] How much to decrease lightness (default 0.03)
|
|
280
|
+
# @return [OKLCH] A darker version of this color
|
|
281
|
+
#
|
|
282
|
+
# @example Make a color perceptually 5% darker
|
|
283
|
+
# color = OKLCH.new(lightness: 0.70, chroma: 0.15, hue: 120)
|
|
284
|
+
# darker = color.darken(0.05)
|
|
285
|
+
def darken(amount = 0.03)
|
|
286
|
+
current_lightness = @lightness.to_ratio
|
|
287
|
+
new_lightness = clamp01(current_lightness - amount)
|
|
288
|
+
self.class.new(lightness: new_lightness, chroma: @chroma.value, hue: @hue.value)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Create a more saturated version by increasing chroma.
|
|
292
|
+
#
|
|
293
|
+
# @param amount [Float] How much to increase chroma (default 0.02)
|
|
294
|
+
# @return [OKLCH] A more saturated version of this color
|
|
295
|
+
#
|
|
296
|
+
# @example Make a color more vivid
|
|
297
|
+
# muted = OKLCH.new(lightness: 0.65, chroma: 0.10, hue: 180)
|
|
298
|
+
# vivid = muted.saturate(0.05)
|
|
299
|
+
def saturate(amount = 0.02)
|
|
300
|
+
new_chroma = [@chroma.value + amount, 0.4].min
|
|
301
|
+
self.class.new(lightness: @lightness.to_ratio, chroma: new_chroma, hue: @hue.value)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Create a less saturated version by decreasing chroma.
|
|
305
|
+
#
|
|
306
|
+
# @param amount [Float] How much to decrease chroma (default 0.02)
|
|
307
|
+
# @return [OKLCH] A less saturated version of this color
|
|
308
|
+
#
|
|
309
|
+
# @example Make a color more muted
|
|
310
|
+
# vivid = OKLCH.new(lightness: 0.65, chroma: 0.20, hue: 30)
|
|
311
|
+
# muted = vivid.desaturate(0.10)
|
|
312
|
+
def desaturate(amount = 0.02)
|
|
313
|
+
new_chroma = [@chroma.value - amount, 0.0].max
|
|
314
|
+
self.class.new(lightness: @lightness.to_ratio, chroma: new_chroma, hue: @hue.value)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Rotate the hue by a specified amount.
|
|
318
|
+
#
|
|
319
|
+
# @param amount [Numeric] Degrees to rotate the hue (default 10)
|
|
320
|
+
# @return [OKLCH] A color with the hue rotated
|
|
321
|
+
#
|
|
322
|
+
# @example Shift to an analogous color
|
|
323
|
+
# blue = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
324
|
+
# blue_green = blue.rotate(30)
|
|
325
|
+
def rotate(amount = 10)
|
|
326
|
+
new_hue = (@hue.value + amount) % 360
|
|
327
|
+
self.class.new(lightness: @lightness.to_ratio, chroma: @chroma.value, hue: new_hue)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Blend this color with another color in OKLCH space.
|
|
331
|
+
#
|
|
332
|
+
# Blending in OKLCH produces perceptually smooth color transitions. Uses
|
|
333
|
+
# shortest-arc hue interpolation to avoid going the long way around the color wheel.
|
|
334
|
+
#
|
|
335
|
+
# @param other [Color] The color to blend with (automatically converted to OKLCH)
|
|
336
|
+
# @param amount [Float] How much of the other color to mix in (0.0-1.0)
|
|
337
|
+
# @return [OKLCH] A new OKLCH color that is a blend of the two
|
|
338
|
+
#
|
|
339
|
+
# @example Create a perceptually smooth gradient
|
|
340
|
+
# blue = OKLCH.new(lightness: 0.60, chroma: 0.15, hue: 240)
|
|
341
|
+
# red = OKLCH.new(lightness: 0.60, chroma: 0.15, hue: 30)
|
|
342
|
+
# purple = blue.blend(red, 0.5)
|
|
343
|
+
def blend(other, amount = 0.5)
|
|
344
|
+
amount = amount.to_f.clamp(0, 1)
|
|
345
|
+
other_oklch = other.respond_to?(:to_oklch) ? other.to_oklch : other
|
|
346
|
+
|
|
347
|
+
# Blend in OKLCH space with shortest-arc hue interpolation
|
|
348
|
+
dh = (((other_oklch.hue.value - @hue.value + 540) % 360) - 180)
|
|
349
|
+
new_hue = (@hue.value + dh * amount) % 360
|
|
350
|
+
new_lightness = lightness + (other_oklch.lightness - lightness) * amount
|
|
351
|
+
new_chroma = @chroma.value + (other_oklch.chroma.value - @chroma.value) * amount
|
|
352
|
+
|
|
353
|
+
self.class.new(lightness: new_lightness, chroma: new_chroma, hue: new_hue)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Convert to CSS oklch() function format.
|
|
357
|
+
#
|
|
358
|
+
# @return [String] CSS string like "oklch(0.6500 0.1500 240.00)"
|
|
359
|
+
#
|
|
360
|
+
# @example
|
|
361
|
+
# color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
362
|
+
# color.to_css_oklch
|
|
363
|
+
# # => "oklch(0.6500 0.1500 240.00)"
|
|
364
|
+
def to_css_oklch
|
|
365
|
+
format("oklch(%.4f %.4f %.2f)", @lightness.to_ratio, @chroma.value, @hue.value)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Convert to CSS custom properties (variables).
|
|
369
|
+
#
|
|
370
|
+
# Outputs the color as CSS variables for lightness, chroma, and hue that can
|
|
371
|
+
# be manipulated or mixed at runtime in CSS.
|
|
372
|
+
#
|
|
373
|
+
# @return [String] CSS variables like "--ul:0.6500;--uc:0.1500;--uh:240.00;"
|
|
374
|
+
#
|
|
375
|
+
# @example
|
|
376
|
+
# color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
377
|
+
# color.to_css_vars
|
|
378
|
+
# # => "--ul:0.6500;--uc:0.1500;--uh:240.00;"
|
|
379
|
+
def to_css_vars
|
|
380
|
+
format("--ul:%.4f;--uc:%.4f;--uh:%.2f;", @lightness.to_ratio, @chroma.value, @hue.value)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Create a CSS color-mix() expression.
|
|
384
|
+
#
|
|
385
|
+
# Generates a CSS color-mix expression that blends this color with a background
|
|
386
|
+
# color (typically a CSS variable).
|
|
387
|
+
#
|
|
388
|
+
# @param bg_css [String] The background color CSS (default "var(--bg)")
|
|
389
|
+
# @param a_pct [Integer] Percentage of this color (default 72)
|
|
390
|
+
# @param bg_pct [Integer] Percentage of background color (default 28)
|
|
391
|
+
# @return [String] CSS color-mix expression
|
|
392
|
+
#
|
|
393
|
+
# @example Mix with background
|
|
394
|
+
# color = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
395
|
+
# color.to_css_color_mix
|
|
396
|
+
# # => "color-mix(in oklch, oklch(0.6500 0.1500 240.00) 72%, var(--bg) 28%)"
|
|
397
|
+
#
|
|
398
|
+
# @example Custom background and percentages
|
|
399
|
+
# color = Unmagic::Color::OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
|
|
400
|
+
# color.to_css_color_mix("#FFFFFF", a_pct: 50, bg_pct: 50)
|
|
401
|
+
# # => "color-mix(in oklch, oklch(0.6500 0.1500 240.00) 50%, #FFFFFF 50%)"
|
|
402
|
+
def to_css_color_mix(bg_css = "var(--bg)", a_pct: 72, bg_pct: 28)
|
|
403
|
+
"color-mix(in oklch, #{to_css_oklch} #{a_pct}%, #{bg_css} #{bg_pct}%)"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Check if two OKLCH colors are equal.
|
|
407
|
+
#
|
|
408
|
+
# @param other [Object] The object to compare with
|
|
409
|
+
# @return [Boolean] true if both colors have the same OKLCH values
|
|
410
|
+
def ==(other)
|
|
411
|
+
other.is_a?(Unmagic::Color::OKLCH) &&
|
|
412
|
+
lightness == other.lightness &&
|
|
413
|
+
chroma == other.chroma &&
|
|
414
|
+
hue == other.hue
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Convert to string representation.
|
|
418
|
+
#
|
|
419
|
+
# Returns the CSS oklch() function format.
|
|
420
|
+
#
|
|
421
|
+
# @return [String] CSS string like "oklch(0.6500 0.1500 240.00)"
|
|
422
|
+
def to_s
|
|
423
|
+
to_css_oklch
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
private
|
|
427
|
+
|
|
428
|
+
def clamp01(x)
|
|
429
|
+
x.clamp(0.0, 1.0)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
class RGB
|
|
6
|
+
# Hexadecimal color parsing utilities.
|
|
7
|
+
#
|
|
8
|
+
# Hex colors are a compact way to write {RGB} values using hexadecimal (base-16)
|
|
9
|
+
# notation. Each pair of hex digits represents one color component (`0-255`).
|
|
10
|
+
#
|
|
11
|
+
# ## Understanding Hexadecimal
|
|
12
|
+
#
|
|
13
|
+
# Hexadecimal uses 16 digits: `0-9` and `A-F`
|
|
14
|
+
#
|
|
15
|
+
# - `0 = 0`, `9 = 9`, `A = 10`, `B = 11`, ... `F = 15`
|
|
16
|
+
# - Two hex digits can represent `0-255` (`16 × 16 = 256` values)
|
|
17
|
+
# - `FF = 255`, `00 = 0`, `80 = 128`, etc.
|
|
18
|
+
#
|
|
19
|
+
# ## Hex Color Format
|
|
20
|
+
#
|
|
21
|
+
# - **Full format**: `#RRGGBB` (6 digits, 2 per component)
|
|
22
|
+
# - **Short format**: `#RGB` (3 digits, each digit is doubled)
|
|
23
|
+
# - **Hash optional**: Can be written with or without the `#` prefix
|
|
24
|
+
#
|
|
25
|
+
# ## Examples
|
|
26
|
+
#
|
|
27
|
+
# - `#FF0000` = Red (`255, 0, 0`)
|
|
28
|
+
# - `#00FF00` = Green (`0, 255, 0`)
|
|
29
|
+
# - `#0000FF` = Blue (`0, 0, 255`)
|
|
30
|
+
# - `#F00` = `#FF0000` (short form)
|
|
31
|
+
# - `#ABC` = `#AABBCC` (short form expanded)
|
|
32
|
+
class Hex
|
|
33
|
+
# Error raised when parsing hex color strings fails
|
|
34
|
+
class ParseError < Color::Error; end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
# Check if a string is a valid hex color.
|
|
38
|
+
#
|
|
39
|
+
# @param value [String] The string to validate
|
|
40
|
+
# @return [Boolean] true if valid hex color, false otherwise
|
|
41
|
+
#
|
|
42
|
+
# @example
|
|
43
|
+
# Unmagic::Color::RGB::Hex.valid?("#FF5733")
|
|
44
|
+
# # => true
|
|
45
|
+
#
|
|
46
|
+
# Unmagic::Color::RGB::Hex.valid?("F73")
|
|
47
|
+
# # => true
|
|
48
|
+
#
|
|
49
|
+
# Unmagic::Color::RGB::Hex.valid?("GGGGGG")
|
|
50
|
+
# # => false
|
|
51
|
+
def valid?(value)
|
|
52
|
+
parse(value)
|
|
53
|
+
true
|
|
54
|
+
rescue ParseError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Parse a hexadecimal color string.
|
|
59
|
+
#
|
|
60
|
+
# Accepts both full (6-digit) and short (3-digit) hex formats,
|
|
61
|
+
# with or without the # prefix.
|
|
62
|
+
#
|
|
63
|
+
# @param input [String] The hex color string to parse
|
|
64
|
+
# @return [RGB] The parsed RGB color
|
|
65
|
+
# @raise [ParseError] If the input is not a valid hex color
|
|
66
|
+
#
|
|
67
|
+
# @example Full format with hash
|
|
68
|
+
# Unmagic::Color::RGB::Hex.parse("#FF8800")
|
|
69
|
+
#
|
|
70
|
+
# @example Short format without hash
|
|
71
|
+
# Unmagic::Color::RGB::Hex.parse("F80")
|
|
72
|
+
def parse(input)
|
|
73
|
+
raise ParseError, "Input must be a string" unless input.is_a?(::String)
|
|
74
|
+
|
|
75
|
+
# Clean up the input
|
|
76
|
+
hex = input.strip.gsub(/^#/, "")
|
|
77
|
+
|
|
78
|
+
# Check for valid length (3 or 6 characters)
|
|
79
|
+
unless hex.length == 3 || hex.length == 6
|
|
80
|
+
raise ParseError, "Invalid number of characters (got #{hex.length}, expected 3 or 6)"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check if all characters are valid hex digits
|
|
84
|
+
unless hex.match?(/\A[0-9A-Fa-f]+\z/)
|
|
85
|
+
invalid_chars = hex.chars.reject { |c| c.match?(/[0-9A-Fa-f]/) }
|
|
86
|
+
raise ParseError, "Invalid hex characters: #{invalid_chars.join(", ")}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Handle 3-character hex codes
|
|
90
|
+
if hex.length == 3
|
|
91
|
+
hex = hex.chars.map { |c| c * 2 }.join
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
r = hex[0..1].to_i(16)
|
|
95
|
+
g = hex[2..3].to_i(16)
|
|
96
|
+
b = hex[4..5].to_i(16)
|
|
97
|
+
|
|
98
|
+
RGB.new(red: r, green: g, blue: b)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
class RGB < Color
|
|
6
|
+
# Named colors support for RGB colors.
|
|
7
|
+
#
|
|
8
|
+
# Provides access to standard named colors (like "red", "blue", "goldenrod")
|
|
9
|
+
# and converts them to RGB color instances.
|
|
10
|
+
#
|
|
11
|
+
# @example Parse a named color
|
|
12
|
+
# Unmagic::Color::RGB::Named.parse("goldenrod")
|
|
13
|
+
# #=> RGB instance for #daa520
|
|
14
|
+
#
|
|
15
|
+
# @example Case-insensitive and whitespace-tolerant
|
|
16
|
+
# Unmagic::Color::RGB::Named.parse("Golden Rod")
|
|
17
|
+
# #=> RGB instance for #daa520
|
|
18
|
+
# Unmagic::Color::RGB::Named.parse("GOLDENROD")
|
|
19
|
+
# #=> RGB instance for #daa520
|
|
20
|
+
#
|
|
21
|
+
# @example Check if a name is valid
|
|
22
|
+
# Unmagic::Color::RGB::Named.valid?("goldenrod")
|
|
23
|
+
# #=> true
|
|
24
|
+
# Unmagic::Color::RGB::Named.valid?("notacolor")
|
|
25
|
+
# #=> false
|
|
26
|
+
class Named
|
|
27
|
+
# Error raised when a color name is not found
|
|
28
|
+
class ParseError < Color::Error; end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# Parse a named color and return its RGB representation.
|
|
32
|
+
#
|
|
33
|
+
# @param name [String] The color name to parse (case-insensitive)
|
|
34
|
+
# @return [RGB] The RGB color instance
|
|
35
|
+
# @raise [ParseError] If the color name is not recognized
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# Unmagic::Color::RGB::Named.parse("goldenrod")
|
|
39
|
+
# #=> RGB instance for #daa520
|
|
40
|
+
def parse(name)
|
|
41
|
+
normalized_name = normalize_name(name)
|
|
42
|
+
hex_value = data[normalized_name]
|
|
43
|
+
|
|
44
|
+
raise ParseError, "Unknown color name: #{name.inspect}" unless hex_value
|
|
45
|
+
|
|
46
|
+
Hex.parse(hex_value)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if a color name is valid.
|
|
50
|
+
#
|
|
51
|
+
# @param name [String] The color name to check
|
|
52
|
+
# @return [Boolean] true if the name exists
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# Unmagic::Color::RGB::Named.valid?("goldenrod")
|
|
56
|
+
# #=> true
|
|
57
|
+
def valid?(name)
|
|
58
|
+
normalized_name = normalize_name(name)
|
|
59
|
+
data.key?(normalized_name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get all available color names.
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<String>] Array of all color names
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# Unmagic::Color::RGB::Named.all.take(5)
|
|
68
|
+
# #=> ["black", "silver", "gray", "white", "maroon"]
|
|
69
|
+
def all
|
|
70
|
+
data.keys
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Normalize a color name for lookup.
|
|
76
|
+
# Converts to lowercase and removes all whitespace.
|
|
77
|
+
#
|
|
78
|
+
# @param name [String] The color name to normalize
|
|
79
|
+
# @return [String] The normalized name
|
|
80
|
+
def normalize_name(name)
|
|
81
|
+
name.to_s.downcase.gsub(/\s+/, "")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Load color data from the rgb.txt file.
|
|
85
|
+
# Uses memoization to only load the file once.
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash] Hash of color names to hex values
|
|
88
|
+
def data
|
|
89
|
+
@data ||= load_data
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Load and parse the rgb.txt file.
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash] Hash of color names to hex values
|
|
95
|
+
def load_data
|
|
96
|
+
data_file = File.join(__dir__, "..", "..", "..", "..", "data", "rgb.txt")
|
|
97
|
+
colors = {}
|
|
98
|
+
|
|
99
|
+
File.readlines(data_file).each do |line|
|
|
100
|
+
name, hex = line.strip.split("\t")
|
|
101
|
+
next if name.nil? || hex.nil?
|
|
102
|
+
|
|
103
|
+
colors[name] = hex
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
colors
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|