unmagic-color 0.2.1 → 0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 276b2cc6192a6fea7e69f4893c9989434e8101c3e9c6a88b56996eeeeb910262
4
- data.tar.gz: cd421ccce7640490b140a74a62640ee9dbbb6060f815d770b14ac12e4b1ff991
3
+ metadata.gz: 8e8aee8c90b0b846c5352c1a8c2f9638fdbceaa830a8b240805c093326bd0a5e
4
+ data.tar.gz: febb8693169a657fd975d9d60e69232a291aa1f1a8a3cd05372f279a008cbe9d
5
5
  SHA512:
6
- metadata.gz: 2da573372028a3d08793d01bcd9245a24fc88934085b081712934e4606a2d60ff03d8c8f6f94b06abb2fb6817e1b5b0acdf8ac2fd332e1d9f9b2923a90221d5c
7
- data.tar.gz: 8a17575b7f89b7f9c29d613208972242bd851e42b25659a7533a4c6ed156ce14f9f9396148aa6e171ad5a8e0159395d8e9f9e28b881fa000bbdeb6492cc40388
6
+ metadata.gz: d6df173651d743f7e4696d22334afaedcac56160e4ec579e2a00c2d06b80802724fd2f3e6da36cd05970994aa14e4ed0466fe33bf1f188b98f291385ed351a0a
7
+ data.tar.gz: 46b517537f8393d29c26567daebbfd83913d8d7cd56766f7caa7d35b048161e9c8977865bd9d36ca75669d8296179290fcf2d38c4e1c58780740707158e3ee68
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3] - 2026-05-17
11
+
12
+ ### Added
13
+
14
+ #### Perceptual Tonal Scales
15
+ - `scale(steps:, lightness:, chroma:, hue_shift:, anchor:, gamut:)` - generates perceptually-uniform tonal palettes in OKLCH, controlling lightness, chroma, and hue independently
16
+ - An 11-step scale anchored in the middle produces a Tailwind-style 50–950 palette (`scale` itself knows nothing about Tailwind)
17
+ - Tapered default chroma curve that rises into the mid-tones and eases off near white and black, avoiding muddy lights and neon darks
18
+ - `anchor:` pins the source color to an exact step and builds the rest of the scale around it
19
+ - `lightness:`, `chroma:`, and `hue_shift:` accept ranges, arrays, or procs for full control over each curve
20
+ - `gamut:` gamut-maps results into sRGB by default, or returns raw wide-gamut OKLCH with `:none`
21
+
22
+ #### OKLCH Gamut Mapping
23
+ - `OKLCH#in_gamut?` - reports whether a color is displayable within the sRGB gamut
24
+ - `OKLCH#clamp_to_gamut` - pulls an out-of-gamut color into sRGB by reducing chroma while holding lightness and hue fixed (perceptually correct, unlike per-channel RGB clipping)
25
+ - `OKLCH#to_oklab` - converts to the cartesian OKLab color space
26
+
27
+ ### Fixed
28
+ - Rainbow gradient rendering in the WebAssembly demo
29
+
30
+ ## [0.2.2] - 2026-01-05
31
+
32
+ ### Added
33
+ - `Gradient::Bitmap#to_ansi` method to render gradients as colored blocks in terminal
34
+
10
35
  ## [0.2.1] - 2026-01-05
11
36
 
12
37
  ### Added
@@ -81,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
81
106
  - HSL color progressions for palette generation
82
107
  - Multiple hash functions for string-to-color derivation
83
108
 
109
+ [0.2.2]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.2.2
84
110
  [0.2.1]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.2.1
85
111
  [0.2.0]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.2.0
86
112
  [0.1.0]: https://github.com/unreasonable-magic/unmagic-color/releases/tag/v0.1.0
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  A comprehensive Ruby color manipulation library with support for RGB, HSL, and OKLCH color spaces. Parse, convert, and manipulate colors with an intuitive API.
6
6
 
7
+ We have a fancy-pants WASM based demo here: https://unreasonable-magic.github.io/unmagic-color/
8
+
7
9
  ## Installation
8
10
 
9
11
  Add this line to your application's Gemfile:
@@ -17,38 +17,23 @@ module Unmagic
17
17
  link = highlighter.link("https://github.com/unreasonable-magic/unmagic-color")
