tincture 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e3102199850ecef6f4276a6316c9766db75a90aa362db1b3bb06bcd8e476ec40
4
+ data.tar.gz: 149c2ce8a9cdfa7a3c35a223d7bcc9404b38711aa1bc4f8205617d6e71106466
5
+ SHA512:
6
+ metadata.gz: 31cf7ada2b2bae78a4bb0d6052fc2efcf438c3e21c4f885e5e237c3cec8327cce22174e49b9c42fa87b7861cac7b79f084e15dd6fb07449996d759ac3336af96
7
+ data.tar.gz: 0726bfdc5a709161d2b6adb92749a9e69d7acebb38cd9a916e75d20392d05500cc6c455d1f5651042fa166b456dcd1430f1e3e10ea6e3cde1d7fb9679a104fd4
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-10-21)
4
+
5
+ - `Tincture::Color` parses CSS hex, named, `rgb()`, `hsl()`, `hwb()`, `oklab()` and `oklch()` colours, and converts between them and HSV.
6
+ - WCAG 2 contrast: `contrast_ratio`, `readable_on?`, `contrast_level`, `contrast_color` and `nearest_readable_on`.
7
+ - Colour difference with ΔE OK and CIEDE2000.
8
+ - An optional Active Model `:color` attribute type and `color:` validator, loaded automatically in Rails.
9
+ - UK spelling aliases for every name.
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Andy Croll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # Tincture
2
+
3
+ [![CI](https://github.com/andycroll/tincture/actions/workflows/ci.yml/badge.svg)](https://github.com/andycroll/tincture/actions/workflows/ci.yml)
4
+
5
+ Colour values for plain Ruby, with an optional Active Model attribute type
6
+
7
+ - Parse CSS hex, named, `rgb()`, `hsl()`, `hwb()`, `oklab()` and `oklch()` colours into one immutable value
8
+ - Convert between them, plus HSV
9
+ - Check WCAG 2 contrast, pick black or white text, and find readable brand colours
10
+ - Measure colour difference with ΔE OK or CIEDE2000
11
+ - Cast model attributes with `attribute :accent, :color`, loaded automatically in Rails
12
+
13
+ ## Installation
14
+
15
+ Add this line to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "tincture"
19
+ ```
20
+
21
+ The colour code is plain Ruby with no dependencies. In a Rails app, the attribute type and validator load when the app boots. Elsewhere, require them yourself:
22
+
23
+ ```ruby
24
+ require "tincture/active_model"
25
+ ```
26
+
27
+ ## Colors
28
+
29
+ Parse any CSS colour, legacy or modern syntax
30
+
31
+ ```ruby
32
+ Tincture::Color.parse("#ff8800")
33
+ Tincture::Color.parse("rebeccapurple")
34
+ Tincture::Color.parse("rgb(255 136 0 / 50%)")
35
+ Tincture::Color.parse("hsl(32deg 100% 50%)")
36
+ Tincture::Color.parse("hwb(32 0% 0%)")
37
+ Tincture::Color.parse("oklch(0.7442 0.1812 56.46)")
38
+ ```
39
+
40
+ Anything else raises `Tincture::InvalidColor`. Colours outside sRGB clip to the nearest channel values.
41
+
42
+ Or build one
43
+
44
+ ```ruby
45
+ Tincture::Color.rgb(255, 136, 0)
46
+ Tincture::Color.hsl(32, 100, 50, 0.5)
47
+ Tincture::Color.hsv(32, 100, 100)
48
+ Tincture::Color.oklch(0.7442, 0.1812, 56.46)
49
+ ```
50
+
51
+ Convert it
52
+
53
+ ```ruby
54
+ color.hex # "#ff8800"
55
+ color.rgb # [255, 136, 0]
56
+ color.hsl # [32.0, 100.0, 50.0]
57
+ color.hsv # [32.0, 100.0, 100.0]
58
+ color.css_rgb # "rgb(255 136 0)"
59
+ color.css_hsl # "hsl(32 100% 50%)"
60
+ color.css_hwb # "hwb(32 0% 0%)"
61
+ color.css_oklch # "oklch(0.7442 0.1812 56.46)"
62
+ ```
63
+
64
+ Every output comes from the 8-bit channels, so hex round-trips through any of them.
65
+
66
+ ## Contrast
67
+
68
+ Pick black or white text for a background colour
69
+
70
+ ```ruby
71
+ background.contrast_color # WCAG 2, the default
72
+ background.contrast_color(method: :apca) # APCA, needs tincture-apca
73
+ background.contrast_color(method: :oklab)
74
+ ```
75
+
76
+ Each returns `Tincture::Color::BLACK` or `WHITE`, like CSS `contrast-color()`. `contrast_colour` is the UK alias. WCAG 2 always passes AA for body text, but picks black on bright blues and reds where most people would use white. APCA judges by how contrast looks. Oklab compares lightness against APCA's black/white crossover: it agrees with APCA on about 98% of colours and has no licence restriction. APCA and Oklab picks fail WCAG 2 AA on about 29% of colours, so keep the default where WCAG 2 audits apply.
77
+
78
+ Check a pairing against WCAG 2
79
+
80
+ ```ruby
81
+ text.contrast_ratio("#ffffff") # 1 to 21
82
+ text.readable_on?("#ffffff") # AA, normal text
83
+ text.readable_on?("#ffffff", level: :aaa, large: true)
84
+ text.contrast_level("#ffffff") # :aaa, :aa, :aa_large or nil
85
+ color.luminance # 0 to 1
86
+ ```
87
+
88
+ Find the closest readable version of a brand colour
89
+
90
+ ```ruby
91
+ Tincture::Color.parse("#ff8800").nearest_readable_on("white").hex # "#b66000"
92
+ ```
93
+
94
+ It moves the colour toward black or white in Oklab, keeping its hue, and returns `nil` when no colour can reach the level.
95
+
96
+ Or pick the best of your own text colours, by WCAG 2 or APCA
97
+
98
+ ```ruby
99
+ Tincture::Color.parse("#ffd166").contrast_color(among: ["#1f2937", "white"]).hex # "#1f2937"
100
+ ```
101
+
102
+ > [!NOTE]
103
+ > Custom candidates can fail WCAG 2 AA, even with the default `:wcag` method: it returns the best candidate, not a passing one. On `#0a7cff`, `among: ["#1f2937", "white"]` returns white at 3.93:1. Check the result with `readable_on?`, or adjust it with `nearest_readable_on`.
104
+
105
+ ## APCA
106
+
107
+ APCA contrast lives in a separate gem, `tincture-apca`, because its licence only allows web content in support of WCAG. Add it and `require "tincture/apca"` to get `apca_contrast` and `contrast_color(method: :apca)`.
108
+
109
+ ## Difference
110
+
111
+ Measure how different two colours look
112
+
113
+ ```ruby
114
+ color.difference("#ff8000") # ΔE OK, under about 0.02 is hard to see
115
+ color.difference("#ff8000", formula: :ciede2000) # ΔE00, about 1 is just visible
116
+ ```
117
+
118
+ CIEDE2000 matches all 34 of Sharma's reference pairs.
119
+
120
+ ## Attributes
121
+
122
+ Declare the type on any model with `ActiveModel::Attributes` or Active Record. Outside Rails, `require "tincture/active_model"` first.
123
+
124
+ ```ruby
125
+ class Branding < ApplicationRecord
126
+ attribute :accent_color, :color
127
+ end
128
+
129
+ branding.accent_color = "rgb(255 136 0)"
130
+ branding.accent_color.hex # "#ff8800"
131
+ ```
132
+
133
+ Blank or unparseable input casts to `nil`. Another spelling of the same colour does not mark the attribute as changed, and queries accept any CSS form. Colours render as hex in JSON.
134
+
135
+ Store bare hex for columns that hold `ff8800`
136
+
137
+ ```ruby
138
+ attribute :accent_color_hex, :color, prefix: false
139
+ ```
140
+
141
+ ## Validation
142
+
143
+ Reject input the type could not parse (Active Record only)
144
+
145
+ ```ruby
146
+ validates :accent_color, color: true
147
+ ```
148
+
149
+ Blank input passes, so add `presence: true` to require a colour. Do not pass `allow_nil` or `allow_blank`: unparseable input casts to `nil`, so they skip it.
150
+
151
+ ## UK spelling
152
+
153
+ Every name has a UK alias
154
+
155
+ ```ruby
156
+ Tincture::Colour.parse("#ff8800")
157
+ attribute :accent_colour, :colour
158
+ validates :accent_colour, colour: true # "is not a valid colour"
159
+ background.contrast_colour
160
+ ```
161
+
162
+ `Tincture::InvalidColour` is the same class as `Tincture::InvalidColor`.
163
+
164
+ ## Development
165
+
166
+ ```sh
167
+ bundle install
168
+ bundle exec rake # tests and RuboCop
169
+ bundle exec rake rdoc # API docs in doc/
170
+ ```
171
+
172
+ To release, set the version in `lib/tincture/version.rb`, date the changelog entry, commit, and push a `vX.Y.Z` tag. GitHub Actions runs the checks and publishes the gem to rubygems.org through trusted publishing.
173
+
174
+ Comments are Markdown. Method types are inline RBS (`#: (String) -> Color`), which RDoc shows on each method.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The Active Model integration: the `:color` and `:colour` attribute types and
4
+ # the `color:` and `colour:` validators. Rails apps load it when they boot;
5
+ # elsewhere, `require "tincture/active_model"`.
6
+
7
+ require 'active_model'
8
+ require 'tincture'
9
+ require_relative 'type'
10
+ require_relative 'color_validator'
11
+
12
+ ActiveModel::Type.register(:color, Tincture::Type)
13
+ ActiveModel::Type.register(:colour, Tincture::Type)
14
+
15
+ ActiveSupport.on_load(:active_record) do
16
+ ActiveRecord::Type.register(:color, Tincture::Type)
17
+ ActiveRecord::Type.register(:colour, Tincture::Type)
18
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ class Color
5
+ # How different two colours look. The formulas live in
6
+ # Tincture::Difference. Mixed into Color.
7
+ module Comparison
8
+ D65_WHITE = [0.95047, 1.0, 1.08883].freeze # :nodoc:
9
+
10
+ # How different two colours look (ΔE). `formula: :oklab` (the default)
11
+ # is ΔE OK, where under about 0.02 is hard to see. `formula:
12
+ # :ciede2000` is ΔE00, where about 1 is just visible. See
13
+ # Tincture::Difference.
14
+ #
15
+ # Tincture::Color.parse("red").difference("white") # => 0.4526…
16
+ #
17
+ #: (String | Color other, ?formula: :oklab | :ciede2000) -> Float
18
+ def difference(other, formula: :oklab)
19
+ other = Color.parse(other)
20
+
21
+ case formula
22
+ when :oklab then Difference.oklab(oklab_components, other.oklab_components)
23
+ when :ciede2000 then Difference.ciede2000(lab_components, other.lab_components)
24
+ else raise ArgumentError, "unknown formula #{formula.inspect}, use :oklab or :ciede2000"
25
+ end
26
+ end
27
+
28
+ protected
29
+
30
+ # CIELAB against the D65 white point, as sRGB tools usually report it.
31
+ def lab_components # :nodoc:
32
+ red, green, blue = linear_rgb
33
+
34
+ x, y, z = [
35
+ ((0.4124564 * red) + (0.3575761 * green) + (0.1804375 * blue)) / D65_WHITE[0],
36
+ ((0.2126729 * red) + (0.7151522 * green) + (0.0721750 * blue)) / D65_WHITE[1],
37
+ ((0.0193339 * red) + (0.1191920 * green) + (0.9503041 * blue)) / D65_WHITE[2]
38
+ ].map { it > (6 / 29.0)**3 ? Math.cbrt(it) : (it / (3 * ((6 / 29.0)**2))) + (4 / 29.0) }
39
+
40
+ [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]
41
+ end
42
+ end
43
+
44
+ include Comparison
45
+ end
46
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ class Color
5
+ # WCAG 2 minimum contrast ratios.
6
+ WCAG_RATIOS = { aa: { normal: 4.5, large: 3.0 }, aaa: { normal: 7.0, large: 4.5 } }.freeze
7
+
8
+ # Oklab lightness of the grey where black and white text have equal APCA
9
+ # contrast. #contrast_color with `method: :oklab` picks black above it.
10
+ OKLAB_CONTRAST_THRESHOLD = 0.716
11
+
12
+ # The dark default for #contrast_color.
13
+ BLACK = NAMED.fetch('black')
14
+
15
+ # The light default for #contrast_color.
16
+ WHITE = NAMED.fetch('white')
17
+
18
+ # WCAG 2 contrast, and picking text colours that read on a background.
19
+ # Mixed into Color.
20
+ module Contrast
21
+ # Added to Color itself.
22
+ module ClassMethods
23
+ # Scores for #contrast_color methods other than `:oklab`, keyed by
24
+ # name. Each takes a candidate and the background and returns a
25
+ # score, higher is better. Extensions such as tincture-apca add to it.
26
+ #
27
+ #: () -> Hash[Symbol, ^(Color, Color) -> Numeric]
28
+ def contrast_scorers
29
+ @contrast_scorers ||= { wcag: ->(text, background) { text.contrast_ratio(background) } }
30
+ end
31
+ end
32
+
33
+ # WCAG 2 relative luminance, from 0 (black) to 1 (white). Ignores
34
+ # alpha.
35
+ #
36
+ #: () -> Float
37
+ def luminance
38
+ red, green, blue = linear_rgb
39
+ (0.2126 * red) + (0.7152 * green) + (0.0722 * blue)
40
+ end
41
+
42
+ # WCAG 2 contrast ratio against another colour, from 1 to 21. WCAG AA
43
+ # asks for 4.5 for body text. Ignores alpha.
44
+ #
45
+ # Tincture::Color.parse("#767676").contrast_ratio("white") # => 4.54…
46
+ #
47
+ #: (String | Color other) -> Float
48
+ def contrast_ratio(other)
49
+ darker, lighter = [luminance, Color.parse(other).luminance].minmax
50
+ (lighter + 0.05) / (darker + 0.05)
51
+ end
52
+
53
+ # The colour for text set on this colour, like CSS `contrast-color()`:
54
+ # BLACK or WHITE by default, or the best of the colours given in
55
+ # `among:`. Ties go to the earlier candidate.
56
+ #
57
+ # Custom `among:` candidates can return a colour that fails WCAG 2 AA,
58
+ # even with `method: :wcag`: it picks the best candidate, not a
59
+ # passing one. On `#0a7cff`, `among: ["#1f2937", "white"]` returns
60
+ # white at 3.93:1. Check the result with #readable_on?, or use
61
+ # #nearest_readable_on to adjust it.
62
+ #
63
+ # - `method: :wcag` (the default) picks the higher WCAG 2 contrast
64
+ # ratio. Between black and white that always passes AA. It picks
65
+ # black on bright blues, where most people would use white.
66
+ # - `method: :apca` picks the larger APCA contrast. It needs the
67
+ # tincture-apca gem, whose licence restricts it to web content.
68
+ # - `method: :oklab` picks black when Oklab lightness is above
69
+ # OKLAB_CONTRAST_THRESHOLD, so it only takes black and white. It
70
+ # needs no extra gem, and agrees with APCA on about 98% of sRGB
71
+ # colours, where WCAG 2 agrees on about 71%. Its pick fails WCAG 2
72
+ # AA on about 29% of colours, so audits flag it.
73
+ #
74
+ # blue = Tincture::Color.parse("#0a7cff")
75
+ # blue.contrast_color # => BLACK
76
+ # blue.contrast_color(method: :oklab) # => WHITE
77
+ #
78
+ # amber = Tincture::Color.parse("#ffd166")
79
+ # amber.contrast_color(among: ["#1f2937", "white"]).hex # => "#1f2937"
80
+ #
81
+ #: (?among: Array[String | Color], ?method: Symbol) -> Color
82
+ def contrast_color(among: [BLACK, WHITE], method: :wcag)
83
+ candidates = among.map { Color.parse(it) }
84
+
85
+ if method == :oklab
86
+ oklab_contrast_color(candidates)
87
+ else
88
+ candidates.max_by { contrast_scorer(method).call(it, self) }
89
+ end
90
+ end
91
+
92
+ alias contrast_colour contrast_color
93
+
94
+ # Whether text in this colour on `background` meets a WCAG 2 level:
95
+ # `level: :aa` (the default) or `:aaa`, with `large: true` for text at
96
+ # least 18pt, or 14pt bold.
97
+ #
98
+ #: (String | Color background, ?level: :aa | :aaa, ?large: bool) -> bool
99
+ def readable_on?(background, level: :aa, large: false)
100
+ contrast_ratio(background) >= WCAG_RATIOS.fetch(level).fetch(large ? :large : :normal)
101
+ end
102
+
103
+ # The highest WCAG 2 level text in this colour reaches on
104
+ # `background`: `:aaa`, `:aa` (also AAA for large text), `:aa_large`,
105
+ # or `nil`.
106
+ #
107
+ #: (String | Color background) -> (:aaa | :aa | :aa_large | nil)
108
+ def contrast_level(background)
109
+ ratio = contrast_ratio(background)
110
+
111
+ if ratio >= WCAG_RATIOS[:aaa][:normal] then :aaa
112
+ elsif ratio >= WCAG_RATIOS[:aa][:normal] then :aa
113
+ elsif ratio >= WCAG_RATIOS[:aa][:large] then :aa_large
114
+ end
115
+ end
116
+
117
+ # The closest colour to this one that is #readable_on? `background`:
118
+ # this colour if it already is, otherwise this colour moved in Oklab
119
+ # toward black or white, keeping its hue. `nil` when even black or
120
+ # white cannot reach the level.
121
+ #
122
+ # Tincture::Color.parse("#ff8800").nearest_readable_on("white").hex # => "#b66000"
123
+ #
124
+ #: (String | Color background, ?level: :aa | :aaa, ?large: bool) -> Color?
125
+ def nearest_readable_on(background, level: :aa, large: false)
126
+ background = Color.parse(background)
127
+ extreme = background.contrast_color
128
+
129
+ if readable_on?(background, level:, large:)
130
+ self
131
+ elsif extreme.readable_on?(background, level:, large:)
132
+ nearest_mix_toward(extreme) { it.readable_on?(background, level:, large:) }
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ # The smallest mix toward `extreme` that passes the block. Assumes the
139
+ # block fails here and passes at `extreme`.
140
+ def nearest_mix_toward(extreme)
141
+ amount = (0.0..1.0).bsearch { |candidate| yield mixed_toward(extreme, candidate) }
142
+ mixed_toward(extreme, amount)
143
+ end
144
+
145
+ def contrast_scorer(method)
146
+ Color.contrast_scorers.fetch(method) do
147
+ hint = method == :apca ? ' (add the tincture-apca gem and require "tincture/apca")' : ''
148
+ raise ArgumentError, "unknown method #{method.inspect}#{hint}"
149
+ end
150
+ end
151
+
152
+ def oklab_contrast_color(candidates)
153
+ if candidates.sort_by(&:luminance) == [BLACK, WHITE]
154
+ oklab_components.first > OKLAB_CONTRAST_THRESHOLD ? BLACK : WHITE
155
+ else
156
+ raise ArgumentError, 'method: :oklab picks between black and white only'
157
+ end
158
+ end
159
+ end
160
+
161
+ include Contrast
162
+ extend Contrast::ClassMethods
163
+ end
164
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ class Color
5
+ # HSL and its relatives HWB and HSV, which share its hue angle. Mixed into
6
+ # Color.
7
+ module Hsl
8
+ # Added to Color itself.
9
+ module ClassMethods
10
+ # Builds a colour from a hue in degrees, and saturation and lightness
11
+ # percentages (0–100).
12
+ #
13
+ #: (Numeric hue, Numeric saturation, Numeric lightness, ?Numeric alpha) -> Color
14
+ def hsl(hue, saturation, lightness, alpha = 1.0)
15
+ channels = hsl_channels(hue, saturation / 100.0, lightness / 100.0)
16
+ rgb(*channels.map { it * 255 }, alpha)
17
+ end
18
+
19
+ # Builds a colour from a hue in degrees, and whiteness and blackness
20
+ # percentages (0–100). Whiteness and blackness that sum past 100%
21
+ # give a grey.
22
+ #
23
+ #: (Numeric hue, Numeric whiteness, Numeric blackness, ?Numeric alpha) -> Color
24
+ def hwb(hue, whiteness, blackness, alpha = 1.0)
25
+ whiteness = whiteness.clamp(0, 100) / 100.0
26
+ blackness = blackness.clamp(0, 100) / 100.0
27
+
28
+ channels =
29
+ if whiteness + blackness >= 1
30
+ [whiteness / (whiteness + blackness)] * 3
31
+ else
32
+ hsl_channels(hue, 1, 0.5).map { (it * (1 - whiteness - blackness)) + whiteness }
33
+ end
34
+
35
+ rgb(*channels.map { it * 255 }, alpha)
36
+ end
37
+
38
+ # Builds a colour from a hue in degrees, and saturation and value
39
+ # percentages (0–100), as colour pickers use. Not a CSS format.
40
+ #
41
+ #: (Numeric hue, Numeric saturation, Numeric value, ?Numeric alpha) -> Color
42
+ def hsv(hue, saturation, value, alpha = 1.0)
43
+ hwb(hue, (100 - saturation) * value / 100.0, 100 - value, alpha)
44
+ end
45
+
46
+ private
47
+
48
+ def hsl_channels(hue, saturation, lightness)
49
+ saturation = saturation.clamp(0, 1)
50
+ lightness = lightness.clamp(0, 1)
51
+ amount = saturation * [lightness, 1 - lightness].min
52
+
53
+ [0, 8, 4].map do |offset|
54
+ k = (offset + (hue / 30.0)) % 12
55
+ lightness - (amount * [-1, [k - 3, 9 - k, 1].min].max)
56
+ end
57
+ end
58
+ end
59
+
60
+ # Hue in degrees, saturation and lightness as percentages, to one
61
+ # decimal place.
62
+ #
63
+ #: () -> [Float, Float, Float]
64
+ def hsl
65
+ max, min = channel_fractions.minmax.reverse
66
+ lightness = (max + min) / 2
67
+ saturation = max == min ? 0 : (max - lightness) / [lightness, 1 - lightness].min
68
+
69
+ [hue, rounded(saturation * 100, 1), rounded(lightness * 100, 1)]
70
+ end
71
+
72
+ # Hue in degrees, whiteness and blackness as percentages, to one
73
+ # decimal place.
74
+ #
75
+ #: () -> [Float, Float, Float]
76
+ def hwb
77
+ max, min = channel_fractions.minmax.reverse
78
+ [hue, rounded(min * 100, 1), rounded((1 - max) * 100, 1)]
79
+ end
80
+
81
+ # Hue in degrees, saturation and value as percentages, to one decimal
82
+ # place.
83
+ #
84
+ #: () -> [Float, Float, Float]
85
+ def hsv
86
+ max, min = channel_fractions.minmax.reverse
87
+ saturation = max.zero? ? 0 : (max - min) / max
88
+
89
+ [hue, rounded(saturation * 100, 1), rounded(max * 100, 1)]
90
+ end
91
+
92
+ # `"hsl(32 100% 50%)"` or `"hsl(32 100% 50% / 0.5)"`.
93
+ #
94
+ #: () -> String
95
+ def css_hsl = css_function('hsl', hsl, percentages: true)
96
+
97
+ # `"hwb(32 0% 0%)"` or `"hwb(32 0% 0% / 0.5)"`.
98
+ #
99
+ #: () -> String
100
+ def css_hwb = css_function('hwb', hwb, percentages: true)
101
+
102
+ private
103
+
104
+ def hue
105
+ red, green, blue = channel_fractions
106
+ max, min = channel_fractions.minmax.reverse
107
+ delta = max - min
108
+
109
+ sector =
110
+ if delta.zero? then 0
111
+ elsif max == red then ((green - blue) / delta) % 6
112
+ elsif max == green then ((blue - red) / delta) + 2
113
+ else ((red - green) / delta) + 4
114
+ end
115
+
116
+ rounded(sector * 60, 1) % 360
117
+ end
118
+ end
119
+
120
+ include Hsl
121
+ extend Hsl::ClassMethods
122
+ end
123
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ class Color
5
+ # Oklab and its cylindrical form Oklch, where equal steps look equally
6
+ # different. Mixed into Color.
7
+ module Oklab
8
+ # Added to Color itself.
9
+ module ClassMethods
10
+ # Builds a colour from Oklab lightness (0–1) and its `a` (green–red)
11
+ # and `b` (blue–yellow) axes.
12
+ #
13
+ #: (Numeric lightness, Numeric green_red, Numeric blue_yellow, ?Numeric alpha) -> Color
14
+ def oklab(lightness, green_red, blue_yellow, alpha = 1.0)
15
+ lightness = lightness.clamp(0, 1)
16
+
17
+ l, m, s = [
18
+ lightness + (0.3963377774 * green_red) + (0.2158037573 * blue_yellow),
19
+ lightness - (0.1055613458 * green_red) - (0.0638541728 * blue_yellow),
20
+ lightness - (0.0894841775 * green_red) - (1.2914855480 * blue_yellow)
21
+ ].map { it**3 }
22
+
23
+ linear = [
24
+ (4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s),
25
+ (-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s),
26
+ (-0.0041960863 * l) - (0.7034186147 * m) + (1.7076147010 * s)
27
+ ]
28
+
29
+ rgb(*linear.map { gamma_encoded(it) * 255 }, alpha)
30
+ end
31
+
32
+ # Builds a colour from Oklab lightness (0–1), chroma and a hue in
33
+ # degrees.
34
+ #
35
+ #: (Numeric lightness, Numeric chroma, Numeric hue, ?Numeric alpha) -> Color
36
+ def oklch(lightness, chroma, hue, alpha = 1.0)
37
+ chroma = [chroma, 0].max
38
+ radians = hue * Math::PI / 180
39
+ oklab(lightness, chroma * Math.cos(radians), chroma * Math.sin(radians), alpha)
40
+ end
41
+
42
+ private
43
+
44
+ def gamma_encoded(linear)
45
+ linear = linear.clamp(0.0, 1.0)
46
+ linear <= 0.0031308 ? linear * 12.92 : (1.055 * (linear**(1 / 2.4))) - 0.055
47
+ end
48
+ end
49
+
50
+ # Oklab lightness (0–1) and its `a` and `b` axes, to four decimal
51
+ # places.
52
+ #
53
+ #: () -> [Float, Float, Float]
54
+ def oklab = oklab_components.map { rounded(it, 4) }
55
+
56
+ # Oklab lightness (0–1), chroma to four decimal places, and hue in
57
+ # degrees to two.
58
+ #
59
+ #: () -> [Float, Float, Float]
60
+ def oklch
61
+ lightness, a, b = oklab_components
62
+ chroma = rounded(Math.hypot(a, b), 4)
63
+ angle = chroma.zero? ? 0 : Math.atan2(b, a) * 180 / Math::PI
64
+
65
+ [rounded(lightness, 4), chroma, rounded(angle, 2) % 360]
66
+ end
67
+
68
+ # `"oklab(0.628 0.2249 0.1258)"`, with `/ alpha` when translucent.
69
+ #
70
+ #: () -> String
71
+ def css_oklab = css_function('oklab', oklab)
72
+
73
+ # `"oklch(0.628 0.2577 29.23)"`, with `/ alpha` when translucent.
74
+ #
75
+ #: () -> String
76
+ def css_oklch = css_function('oklch', oklch)
77
+
78
+ protected
79
+
80
+ def oklab_components # :nodoc:
81
+ red, green, blue = linear_rgb
82
+
83
+ l, m, s = [
84
+ (0.4122214708 * red) + (0.5363325363 * green) + (0.0514459929 * blue),
85
+ (0.2119034982 * red) + (0.6806995451 * green) + (0.1073969566 * blue),
86
+ (0.0883024619 * red) + (0.2817188376 * green) + (0.6299787005 * blue)
87
+ ].map { Math.cbrt(it) }
88
+
89
+ [
90
+ (0.2104542553 * l) + (0.7936177850 * m) - (0.0040720468 * s),
91
+ (1.9779984951 * l) - (2.4285922050 * m) + (0.4505937099 * s),
92
+ (0.0259040371 * l) + (0.7827717662 * m) - (0.8086757660 * s)
93
+ ]
94
+ end
95
+
96
+ private
97
+
98
+ def mixed_toward(other, amount)
99
+ lightness, a, b = oklab_components.zip(other.oklab_components).map do |mine, theirs|
100
+ mine + ((theirs - mine) * amount)
101
+ end
102
+ Color.oklab(lightness, a, b, alpha)
103
+ end
104
+ end
105
+
106
+ include Oklab
107
+ extend Oklab::ClassMethods
108
+ end
109
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Tincture
6
+ class Color
7
+ # Reads CSS colour strings: hex, named colours and the `rgb()`, `hsl()`,
8
+ # `hwb()`, `oklab()` and `oklch()` functions. Extends Color, so its
9
+ # methods are called on the class: `Tincture::Color.parse`.
10
+ module Parsing
11
+ HEX = /
12
+ \A
13
+ \#? # optional leading hash
14
+ (?<digits>
15
+ \h{3,4} # rgb or rgba shorthand
16
+ | \h{6} # rrggbb
17
+ | \h{8} # rrggbbaa
18
+ )
19
+ \z
20
+ /x # :nodoc:
21
+
22
+ FUNCTION = /
23
+ \A
24
+ (?<name>[a-z]+) # rgb, hsla, oklch…
25
+ \(
26
+ (?<arguments>[^)]*) # everything between the parentheses
27
+ \)
28
+ \z
29
+ /xi # :nodoc:
30
+
31
+ TOKEN = /
32
+ \A
33
+ (?<number>
34
+ [+-]? # optional sign
35
+ (?:
36
+ \d+(?:\.\d*)? # integer, optional decimals: 1, 1., 1.5
37
+ | \.\d+ # bare decimals: .5
38
+ )
39
+ (?:e[+-]?\d+)? # optional exponent: 1e3
40
+ )
41
+ (?<unit>%|deg|grad|rad|turn)?
42
+ \z
43
+ /xi # :nodoc:
44
+
45
+ CHANNEL = { nil => 1, '%' => 255r / 100 }.freeze # :nodoc:
46
+ FRACTION = { nil => 1, '%' => 1r / 100 }.freeze # :nodoc:
47
+ PERCENTAGE = { nil => 1, '%' => 1 }.freeze # :nodoc:
48
+ HUE = { nil => 1, 'deg' => 1, 'grad' => 0.9, 'rad' => 180 / Math::PI, 'turn' => 360 }.freeze # :nodoc:
49
+ OK_AXIS = { nil => 1, '%' => 4r / 1000 }.freeze # :nodoc:
50
+
51
+ UNITS = {
52
+ 'rgb' => [CHANNEL, CHANNEL, CHANNEL, FRACTION],
53
+ 'hsl' => [HUE, PERCENTAGE, PERCENTAGE, FRACTION],
54
+ 'hwb' => [HUE, PERCENTAGE, PERCENTAGE, FRACTION],
55
+ 'oklab' => [FRACTION, OK_AXIS, OK_AXIS, FRACTION],
56
+ 'oklch' => [FRACTION, OK_AXIS, HUE, FRACTION]
57
+ }.freeze # :nodoc:
58
+
59
+ # Only rgb() and hsl() take the comma syntax and the rgba()/hsla() names.
60
+ LEGACY = { 'rgb' => 'rgb', 'rgba' => 'rgb', 'hsl' => 'hsl', 'hsla' => 'hsl' }.freeze # :nodoc:
61
+
62
+ # Parses a CSS colour string. Returns a Color unchanged.
63
+ #
64
+ # Tincture::Color.parse("#ff8800")
65
+ # Tincture::Color.parse("rebeccapurple")
66
+ # Tincture::Color.parse("oklch(0.7 0.18 50 / 50%)")
67
+ #
68
+ # Raises InvalidColor for anything else.
69
+ #
70
+ #: (String | Color input) -> Color
71
+ def parse(input)
72
+ return input if input.is_a?(Color)
73
+
74
+ string = input.to_s.strip
75
+ color = hex_color(string) || NAMED[string.downcase] || function_color(string)
76
+ color || raise(InvalidColor, "#{input.inspect} is not a CSS color")
77
+ end
78
+
79
+ private
80
+
81
+ def hex_color(string)
82
+ digits = HEX.match(string)&.[](:digits)
83
+ return unless digits
84
+
85
+ digits = digits.chars.map { it * 2 }.join if digits.length <= 4
86
+ red, green, blue, alpha = digits.scan(/../).map(&:hex)
87
+ rgb(red, green, blue, (alpha || 255) / 255.0)
88
+ end
89
+
90
+ def function_color(string)
91
+ match = FUNCTION.match(string)
92
+ return unless match
93
+
94
+ name = match[:name].downcase
95
+ constructor = LEGACY.fetch(name, name)
96
+ units = UNITS[constructor]
97
+ values = units && function_values(match[:arguments], units, legacy: LEGACY.key?(name))
98
+ public_send(constructor, *values) if values
99
+ end
100
+
101
+ def function_values(arguments, units, legacy:)
102
+ tokens = function_tokens(arguments, legacy:)
103
+ return unless tokens
104
+
105
+ values = tokens.zip(units).map { |token, scale| number(token, scale) }
106
+ values unless values.include?(nil)
107
+ end
108
+
109
+ # Legacy syntax separates with commas and takes alpha fourth; modern
110
+ # syntax separates with spaces and takes alpha after a slash.
111
+ def function_tokens(arguments, legacy:)
112
+ if arguments.include?(',')
113
+ legacy_tokens(arguments) if legacy
114
+ else
115
+ modern_tokens(arguments)
116
+ end
117
+ end
118
+
119
+ def legacy_tokens(arguments)
120
+ tokens = arguments.split(',', -1).map(&:strip)
121
+ tokens if tokens.size.between?(3, 4)
122
+ end
123
+
124
+ def modern_tokens(arguments)
125
+ channels, alpha, *extra = arguments.split('/')
126
+ tokens = channels.to_s.split
127
+ [*tokens, *alpha&.strip] if tokens.size == 3 && extra.empty?
128
+ end
129
+
130
+ def number(token, scale)
131
+ match = TOKEN.match(token)
132
+ return unless match
133
+
134
+ unit = match[:unit]&.downcase
135
+ match[:number].to_r * scale[unit] if scale.key?(unit)
136
+ end
137
+ end
138
+
139
+ extend Parsing
140
+
141
+ # CSS named colours, keyed by lowercase name.
142
+ NAMED = YAML.load_file(File.expand_path('../named_colors.yml', __dir__)).transform_values { parse(it) }.freeze
143
+ end
144
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ # Raised by Color.parse for input that is not a CSS colour.
5
+ class InvalidColor < ArgumentError; end
6
+
7
+ # An sRGB colour: 8-bit `red`, `green` and `blue` channels and an `alpha`
8
+ # from 0 to 1. Immutable.
9
+ #
10
+ # Parses CSS hex, named, `rgb()`, `hsl()`, `hwb()`, `oklab()` and `oklch()`
11
+ # colours. Every output is derived from the 8-bit channels, so a colour
12
+ # stored as hex round-trips through any of them. Colours outside sRGB, such
13
+ # as a high-chroma `oklch()`, clip to the nearest channel values.
14
+ #
15
+ # color = Tincture::Color.parse("orange")
16
+ # color.css_hsl # => "hsl(38.8 100% 50%)"
17
+ # color.contrast_color # => Tincture::Color::BLACK
18
+ #
19
+ # Each part lives in a module mixed into this class: Parsing (`parse`), Hsl
20
+ # (HSL, HWB, HSV), Oklab (Oklab, Oklch), Contrast (WCAG, black or white
21
+ # text) and Comparison (ΔE).
22
+ #
23
+ # Conversions follow [CSS Color 4](https://www.w3.org/TR/css-color-4/) and
24
+ # [Oklab](https://bottosson.github.io/posts/oklab/).
25
+ class Color < Data.define(:red, :green, :blue, :alpha)
26
+ # Builds a colour from 0–255 channels and a 0–1 alpha. Out-of-range
27
+ # values clamp, as they do in CSS.
28
+ #
29
+ #: (Numeric red, Numeric green, Numeric blue, ?Numeric alpha) -> Color
30
+ def self.rgb(red, green, blue, alpha = 1.0)
31
+ red, green, blue = [red, green, blue].map { it.clamp(0, 255).round }
32
+ new(red:, green:, blue:, alpha: alpha.clamp(0.0, 1.0).to_f)
33
+ end
34
+
35
+ # `"#ff8800"`, or `"#ff880080"` when translucent.
36
+ #
37
+ #: () -> String
38
+ def hex
39
+ bytes = opaque? ? rgb : [*rgb, alpha_byte]
40
+ "##{bytes.map { format('%02x', it) }.join}"
41
+ end
42
+
43
+ alias to_s hex
44
+
45
+ # JSON renders a colour as its hex string.
46
+ #
47
+ #: (*untyped) -> String
48
+ def as_json(*) = hex
49
+
50
+ # Red, green and blue, each 0–255.
51
+ #
52
+ #: () -> [Integer, Integer, Integer]
53
+ def rgb = [red, green, blue]
54
+
55
+ # `"rgb(255 136 0)"` or `"rgb(255 136 0 / 0.5)"`.
56
+ #
57
+ #: () -> String
58
+ def css_rgb = css_function('rgb', rgb)
59
+
60
+ # Whether the colour has no transparency at 8-bit precision.
61
+ #
62
+ #: () -> bool
63
+ def opaque? = alpha_byte == 255
64
+
65
+ private
66
+
67
+ def alpha_byte = (alpha * 255).round
68
+
69
+ def channel_fractions = rgb.map { it / 255.0 }
70
+
71
+ def linear_rgb
72
+ channel_fractions.map { it <= 0.04045 ? it / 12.92 : ((it + 0.055) / 1.055)**2.4 }
73
+ end
74
+
75
+ # Adding 0.0 turns -0.0 into 0.0, so CSS never shows "-0".
76
+ def rounded(number, digits) = number.round(digits) + 0.0
77
+
78
+ def css_function(name, components, percentages: false)
79
+ first, *rest = components.map { css_number(it) }
80
+ rest = rest.map { "#{it}%" } if percentages
81
+ alpha = " / #{css_number(self.alpha.round(3))}" unless opaque?
82
+
83
+ "#{name}(#{[first, *rest].join(' ')}#{alpha})"
84
+ end
85
+
86
+ def css_number(number) = number.to_s.delete_suffix('.0')
87
+ end
88
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModel # :nodoc:
4
+ module Validations # :nodoc:
5
+ # `validates :accent, color: true` adds an error when a `:color` attribute
6
+ # was given input it could not parse. The type casts that input to `nil`,
7
+ # so this reads the raw value back, which only Active Record keeps.
8
+ #
9
+ # Blank input passes; pair with `presence: true` to require a colour. Do
10
+ # not pass `allow_nil` or `allow_blank`: unparseable input casts to `nil`,
11
+ # so they would skip it.
12
+ class ColorValidator < EachValidator
13
+ # The error message unless the validation passes `message:`.
14
+ MESSAGE = 'is not a valid color'
15
+
16
+ def validate_each(record, attribute, value) # :nodoc:
17
+ if value.nil? && record.read_attribute_before_type_cast(attribute).present?
18
+ record.errors.add(attribute, :invalid_color, message: options.fetch(:message, self.class::MESSAGE))
19
+ end
20
+ end
21
+ end
22
+
23
+ # `validates :accent, colour: true`, with the error in UK spelling.
24
+ class ColourValidator < ColorValidator
25
+ MESSAGE = 'is not a valid colour' # :nodoc:
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ # Colour difference (ΔE) formulas. Color#difference is the usual way in;
5
+ # these take colour space coordinates directly.
6
+ module Difference
7
+ module_function
8
+
9
+ # ΔE OK: straight-line distance between two Oklab colours, as CSS Color 4
10
+ # measures it. Differences under about 0.02 are hard to see.
11
+ #
12
+ #: ([Float, Float, Float] first, [Float, Float, Float] second) -> Float
13
+ def oklab(first, second)
14
+ Math.sqrt(first.zip(second).sum { |a, b| (a - b)**2 })
15
+ end
16
+
17
+ # CIEDE2000 (ΔE00) between two CIELAB colours, the print and paint
18
+ # standard. A difference of about 1 is just visible.
19
+ #
20
+ # Follows [Sharma, Wu and Dalal](https://hajim.rochester.edu/ece/sites/gsharma/ciede2000/ciede2000noteCRNA.pdf),
21
+ # with the weighting factors kL, kC and kH all 1.
22
+ #
23
+ #: ([Float, Float, Float] first, [Float, Float, Float] second) -> Float
24
+ def ciede2000(first, second)
25
+ (l1, a1, b1), (l2, a2, b2) = first, second
26
+
27
+ chroma_mean = (Math.hypot(a1, b1) + Math.hypot(a2, b2)) / 2
28
+ g = 0.5 * (1 - chroma_weight(chroma_mean))
29
+ a1 *= 1 + g
30
+ a2 *= 1 + g
31
+
32
+ c1 = Math.hypot(a1, b1)
33
+ c2 = Math.hypot(a2, b2)
34
+ h1 = hue_angle(a1, b1)
35
+ h2 = hue_angle(a2, b2)
36
+ chromatic = c1 * c2 != 0
37
+
38
+ delta_l = l2 - l1
39
+ delta_c = c2 - c1
40
+ delta_h = 2 * Math.sqrt(c1 * c2) * Math.sin(radians(hue_difference(h1, h2, chromatic) / 2))
41
+
42
+ l_mean = (l1 + l2) / 2
43
+ c_mean = (c1 + c2) / 2
44
+ h_mean = hue_mean(h1, h2, chromatic)
45
+
46
+ t = 1 - (0.17 * Math.cos(radians(h_mean - 30))) + (0.24 * Math.cos(radians(2 * h_mean))) +
47
+ (0.32 * Math.cos(radians((3 * h_mean) + 6))) - (0.20 * Math.cos(radians((4 * h_mean) - 63)))
48
+
49
+ s_l = 1 + (0.015 * ((l_mean - 50)**2) / Math.sqrt(20 + ((l_mean - 50)**2)))
50
+ s_c = 1 + (0.045 * c_mean)
51
+ s_h = 1 + (0.015 * c_mean * t)
52
+ rotation = -Math.sin(radians(60 * Math.exp(-((h_mean - 275) / 25)**2))) * 2 * chroma_weight(c_mean)
53
+
54
+ lightness = delta_l / s_l
55
+ chroma = delta_c / s_c
56
+ hue = delta_h / s_h
57
+ Math.sqrt((lightness**2) + (chroma**2) + (hue**2) + (rotation * chroma * hue))
58
+ end
59
+
60
+ def chroma_weight(chroma) # :nodoc:
61
+ Math.sqrt((chroma**7) / ((chroma**7) + (25.0**7)))
62
+ end
63
+
64
+ def hue_angle(green_red, blue_yellow) # :nodoc:
65
+ green_red.zero? && blue_yellow.zero? ? 0 : Math.atan2(blue_yellow, green_red) * 180 / Math::PI % 360
66
+ end
67
+
68
+ def hue_difference(first_hue, second_hue, chromatic) # :nodoc:
69
+ difference = second_hue - first_hue
70
+
71
+ if !chromatic then 0
72
+ elsif difference > 180 then difference - 360
73
+ elsif difference < -180 then difference + 360
74
+ else difference
75
+ end
76
+ end
77
+
78
+ def hue_mean(first_hue, second_hue, chromatic) # :nodoc:
79
+ sum = first_hue + second_hue
80
+
81
+ if !chromatic then sum
82
+ elsif (first_hue - second_hue).abs <= 180 then sum / 2
83
+ elsif sum < 360 then (sum + 360) / 2
84
+ else (sum - 360) / 2
85
+ end
86
+ end
87
+
88
+ def radians(degrees) = degrees * Math::PI / 180 # :nodoc:
89
+
90
+ private_class_method :chroma_weight, :hue_angle, :hue_difference, :hue_mean, :radians
91
+ end
92
+ end
@@ -0,0 +1,150 @@
1
+ # CSS Color 4 named colors: https://www.w3.org/TR/css-color-4/#named-colors
2
+ aliceblue: "#f0f8ff"
3
+ antiquewhite: "#faebd7"
4
+ aqua: "#00ffff"
5
+ aquamarine: "#7fffd4"
6
+ azure: "#f0ffff"
7
+ beige: "#f5f5dc"
8
+ bisque: "#ffe4c4"
9
+ black: "#000000"
10
+ blanchedalmond: "#ffebcd"
11
+ blue: "#0000ff"
12
+ blueviolet: "#8a2be2"
13
+ brown: "#a52a2a"
14
+ burlywood: "#deb887"
15
+ cadetblue: "#5f9ea0"
16
+ chartreuse: "#7fff00"
17
+ chocolate: "#d2691e"
18
+ coral: "#ff7f50"
19
+ cornflowerblue: "#6495ed"
20
+ cornsilk: "#fff8dc"
21
+ crimson: "#dc143c"
22
+ cyan: "#00ffff"
23
+ darkblue: "#00008b"
24
+ darkcyan: "#008b8b"
25
+ darkgoldenrod: "#b8860b"
26
+ darkgray: "#a9a9a9"
27
+ darkgreen: "#006400"
28
+ darkgrey: "#a9a9a9"
29
+ darkkhaki: "#bdb76b"
30
+ darkmagenta: "#8b008b"
31
+ darkolivegreen: "#556b2f"
32
+ darkorange: "#ff8c00"
33
+ darkorchid: "#9932cc"
34
+ darkred: "#8b0000"
35
+ darksalmon: "#e9967a"
36
+ darkseagreen: "#8fbc8f"
37
+ darkslateblue: "#483d8b"
38
+ darkslategray: "#2f4f4f"
39
+ darkslategrey: "#2f4f4f"
40
+ darkturquoise: "#00ced1"
41
+ darkviolet: "#9400d3"
42
+ deeppink: "#ff1493"
43
+ deepskyblue: "#00bfff"
44
+ dimgray: "#696969"
45
+ dimgrey: "#696969"
46
+ dodgerblue: "#1e90ff"
47
+ firebrick: "#b22222"
48
+ floralwhite: "#fffaf0"
49
+ forestgreen: "#228b22"
50
+ fuchsia: "#ff00ff"
51
+ gainsboro: "#dcdcdc"
52
+ ghostwhite: "#f8f8ff"
53
+ gold: "#ffd700"
54
+ goldenrod: "#daa520"
55
+ gray: "#808080"
56
+ green: "#008000"
57
+ greenyellow: "#adff2f"
58
+ grey: "#808080"
59
+ honeydew: "#f0fff0"
60
+ hotpink: "#ff69b4"
61
+ indianred: "#cd5c5c"
62
+ indigo: "#4b0082"
63
+ ivory: "#fffff0"
64
+ khaki: "#f0e68c"
65
+ lavender: "#e6e6fa"
66
+ lavenderblush: "#fff0f5"
67
+ lawngreen: "#7cfc00"
68
+ lemonchiffon: "#fffacd"
69
+ lightblue: "#add8e6"
70
+ lightcoral: "#f08080"
71
+ lightcyan: "#e0ffff"
72
+ lightgoldenrodyellow: "#fafad2"
73
+ lightgray: "#d3d3d3"
74
+ lightgreen: "#90ee90"
75
+ lightgrey: "#d3d3d3"
76
+ lightpink: "#ffb6c1"
77
+ lightsalmon: "#ffa07a"
78
+ lightseagreen: "#20b2aa"
79
+ lightskyblue: "#87cefa"
80
+ lightslategray: "#778899"
81
+ lightslategrey: "#778899"
82
+ lightsteelblue: "#b0c4de"
83
+ lightyellow: "#ffffe0"
84
+ lime: "#00ff00"
85
+ limegreen: "#32cd32"
86
+ linen: "#faf0e6"
87
+ magenta: "#ff00ff"
88
+ maroon: "#800000"
89
+ mediumaquamarine: "#66cdaa"
90
+ mediumblue: "#0000cd"
91
+ mediumorchid: "#ba55d3"
92
+ mediumpurple: "#9370db"
93
+ mediumseagreen: "#3cb371"
94
+ mediumslateblue: "#7b68ee"
95
+ mediumspringgreen: "#00fa9a"
96
+ mediumturquoise: "#48d1cc"
97
+ mediumvioletred: "#c71585"
98
+ midnightblue: "#191970"
99
+ mintcream: "#f5fffa"
100
+ mistyrose: "#ffe4e1"
101
+ moccasin: "#ffe4b5"
102
+ navajowhite: "#ffdead"
103
+ navy: "#000080"
104
+ oldlace: "#fdf5e6"
105
+ olive: "#808000"
106
+ olivedrab: "#6b8e23"
107
+ orange: "#ffa500"
108
+ orangered: "#ff4500"
109
+ orchid: "#da70d6"
110
+ palegoldenrod: "#eee8aa"
111
+ palegreen: "#98fb98"
112
+ paleturquoise: "#afeeee"
113
+ palevioletred: "#db7093"
114
+ papayawhip: "#ffefd5"
115
+ peachpuff: "#ffdab9"
116
+ peru: "#cd853f"
117
+ pink: "#ffc0cb"
118
+ plum: "#dda0dd"
119
+ powderblue: "#b0e0e6"
120
+ purple: "#800080"
121
+ rebeccapurple: "#663399"
122
+ red: "#ff0000"
123
+ rosybrown: "#bc8f8f"
124
+ royalblue: "#4169e1"
125
+ saddlebrown: "#8b4513"
126
+ salmon: "#fa8072"
127
+ sandybrown: "#f4a460"
128
+ seagreen: "#2e8b57"
129
+ seashell: "#fff5ee"
130
+ sienna: "#a0522d"
131
+ silver: "#c0c0c0"
132
+ skyblue: "#87ceeb"
133
+ slateblue: "#6a5acd"
134
+ slategray: "#708090"
135
+ slategrey: "#708090"
136
+ snow: "#fffafa"
137
+ springgreen: "#00ff7f"
138
+ steelblue: "#4682b4"
139
+ tan: "#d2b48c"
140
+ teal: "#008080"
141
+ thistle: "#d8bfd8"
142
+ tomato: "#ff6347"
143
+ transparent: "#00000000"
144
+ turquoise: "#40e0d0"
145
+ violet: "#ee82ee"
146
+ wheat: "#f5deb3"
147
+ white: "#ffffff"
148
+ whitesmoke: "#f5f5f5"
149
+ yellow: "#ffff00"
150
+ yellowgreen: "#9acd32"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ # Loads the Active Model integration when a Rails app boots, before the
5
+ # app's own initializers and models.
6
+ class Railtie < Rails::Railtie
7
+ initializer 'tincture.active_model' do
8
+ require 'tincture/active_model'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ # The `:color` (and `:colour`) attribute type. Casts input to a Color, and
5
+ # blank or unparseable input to `nil`.
6
+ #
7
+ # Stores `"#ff8800"`, or `"ff8800"` with `prefix: false`:
8
+ #
9
+ # attribute :accent_color_hex, :color, prefix: false
10
+ class Type < ActiveModel::Type::Value
11
+ # `prefix: false` stores hex without the leading `#`.
12
+ #
13
+ #: (?prefix: bool, **untyped) -> void
14
+ def initialize(prefix: true, **)
15
+ @prefix = prefix
16
+ super(**)
17
+ end
18
+
19
+ def type = :color # :nodoc:
20
+
21
+ # The hex string to store, or `nil` for blank or unparseable input.
22
+ #
23
+ #: (untyped value) -> String?
24
+ def serialize(value)
25
+ hex = cast(value)&.hex
26
+ @prefix ? hex : hex&.delete_prefix('#')
27
+ end
28
+
29
+ private
30
+
31
+ def cast_value(value)
32
+ Color.parse(value)
33
+ rescue InvalidColor
34
+ nil
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tincture
4
+ VERSION = '1.0.0' # :nodoc:
5
+ end
data/lib/tincture.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tincture/version'
4
+ require_relative 'tincture/difference'
5
+ require_relative 'tincture/color'
6
+ require_relative 'tincture/color/parsing'
7
+ require_relative 'tincture/color/hsl'
8
+ require_relative 'tincture/color/oklab'
9
+ require_relative 'tincture/color/contrast'
10
+ require_relative 'tincture/color/comparison'
11
+
12
+ # Colour values for plain Ruby, with an optional Active Model integration.
13
+ #
14
+ # - Tincture::Color parses, converts and compares colours.
15
+ # - Tincture::Type casts model attributes declared `:color` or `:colour`.
16
+ # - ActiveModel::Validations::ColorValidator rejects input the type could not
17
+ # parse.
18
+ #
19
+ # The type and validator need `require "tincture/active_model"`. Rails apps
20
+ # load it automatically.
21
+ #
22
+ # Every name has a UK alias: `Colour`, `InvalidColour`, `:colour`.
23
+ module Tincture
24
+ Colour = Color
25
+ InvalidColour = InvalidColor
26
+ end
27
+
28
+ require_relative 'tincture/railtie' if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tincture
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Croll
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Parse hex, rgb() and hsl() colours into one value object, convert between
13
+ them, and cast model attributes to it with `attribute :accent, :color`.
14
+ email:
15
+ - andy@goodscary.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/tincture.rb
24
+ - lib/tincture/active_model.rb
25
+ - lib/tincture/color.rb
26
+ - lib/tincture/color/comparison.rb
27
+ - lib/tincture/color/contrast.rb
28
+ - lib/tincture/color/hsl.rb
29
+ - lib/tincture/color/oklab.rb
30
+ - lib/tincture/color/parsing.rb
31
+ - lib/tincture/color_validator.rb
32
+ - lib/tincture/difference.rb
33
+ - lib/tincture/named_colors.yml
34
+ - lib/tincture/railtie.rb
35
+ - lib/tincture/type.rb
36
+ - lib/tincture/version.rb
37
+ homepage: https://github.com/andycroll/tincture
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ source_code_uri: https://github.com/andycroll/tincture
42
+ changelog_uri: https://github.com/andycroll/tincture/blob/main/CHANGELOG.md
43
+ rubygems_mfa_required: 'true'
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '3.4'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubygems_version: 4.0.20
59
+ specification_version: 4
60
+ summary: Colour values for Ruby, with an optional Active Model attribute type
61
+ test_files: []