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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +22 -0
- data/README.md +174 -0
- data/lib/tincture/active_model.rb +18 -0
- data/lib/tincture/color/comparison.rb +46 -0
- data/lib/tincture/color/contrast.rb +164 -0
- data/lib/tincture/color/hsl.rb +123 -0
- data/lib/tincture/color/oklab.rb +109 -0
- data/lib/tincture/color/parsing.rb +144 -0
- data/lib/tincture/color.rb +88 -0
- data/lib/tincture/color_validator.rb +28 -0
- data/lib/tincture/difference.rb +92 -0
- data/lib/tincture/named_colors.yml +150 -0
- data/lib/tincture/railtie.rb +11 -0
- data/lib/tincture/type.rb +37 -0
- data/lib/tincture/version.rb +5 -0
- data/lib/tincture.rb +28 -0
- metadata +61 -0
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
|
+
[](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
|
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: []
|