18
18
 
19
19
  code = highlighter.highlight(<<~RUBY)
20
- # Parse colors
20
+ # Parse a color (hex, rgb, hsl, oklch, ansi, css named, x11)
21
21
  parse("#ff5733")
22
- rgb(255, 87, 51)
22
+ parse("goldenrod")
23
+
24
+ # Manually create colors
25
+ rgb(255, 87, 51, alpha: percentage(50))
23
26
  hsl(9, 100, 60)
24
27
  oklch(0.65, 0.22, 30)
25
- parse("rebeccapurple")
26
-
27
- # Manipulate colors
28
- color = parse("#ff5733")
29
- color.lighten(0.1)
30
- color.darken(0.1)
31
- color.saturate(0.1)
32
- color.desaturate(0.1)
33
- color.rotate(30)
34
28
 
35
- # Convert between formats
36
- color.to_rgb
37
- color.to_hsl
38
- color.to_oklch
39
- color.to_hex
40
- color.to_css_oklch
29
+ # Show a color card
30
+ show("#ff5733")
41
31
 
42
- # Create gradients
43
- gradient(:linear, ["#FF0000", "#0000FF"]).rasterize(width: 10).pixels[0].map(&:to_hex)
32
+ # Make a rainbow
33
+ puts gradient(:linear, %w[red orange yellow green blue purple], direction: "to right").rasterize(width: 60).to_ansi
44
34
 
45
- # Helpers
46
- rgb(255, 87, 51)
47
- hsl(9, 100, 60)
48
- oklch(0.65, 0.22, 30)
49
- parse("#ff5733")
50
- gradient(:linear, ["#FF0000", "#0000FF"])
51
- percentage(50)
35
+ # Generate Tailwind color scales
36
+ puts gradient(:linear, parse("#3b82f6").scale(steps: 11, anchor: 5), direction: "to right").rasterize(width: 60).to_ansi
52
37
  RUBY
53
38
 
54
39
  "#{link}\n\n#{code}"
@@ -85,6 +85,27 @@ module Unmagic
85
85
  def to_a
86
86
  @pixels.flatten
87
87
  end
88
+
89
+ # Convert to ANSI escape codes for terminal display.
90
+ #
91
+ # Renders each pixel as a colored character using 24-bit true color
92
+ # ANSI codes. Each row is joined and rows are separated by newlines.
93
+ #
94
+ # @param fill [String] Character to use for each pixel (default: "█")
95
+ # @return [String] ANSI-colored string representation
96
+ #
97
+ # @example Render a rainbow gradient
98
+ # gradient = Gradient.linear(%w[red yellow green blue])
99
+ # bitmap = gradient.rasterize(width: 40)
100
+ # puts bitmap.to_ansi
101
+ #
102
+ # @example Use custom fill character
103
+ # puts bitmap.to_ansi(fill: "▀")
104
+ def to_ansi(fill: "█")
105
+ @pixels.map do |row|
106
+ row.map { |color| "\e[#{color.to_ansi}m#{fill}\e[0m" }.join
107
+ end.join("\n")
108
+ end
88
109
  end
89
110
  end
90
111
  end
@@ -5,8 +5,9 @@ module Unmagic
5
5
  # Color harmony and variations module.
6
6
  #
7
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.
8
+ # color theory principles. Harmony and the {#shades}/{#tints}/{#tones}
9
+ # variations are computed in HSL color space for accurate hue-based
10
+ # relationships; {#scale} is computed in OKLCH for perceptual uniformity.
10
11
  #
11
12
  # Included in the base Color class, making these methods available to
12
13
  # RGB, HSL, and OKLCH color spaces via inheritance.
@@ -40,6 +41,25 @@ module Unmagic
40
41
  # blue.shades(steps: 3) # => [darker1, darker2, darker3]
41
42
  # blue.tints(steps: 3) # => [lighter1, lighter2, lighter3]
42
43
  module Harmony
44
+ # Default lightness curve for {#scale}: 11 control points describing the
45
+ # *shape* of the light→dark sweep (1.0 = lightest, 0.0 = darkest), sampled
46
+ # with linear interpolation. Derived from the average of several
47
+ # hand-tuned production color ramps.
48
+ SCALE_LIGHTNESS_SHAPE = [
49
+ 1.0, 0.948, 0.876, 0.769, 0.622, 0.514, 0.416, 0.323, 0.234, 0.168, 0.0,
50
+ ].freeze
51
+
52
+ # Default chroma curve for {#scale}: 11 control points (peak normalized
53
+ # to 1.0). Chroma rises into the mid-tones and tapers toward both ends —
54
+ # a constant chroma reads as muddy near white and neon near black, and
55
+ # the sRGB gamut itself narrows at the extremes.
56
+ SCALE_CHROMA_CURVE = [
57
+ 0.055, 0.131, 0.247, 0.447, 0.727, 0.920, 1.0, 0.931, 0.767, 0.586, 0.374,
58
+ ].freeze
59
+
60
+ # Default lightness endpoints `[lightest, darkest]` for {#scale}.
61
+ SCALE_LIGHTNESS_DEFAULT = [0.971, 0.270].freeze
62
+
43
63
  # Returns the complementary color (180° opposite on the color wheel).
44
64
  #
45
65
  # Complementary colors create high contrast and visual tension.
@@ -260,8 +280,174 @@ module Unmagic
260
280
  end
261
281
  end
262
282
 
283
+ # Generate a perceptually-uniform tonal scale from this color.
284
+ #
285
+ # Produces an ordered sequence of colors from light to dark, computed in
286
+ # the OKLCH color space so each step sits an even perceptual distance
287
+ # from the last. Unlike {#shades}/{#tints} (which blend toward black or
288
+ # white in HSL), `scale` controls lightness, chroma, and hue
289
+ # independently and gamut-maps every result.
290
+ #
291
+ # The chroma curve is the important part: chroma rises into the
292
+ # mid-tones and tapers toward both ends, because a constant chroma reads
293
+ # as muddy near white and as neon near black, and because the sRGB gamut
294
+ # itself narrows at the extremes.
295
+ #
296
+ # This is a general-purpose primitive. An 11-step scale anchored in the
297
+ # middle happens to produce a Tailwind-style 50–950 palette, but the
298
+ # method itself knows nothing about Tailwind.
299
+ #
300
+ # @param steps [Integer] Number of colors to generate (must be at least 2)
301
+ # @param lightness [Range, Array<Numeric>, Proc, nil] OKLCH lightness
302
+ # control. A `Range` gives the light/dark endpoints; an `Array` gives
303
+ # an explicit value per step; a `Proc` is called with `(t, index)`;
304
+ # `nil` uses the default curve.
305
+ # @param chroma [Symbol, Array<Numeric>, Proc] OKLCH chroma control.
306
+ # `:peak` (default) applies the tapered curve scaled to this color's
307
+ # chroma; `:flat` holds chroma constant; an `Array` or `Proc` supplies
308
+ # values directly.
309
+ # @param hue_shift [Range, Numeric, Proc, nil] Hue drift in degrees
310
+ # across the scale. `nil` (default) keeps the hue constant.
311
+ # @param anchor [Integer, nil] Index at which this color is placed
312
+ # exactly — its lightness, chroma, and hue are preserved at that step
313
+ # and the rest of the scale is built around it.
314
+ # @param gamut [Symbol] `:srgb` (default) gamut-maps every result into
315
+ # sRGB so {RGB#to_hex} is trustworthy; `:none` returns the raw OKLCH
316
+ # colors, which may be wider than sRGB.
317
+ # @return [Array<OKLCH>] `steps` colors in OKLCH, ordered light to dark
318
+ # @raise [ArgumentError] If steps < 2, anchor is out of range, or gamut
319
+ # is not :srgb or :none
320
+ #
321
+ # @example An 11-step palette anchored on the base color
322
+ # base = Unmagic::Color.parse("oklch(0.62 0.21 260)")
323
+ # palette = base.scale(steps: 11, anchor: 5)
324
+ #
325
+ # @example Label an 11-step scale as Tailwind stops
326
+ # stops = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
327
+ # tailwind = stops.zip(base.scale(steps: 11, anchor: 5)).to_h
328
+ def scale(steps: 11, lightness: nil, chroma: :peak, hue_shift: nil, anchor: nil, gamut: :srgb)
329
+ raise ArgumentError, "steps must be at least 2" if steps < 2
330
+ if anchor && !(0...steps).cover?(anchor)
331
+ raise ArgumentError, "anchor must be between 0 and #{steps - 1}"
332
+ end
333
+ unless [:srgb, :none].include?(gamut)
334
+ raise ArgumentError, "gamut must be :srgb or :none"
335
+ end
336
+
337
+ base = to_oklch
338
+ positions = Array.new(steps) { |i| i.fdiv(steps - 1) }
339
+
340
+ lightnesses = scale_lightness(positions, lightness, anchor, base.lightness)
341
+ chromas = scale_chroma(positions, chroma, anchor, base.chroma.value)
342
+ hues = scale_hue(positions, hue_shift, base.hue.value)
343
+
344
+ Array.new(steps) do |i|
345
+ color = OKLCH.new(
346
+ lightness: lightnesses[i].clamp(0.0, 1.0),
347
+ chroma: [chromas[i], 0.0].max,
348
+ hue: hues[i],
349
+ alpha: base.alpha.value,
350
+ )
351
+ gamut == :none ? color : color.clamp_to_gamut
352
+ end
353
+ end
354
+
263
355
  private
264
356
 
357
+ # Compute the OKLCH lightness for each step of a {#scale}.
358
+ #
359
+ # @return [Array<Float>] One lightness ratio (0.0-1.0) per step
360
+ def scale_lightness(positions, lightness, anchor, base_lightness)
361
+ case lightness
362
+ when Array
363
+ positions.each_index.map { |i| lightness[i] || lightness.last }
364
+ when Proc
365
+ positions.each_index.map { |i| lightness.call(positions[i], i) }
366
+ else
367
+ light, dark = if lightness.is_a?(Range)
368
+ [lightness.begin.to_f, lightness.end.to_f]
369
+ else
370
+ SCALE_LIGHTNESS_DEFAULT
371
+ end
372
+ shape = positions.map { |t| interpolate_curve(SCALE_LIGHTNESS_SHAPE, t) }
373
+ return shape.map { |s| dark + (light - dark) * s } unless anchor
374
+
375
+ warp_lightness_to_anchor(shape, shape[anchor], base_lightness, light, dark)
376
+ end
377
+ end
378
+
379
+ # Warp the lightness shape so it passes exactly through the base color's
380
+ # lightness at the anchor, keeping the curve monotonic and pinned to the
381
+ # light/dark endpoints. The light and dark halves are scaled separately.
382
+ #
383
+ # @return [Array<Float>] One lightness ratio per step
384
+ def warp_lightness_to_anchor(shape, anchor_shape, base_lightness, light, dark)
385
+ shape.map do |s|
386
+ if s >= anchor_shape
387
+ next base_lightness if anchor_shape >= 1.0
388
+
389
+ base_lightness + (light - base_lightness) * (s - anchor_shape) / (1.0 - anchor_shape)
390
+ else
391
+ next base_lightness unless anchor_shape.positive?
392
+
393
+ dark + (base_lightness - dark) * s / anchor_shape
394
+ end
395
+ end
396
+ end
397
+
398
+ # Compute the OKLCH chroma for each step of a {#scale}.
399
+ #
400
+ # @return [Array<Float>] One chroma value per step
401
+ def scale_chroma(positions, chroma, anchor, base_chroma)
402
+ case chroma
403
+ when :flat
404
+ positions.map { base_chroma }
405
+ when Array
406
+ positions.each_index.map { |i| chroma[i] || chroma.last }
407
+ when Proc
408
+ positions.each_index.map { |i| chroma.call(positions[i], i) }
409
+ when :peak, nil
410
+ curve = positions.map { |t| interpolate_curve(SCALE_CHROMA_CURVE, t) }
411
+ reference = anchor ? curve[anchor] : curve.max
412
+ factor = reference.zero? ? 0.0 : base_chroma / reference
413
+ curve.map { |c| c * factor }
414
+ else
415
+ raise ArgumentError, "chroma must be :peak, :flat, an Array, or a Proc"
416
+ end
417
+ end
418
+
419
+ # Compute the OKLCH hue for each step of a {#scale}.
420
+ #
421
+ # @return [Array<Float>] One hue value (degrees) per step
422
+ def scale_hue(positions, hue_shift, base_hue)
423
+ case hue_shift
424
+ when nil
425
+ positions.map { base_hue }
426
+ when Range
427
+ from = hue_shift.begin.to_f
428
+ to = hue_shift.end.to_f
429
+ positions.map { |t| base_hue + from + (to - from) * t }
430
+ when Numeric
431
+ positions.map { |t| base_hue + (hue_shift * t) }
432
+ when Proc
433
+ positions.each_index.map { |i| base_hue + hue_shift.call(positions[i], i) }
434
+ else
435
+ raise ArgumentError, "hue_shift must be nil, a Range, Numeric, or a Proc"
436
+ end
437
+ end
438
+
439
+ # Sample an evenly-spaced control-point array at position `t` (0.0-1.0),
440
+ # linearly interpolating between the two nearest points.
441
+ #
442
+ # @return [Float] The interpolated value
443
+ def interpolate_curve(control, t)
444
+ t = t.clamp(0.0, 1.0)
445
+ x = t * (control.length - 1)
446
+ low = x.floor
447
+ high = [low + 1, control.length - 1].min
448
+ control[low] + (control[high] - control[low]) * (x - low)
449
+ end
450
+
265
451
  # Rotate the hue by the specified degrees and return a new color.
266
452
  #
267
453
  # @param degrees [Numeric] Degrees to rotate (positive = clockwise)
@@ -275,31 +275,82 @@ module Unmagic
275
275
  to_rgb.to_hsl
276
276
  end
277
277
 
278
+ # Convert to the OKLab color space as `[lightness, a, b]`.
279
+ #
280
+ # OKLab is the cartesian form of OKLCH: `a` is the green–red axis and
281
+ # `b` is the blue–yellow axis. The Euclidean distance between two OKLab
282
+ # triples is a perceptual color difference (ΔE).
283
+ #
284
+ # @return [Array<Float>] The `[L, a, b]` OKLab coordinates
285
+ #
286
+ # @example
287
+ # color = OKLCH.new(lightness: 0.65, chroma: 0.15, hue: 240)
288
+ # l, a, b = color.to_oklab
289
+ def to_oklab
290
+ h_rad = @hue.value * Math::PI / 180.0
291
+ c = @chroma.value
292
+ [@lightness.to_ratio, c * Math.cos(h_rad), c * Math.sin(h_rad)]
293
+ end
294
+
278
295
  # Convert to RGB color space.
279
296
  #
280
- # @return [RGB] The color in RGB color space (approximation)
281
- # @note This is currently a simplified approximation. A proper OKLCH to sRGB
282
- # conversion requires more complex color science calculations.
297
+ # Uses the full OKLab color-science pipeline: OKLCH OKLab → linear
298
+ # sRGB gamma-encoded sRGB. Colors outside the sRGB gamut are clipped
299
+ # per channel; call {#clamp_to_gamut} first for a perceptual fit.
300
+ #
301
+ # @return [RGB] The color in RGB color space
283
302
  def to_rgb
284
- # For now, convert via approximation - would need proper OKLCH->sRGB conversion
285
- # This is a simplified placeholder that approximates RGB from OKLCH
286
303
  require_relative "rgb"
287
304
 
288
- # Simple approximation: use lightness and chroma to estimate RGB
289
- base = (@lightness.to_ratio * 255).round
290
- saturation = (@chroma * 255).value
305
+ r, g, b = to_linear_srgb.map { |channel| linear_to_srgb(channel) }
306
+ Unmagic::Color::RGB.new(
307
+ red: (r * 255).round.clamp(0, 255),
308
+ green: (g * 255).round.clamp(0, 255),
309
+ blue: (b * 255).round.clamp(0, 255),
310
+ alpha: @alpha,
311
+ )
312
+ end
291
313
 
292
- # Convert hue to RGB ratios (very simplified)
293
- h_rad = (@hue * Math::PI / 180).value
294
- r_offset = (Math.cos(h_rad) * saturation).round
295
- g_offset = (Math.cos(h_rad + 2 * Math::PI / 3) * saturation).round
296
- b_offset = (Math.cos(h_rad + 4 * Math::PI / 3) * saturation).round
314
+ # Whether this color can be displayed in the sRGB gamut.
315
+ #
316
+ # OKLCH can describe colors no sRGB monitor can show — typically
317
+ # high-chroma colors near very light or very dark lightness, where the
318
+ # sRGB gamut narrows. This returns false for those.
319
+ #
320
+ # @param epsilon [Float] Tolerance for channels sitting just past 0 or 1
321
+ # @return [Boolean] true if every linear sRGB channel falls within [0, 1]
322
+ def in_gamut?(epsilon = 1e-4)
323
+ to_linear_srgb.all? { |channel| channel >= -epsilon && channel <= 1.0 + epsilon }
324
+ end
297
325
 
298
- r = (base + r_offset).clamp(0, 255)
299
- g = (base + g_offset).clamp(0, 255)
300
- b = (base + b_offset).clamp(0, 255)
326
+ # Pull this color into the sRGB gamut by reducing chroma.
327
+ #
328
+ # Holds lightness and hue fixed and binary-searches chroma downward
329
+ # until the color is displayable. An already-displayable color is
330
+ # returned unchanged. This is the perceptually correct way to map an
331
+ # out-of-gamut OKLCH color — clipping RGB channels instead shifts hue.
332
+ #
333
+ # @return [OKLCH] An in-gamut color with the same lightness and hue
334
+ #
335
+ # @example
336
+ # vivid = OKLCH.new(lightness: 0.95, chroma: 0.3, hue: 140)
337
+ # vivid.clamp_to_gamut # => a displayable, lower-chroma green
338
+ def clamp_to_gamut
339
+ return self if in_gamut?
340
+
341
+ lo = 0.0
342
+ hi = @chroma.value
343
+ 20.times do
344
+ mid = (lo + hi) / 2.0
345
+ candidate = self.class.new(lightness: @lightness.to_ratio, chroma: mid, hue: @hue.value, alpha: @alpha.value)
346
+ if candidate.in_gamut?
347
+ lo = mid
348
+ else
349
+ hi = mid
350
+ end
351
+ end
301
352
 
302
- Unmagic::Color::RGB.new(red: r, green: g, blue: b, alpha: @alpha)
353
+ self.class.new(lightness: @lightness.to_ratio, chroma: lo, hue: @hue.value, alpha: @alpha.value)
303
354
  end
304
355
 
305
356
  # Convert to hex string.
@@ -540,6 +591,37 @@ module Unmagic
540
591
  def clamp01(x)
541
592
  x.clamp(0.0, 1.0)
542
593
  end
594
+
595
+ # OKLCH → OKLab → linear sRGB. Channels may fall outside [0, 1] when the
596
+ # color is out of gamut; callers clip or gamut-map as needed.
597
+ #
598
+ # @return [Array<Float>] Linear (non-gamma) sRGB channels
599
+ def to_linear_srgb
600
+ l, a, b = to_oklab
601
+
602
+ l_ = ((l + (0.3963377774 * a) + (0.2158037573 * b))**3)
603
+ m_ = ((l - (0.1055613458 * a) - (0.0638541728 * b))**3)
604
+ s_ = ((l - (0.0894841775 * a) - (1.2914855480 * b))**3)
605
+
606
+ [
607
+ (4.0767416621 * l_) - (3.3077115913 * m_) + (0.2309699292 * s_),
608
+ (-1.2684380046 * l_) + (2.6097574011 * m_) - (0.3413193965 * s_),
609
+ (-0.0041960863 * l_) - (0.7034186147 * m_) + (1.7076147010 * s_),
610
+ ]
611
+ end
612
+
613
+ # Gamma-encode a single linear sRGB channel, clipping to [0, 1].
614
+ #
615
+ # @param channel [Float] A linear sRGB channel value
616
+ # @return [Float] The gamma-encoded sRGB channel (0.0-1.0)
617
+ def linear_to_srgb(channel)
618
+ channel = channel.clamp(0.0, 1.0)
619
+ if channel <= 0.0031308
620
+ 12.92 * channel
621
+ else
622
+ (1.055 * (channel**(1 / 2.4))) - 0.055
623
+ end
624
+ end
543
625
  end
544
626
  end
545
627
  end
@@ -379,21 +379,31 @@ module Unmagic
379
379
 
380
380
  # Convert to OKLCH color space.
381
381
  #
382
- # Converts this RGB color to OKLCH (Lightness, Chroma, Hue).
382
+ # Converts this RGB color to OKLCH (Lightness, Chroma, Hue) using the
383
+ # full OKLab color-science pipeline: gamma-decoded sRGB → linear sRGB →
384
+ # OKLab → OKLCH.
383
385
  #
384
386
  # @return [OKLCH] The color in OKLCH color space
385
- # @note This is currently a simplified approximation.
386
387
  def to_oklch
387
- # For now, simple approximation based on RGB -> HSL -> OKLCH
388
- # This is a simplified placeholder
389
388
  require_relative "oklch"
390
- # Convert lightness roughly from RGB luminance
391
- l = luminance
392
- # Approximate chroma from saturation and lightness
393
- hsl = to_hsl
394
- c = hsl.saturation.to_ratio * 0.2 * (1 - (l - 0.5).abs * 2)
395
- h = hsl.hue
396
- Unmagic::Color::OKLCH.new(lightness: l, chroma: c, hue: h, alpha: @alpha)
389
+
390
+ r = srgb_to_linear(@red.value / 255.0)
391
+ g = srgb_to_linear(@green.value / 255.0)
392
+ b = srgb_to_linear(@blue.value / 255.0)
393
+
394
+ l = Math.cbrt((0.4122214708 * r) + (0.5363325363 * g) + (0.0514459929 * b))
395
+ m = Math.cbrt((0.2119034982 * r) + (0.6806995451 * g) + (0.1073969566 * b))
396
+ s = Math.cbrt((0.0883024619 * r) + (0.2817188376 * g) + (0.6299787005 * b))
397
+
398
+ ok_l = (0.2104542553 * l) + (0.7936177850 * m) - (0.0040720468 * s)
399
+ ok_a = (1.9779984951 * l) - (2.4285922050 * m) + (0.4505937099 * s)
400
+ ok_b = (0.0259040371 * l) + (0.7827717662 * m) - (0.8086757660 * s)
401
+
402
+ chroma = Math.sqrt((ok_a * ok_a) + (ok_b * ok_b))
403
+ hue = Math.atan2(ok_b, ok_a) * 180.0 / Math::PI
404
+ hue += 360.0 if hue.negative?
405
+
406
+ Unmagic::Color::OKLCH.new(lightness: ok_l, chroma: chroma, hue: hue, alpha: @alpha)
397
407
  end
398
408
 
399
409
  # Calculate the relative luminance.
@@ -549,6 +559,18 @@ module Unmagic
549
559
 
550
560
  private
551
561
 
562
+ # Gamma-decode a single sRGB channel to linear light.
563
+ #
564
+ # @param channel [Float] An sRGB channel value (0.0-1.0)
565
+ # @return [Float] The linear sRGB channel value
566
+ def srgb_to_linear(channel)
567
+ if channel <= 0.04045
568
+ channel / 12.92
569
+ else
570
+ ((channel + 0.055) / 1.055)**2.4
571
+ end
572
+ end
573
+
552
574
  # Convert to ANSI true color format (24-bit RGB).
553
575
  #
554
576
  # @param layer [Symbol] Foreground or background layer
@@ -3,6 +3,6 @@
3
3
  module Unmagic
4
4
  class Color
5
5
  # Current version of the Unmagic::Color gem
6
- VERSION = "0.2.1"
6
+ VERSION = "0.3"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unmagic-color
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Pitt
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-05-17 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: Parse, convert, and manipulate colors with support for RGB, Hex, HSL
13
14
  formats, contrast calculations, and color blending
@@ -55,6 +56,7 @@ metadata:
55
56
  homepage_uri: https://github.com/unreasonable-magic/unmagic-color
56
57
  source_code_uri: https://github.com/unreasonable-magic/unmagic-color
57
58
  changelog_uri: https://github.com/unreasonable-magic/unmagic-color/CHANGELOG.md
59
+ post_install_message:
58
60
  rdoc_options: []
59
61
  require_paths:
60
62
  - lib
@@ -69,7 +71,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
71
  - !ruby/object:Gem::Version
70
72
  version: '0'
71
73
  requirements: []
72
- rubygems_version: 4.0.3
74
+ rubygems_version: 3.5.22
75
+ signing_key:
73
76
  specification_version: 4
74
77
  summary: Comprehensive color manipulation library
75
78
  test_files: []