windsock 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +85 -0
- data/CONTRIBUTING.md +30 -0
- data/LICENSE.txt +21 -0
- data/README.md +200 -0
- data/Rakefile +12 -0
- data/SECURITY.md +14 -0
- data/lib/windsock/color.rb +441 -0
- data/lib/windsock/helper.rb +56 -0
- data/lib/windsock/parser.rb +245 -0
- data/lib/windsock/railtie.rb +29 -0
- data/lib/windsock/scale.rb +189 -0
- data/lib/windsock/tailwind/LICENSE +21 -0
- data/lib/windsock/tailwind/theme.css +510 -0
- data/lib/windsock/theme.rb +220 -0
- data/lib/windsock/version.rb +5 -0
- data/lib/windsock.rb +67 -0
- metadata +62 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Windsock
|
|
4
|
+
# A parsed CSS color. Stores gamma-encoded sRGB components as floats in 0..1
|
|
5
|
+
# plus alpha, and remembers the original CSS text.
|
|
6
|
+
#
|
|
7
|
+
# Accepts hex, named colors, rgb()/rgba(), hsl()/hsla(), oklch(), oklab(),
|
|
8
|
+
# color(srgb ...) and Tailwind's --alpha(<color> / <alpha>).
|
|
9
|
+
class Color
|
|
10
|
+
FUNCTION = /\A(--alpha|oklch|oklab|rgba?|hsla?|color)\(\s*(.*)\s*\)\z/mi
|
|
11
|
+
JND = 0.02 # just-noticeable difference in OKLab, per CSS Color 4
|
|
12
|
+
EPSILON = 0.0001
|
|
13
|
+
|
|
14
|
+
attr_reader :source, :r, :g, :b, :alpha
|
|
15
|
+
|
|
16
|
+
NAMED = {
|
|
17
|
+
"transparent" => [0.0, 0.0, 0.0, 0.0], "aliceblue" => "#f0f8ff", "antiquewhite" => "#faebd7",
|
|
18
|
+
"aqua" => "#00ffff", "aquamarine" => "#7fffd4", "azure" => "#f0ffff", "beige" => "#f5f5dc",
|
|
19
|
+
"bisque" => "#ffe4c4", "black" => "#000000", "blanchedalmond" => "#ffebcd", "blue" => "#0000ff",
|
|
20
|
+
"blueviolet" => "#8a2be2", "brown" => "#a52a2a", "burlywood" => "#deb887", "cadetblue" => "#5f9ea0",
|
|
21
|
+
"chartreuse" => "#7fff00", "chocolate" => "#d2691e", "coral" => "#ff7f50",
|
|
22
|
+
"cornflowerblue" => "#6495ed", "cornsilk" => "#fff8dc", "crimson" => "#dc143c", "cyan" => "#00ffff",
|
|
23
|
+
"darkblue" => "#00008b", "darkcyan" => "#008b8b", "darkgoldenrod" => "#b8860b", "darkgray" => "#a9a9a9",
|
|
24
|
+
"darkgreen" => "#006400", "darkgrey" => "#a9a9a9", "darkkhaki" => "#bdb76b", "darkmagenta" => "#8b008b",
|
|
25
|
+
"darkolivegreen" => "#556b2f", "darkorange" => "#ff8c00", "darkorchid" => "#9932cc",
|
|
26
|
+
"darkred" => "#8b0000", "darksalmon" => "#e9967a", "darkseagreen" => "#8fbc8f",
|
|
27
|
+
"darkslateblue" => "#483d8b", "darkslategray" => "#2f4f4f", "darkslategrey" => "#2f4f4f",
|
|
28
|
+
"darkturquoise" => "#00ced1", "darkviolet" => "#9400d3", "deeppink" => "#ff1493",
|
|
29
|
+
"deepskyblue" => "#00bfff", "dimgray" => "#696969", "dimgrey" => "#696969", "dodgerblue" => "#1e90ff",
|
|
30
|
+
"firebrick" => "#b22222", "floralwhite" => "#fffaf0", "forestgreen" => "#228b22",
|
|
31
|
+
"fuchsia" => "#ff00ff", "gainsboro" => "#dcdcdc", "ghostwhite" => "#f8f8ff", "gold" => "#ffd700",
|
|
32
|
+
"goldenrod" => "#daa520", "gray" => "#808080", "green" => "#008000", "greenyellow" => "#adff2f",
|
|
33
|
+
"grey" => "#808080", "honeydew" => "#f0fff0", "hotpink" => "#ff69b4", "indianred" => "#cd5c5c",
|
|
34
|
+
"indigo" => "#4b0082", "ivory" => "#fffff0", "khaki" => "#f0e68c", "lavender" => "#e6e6fa",
|
|
35
|
+
"lavenderblush" => "#fff0f5", "lawngreen" => "#7cfc00", "lemonchiffon" => "#fffacd",
|
|
36
|
+
"lightblue" => "#add8e6", "lightcoral" => "#f08080", "lightcyan" => "#e0ffff",
|
|
37
|
+
"lightgoldenrodyellow" => "#fafad2", "lightgray" => "#d3d3d3", "lightgreen" => "#90ee90",
|
|
38
|
+
"lightgrey" => "#d3d3d3", "lightpink" => "#ffb6c1", "lightsalmon" => "#ffa07a",
|
|
39
|
+
"lightseagreen" => "#20b2aa", "lightskyblue" => "#87cefa", "lightslategray" => "#778899",
|
|
40
|
+
"lightslategrey" => "#778899", "lightsteelblue" => "#b0c4de", "lightyellow" => "#ffffe0",
|
|
41
|
+
"lime" => "#00ff00", "limegreen" => "#32cd32", "linen" => "#faf0e6", "magenta" => "#ff00ff",
|
|
42
|
+
"maroon" => "#800000", "mediumaquamarine" => "#66cdaa", "mediumblue" => "#0000cd",
|
|
43
|
+
"mediumorchid" => "#ba55d3", "mediumpurple" => "#9370db", "mediumseagreen" => "#3cb371",
|
|
44
|
+
"mediumslateblue" => "#7b68ee", "mediumspringgreen" => "#00fa9a", "mediumturquoise" => "#48d1cc",
|
|
45
|
+
"mediumvioletred" => "#c71585", "midnightblue" => "#191970", "mintcream" => "#f5fffa",
|
|
46
|
+
"mistyrose" => "#ffe4e1", "moccasin" => "#ffe4b5", "navajowhite" => "#ffdead", "navy" => "#000080",
|
|
47
|
+
"oldlace" => "#fdf5e6", "olive" => "#808000", "olivedrab" => "#6b8e23", "orange" => "#ffa500",
|
|
48
|
+
"orangered" => "#ff4500", "orchid" => "#da70d6", "palegoldenrod" => "#eee8aa", "palegreen" => "#98fb98",
|
|
49
|
+
"paleturquoise" => "#afeeee", "palevioletred" => "#db7093", "papayawhip" => "#ffefd5",
|
|
50
|
+
"peachpuff" => "#ffdab9", "peru" => "#cd853f", "pink" => "#ffc0cb", "plum" => "#dda0dd",
|
|
51
|
+
"powderblue" => "#b0e0e6", "purple" => "#800080", "rebeccapurple" => "#663399", "red" => "#ff0000",
|
|
52
|
+
"rosybrown" => "#bc8f8f", "royalblue" => "#4169e1", "saddlebrown" => "#8b4513", "salmon" => "#fa8072",
|
|
53
|
+
"sandybrown" => "#f4a460", "seagreen" => "#2e8b57", "seashell" => "#fff5ee", "sienna" => "#a0522d",
|
|
54
|
+
"silver" => "#c0c0c0", "skyblue" => "#87ceeb", "slateblue" => "#6a5acd", "slategray" => "#708090",
|
|
55
|
+
"slategrey" => "#708090", "snow" => "#fffafa", "springgreen" => "#00ff7f", "steelblue" => "#4682b4",
|
|
56
|
+
"tan" => "#d2b48c", "teal" => "#008080", "thistle" => "#d8bfd8", "tomato" => "#ff6347",
|
|
57
|
+
"turquoise" => "#40e0d0", "violet" => "#ee82ee", "wheat" => "#f5deb3", "white" => "#ffffff",
|
|
58
|
+
"whitesmoke" => "#f5f5f5", "yellow" => "#ffff00", "yellowgreen" => "#9acd32"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
def self.parse(css)
|
|
62
|
+
return css if css.is_a?(Color)
|
|
63
|
+
|
|
64
|
+
text = css.to_s.strip
|
|
65
|
+
return nil if text.empty?
|
|
66
|
+
|
|
67
|
+
rgba = parse_components(text)
|
|
68
|
+
rgba && new(*rgba, source: text)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.parse!(css)
|
|
72
|
+
parse(css) || raise(ParseError, "cannot parse color: #{css.inspect}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def initialize(r, g, b, alpha = 1.0, source: nil)
|
|
76
|
+
@r = clamp(r)
|
|
77
|
+
@g = clamp(g)
|
|
78
|
+
@b = clamp(b)
|
|
79
|
+
@alpha = clamp(alpha)
|
|
80
|
+
@source = source
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Integer RGB channels, 0..255.
|
|
84
|
+
def rgb
|
|
85
|
+
[r, g, b].map { |c| (c * 255).round }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def red = rgb[0]
|
|
89
|
+
def green = rgb[1]
|
|
90
|
+
def blue = rgb[2]
|
|
91
|
+
|
|
92
|
+
# "#rrggbb", or "#rrggbbaa" when alpha < 1.
|
|
93
|
+
def to_hex(alpha: true)
|
|
94
|
+
channels = rgb
|
|
95
|
+
channels << (self.alpha * 255).round if alpha && !opaque?
|
|
96
|
+
"##{channels.map { |c| c.to_s(16).rjust(2, "0") }.join}"
|
|
97
|
+
end
|
|
98
|
+
alias hex to_hex
|
|
99
|
+
|
|
100
|
+
# Modern syntax: "rgb(59 130 246)" or "rgb(59 130 246 / 0.5)".
|
|
101
|
+
def to_rgb
|
|
102
|
+
base = "rgb(#{rgb.join(" ")}"
|
|
103
|
+
opaque? ? "#{base})" : "#{base} / #{fmt(alpha)})"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Legacy comma syntax, always with alpha: "rgba(59, 130, 246, 1)".
|
|
107
|
+
# Safest form for email clients that choke on modern color syntax.
|
|
108
|
+
def to_rgba
|
|
109
|
+
"rgba(#{rgb.join(", ")}, #{fmt(alpha)})"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def opaque?
|
|
113
|
+
alpha >= 1.0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# WCAG relative luminance, 0 (black) .. 1 (white).
|
|
117
|
+
def luminance
|
|
118
|
+
lr, lg, lb = [r, g, b].map { |c| Color.to_linear(c) }
|
|
119
|
+
(0.2126 * lr) + (0.7152 * lg) + (0.0722 * lb)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# True when white text reads better on this color than black does.
|
|
123
|
+
def dark?
|
|
124
|
+
luminance < 0.179
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def light?
|
|
128
|
+
!dark?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# WCAG contrast ratio against another color (a Color or any CSS color).
|
|
132
|
+
def contrast(other)
|
|
133
|
+
a = luminance
|
|
134
|
+
b = Color.parse!(other.to_s).luminance
|
|
135
|
+
a, b = b, a if b > a
|
|
136
|
+
(a + 0.05) / (b + 0.05)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Minimum contrast ratios from WCAG 2.x. "Large" text is 18pt+ or 14pt bold.
|
|
140
|
+
WCAG = { aa: 4.5, aa_large: 3.0, aaa: 7.0, aaa_large: 4.5 }.freeze
|
|
141
|
+
|
|
142
|
+
# Does text in this color on +other+ (or vice versa) meet a WCAG level?
|
|
143
|
+
#
|
|
144
|
+
# color.accessible?("#fff") # AA, normal text
|
|
145
|
+
# color.accessible?(:white, level: :aa_large)
|
|
146
|
+
def accessible?(other, level: :aa)
|
|
147
|
+
ratio = WCAG.fetch(level) { raise ArgumentError, "level must be one of #{WCAG.keys.join(", ")}" }
|
|
148
|
+
contrast(other) >= ratio
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def aa?(other, large: false) = accessible?(other, level: large ? :aa_large : :aa)
|
|
152
|
+
def aaa?(other, large: false) = accessible?(other, level: large ? :aaa_large : :aaa)
|
|
153
|
+
|
|
154
|
+
# The highest WCAG level this pair reaches for normal text: :aaa, :aa,
|
|
155
|
+
# :aa_large (fine for headings only), or :fail.
|
|
156
|
+
def contrast_level(other)
|
|
157
|
+
ratio = contrast(other)
|
|
158
|
+
if ratio >= WCAG[:aaa] then :aaa
|
|
159
|
+
elsif ratio >= WCAG[:aa] then :aa
|
|
160
|
+
elsif ratio >= WCAG[:aa_large] then :aa_large
|
|
161
|
+
else :fail
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Black or white, whichever contrasts more with this color. For button
|
|
166
|
+
# labels, badges, anything with text on a theme color. Pass the theme's
|
|
167
|
+
# own white and black when they aren't pure (Theme#text_color does).
|
|
168
|
+
def text_color(white: WHITE, black: BLACK)
|
|
169
|
+
contrast(white) >= contrast(black) ? white : black
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# "white" / "black" for named colors, else the hex.
|
|
173
|
+
def name
|
|
174
|
+
NAMED.key(to_hex) || to_hex
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def to_s
|
|
178
|
+
source || to_hex
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def inspect
|
|
182
|
+
"#<Windsock::Color #{to_hex} (#{source})>"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def ==(other)
|
|
186
|
+
other.is_a?(Color) && to_hex == other.to_hex
|
|
187
|
+
end
|
|
188
|
+
alias eql? ==
|
|
189
|
+
|
|
190
|
+
def hash
|
|
191
|
+
to_hex.hash
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def clamp(value)
|
|
197
|
+
value.to_f.clamp(0.0, 1.0)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def fmt(number)
|
|
201
|
+
number == number.round ? number.round.to_s : number.round(3).to_s
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class << self
|
|
205
|
+
def to_linear(c)
|
|
206
|
+
c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055)**2.4
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def to_gamma(c)
|
|
210
|
+
c <= 0.0031308 ? 12.92 * c : (1.055 * (c**(1 / 2.4))) - 0.055
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def parse_components(text)
|
|
216
|
+
if text.start_with?("#")
|
|
217
|
+
parse_hex(text)
|
|
218
|
+
elsif (match = FUNCTION.match(text))
|
|
219
|
+
parse_function(match[1].downcase, match[2])
|
|
220
|
+
else
|
|
221
|
+
named = NAMED[text.downcase]
|
|
222
|
+
named.is_a?(String) ? parse_hex(named) : named
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def parse_hex(text)
|
|
227
|
+
digits = text[1..]
|
|
228
|
+
return nil unless digits.match?(/\A\h{3,4}\z|\A\h{6}\z|\A\h{8}\z/)
|
|
229
|
+
|
|
230
|
+
digits = digits.chars.map { |c| c * 2 }.join if digits.length <= 4
|
|
231
|
+
channels = digits.scan(/\h\h/).map { |h| h.hex / 255.0 }
|
|
232
|
+
channels << 1.0 if channels.length == 3
|
|
233
|
+
channels
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse_function(name, body)
|
|
237
|
+
head, alpha_arg = split_top_level(body, "/")
|
|
238
|
+
alpha = alpha_arg ? parse_alpha(alpha_arg.strip) : 1.0
|
|
239
|
+
return alpha_function(head.strip, alpha) if name == "--alpha"
|
|
240
|
+
|
|
241
|
+
args = split_args(head)
|
|
242
|
+
# Legacy rgba(r, g, b, a) / hsla(h, s, l, a).
|
|
243
|
+
alpha = parse_alpha(args.pop) if alpha_arg.nil? && args.length == 4 && %w[rgb rgba hsl hsla].include?(name)
|
|
244
|
+
|
|
245
|
+
case name
|
|
246
|
+
when "rgb", "rgba" then rgb_function(args, alpha)
|
|
247
|
+
when "hsl", "hsla" then hsl_function(args, alpha)
|
|
248
|
+
when "oklch" then oklch_function(args, alpha)
|
|
249
|
+
when "oklab" then oklab_function(args, alpha)
|
|
250
|
+
when "color" then color_function(args, alpha)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Splits at the first occurrence of +char+ outside parentheses.
|
|
255
|
+
def split_top_level(text, char)
|
|
256
|
+
depth = 0
|
|
257
|
+
text.each_char.with_index do |c, i|
|
|
258
|
+
depth += 1 if c == "("
|
|
259
|
+
depth -= 1 if c == ")"
|
|
260
|
+
return [text[0...i], text[(i + 1)..]] if c == char && depth.zero?
|
|
261
|
+
end
|
|
262
|
+
[text, nil]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def split_args(text)
|
|
266
|
+
text.strip.split(/\s*,\s*|\s+/).reject(&:empty?)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def alpha_function(text, alpha)
|
|
270
|
+
inner = parse_components(text)
|
|
271
|
+
inner && (inner[0, 3] + [inner[3] * alpha])
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def rgb_function(args, alpha)
|
|
275
|
+
return nil unless args.length == 3
|
|
276
|
+
|
|
277
|
+
args.map { |a| channel(a, 255.0) } << alpha
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def hsl_function(args, alpha)
|
|
281
|
+
return nil unless args.length == 3
|
|
282
|
+
|
|
283
|
+
h = hue(args[0]) / 360.0
|
|
284
|
+
s = channel(args[1], 100.0)
|
|
285
|
+
l = channel(args[2], 100.0)
|
|
286
|
+
hsl_to_rgb(h, s, l) << alpha
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def oklch_function(args, alpha)
|
|
290
|
+
return nil unless args.length == 3
|
|
291
|
+
|
|
292
|
+
l = lightness(args[0])
|
|
293
|
+
c = args[1].end_with?("%") ? number(args[1]) / 100.0 * 0.4 : number(args[1])
|
|
294
|
+
h = hue(args[2]) * Math::PI / 180
|
|
295
|
+
oklab_to_rgb(l, c * Math.cos(h), c * Math.sin(h)) << alpha
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def oklab_function(args, alpha)
|
|
299
|
+
return nil unless args.length == 3
|
|
300
|
+
|
|
301
|
+
a, b = args[1, 2].map { |v| v.end_with?("%") ? number(v) / 100.0 * 0.4 : number(v) }
|
|
302
|
+
oklab_to_rgb(lightness(args[0]), a, b) << alpha
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def color_function(args, alpha)
|
|
306
|
+
return nil unless args.length == 4 && %w[srgb srgb-linear].include?(args[0].downcase)
|
|
307
|
+
|
|
308
|
+
channels = args[1, 3].map { |a| channel(a, 1.0) }
|
|
309
|
+
channels = channels.map { |c| to_gamma(c) } if args[0].casecmp?("srgb-linear")
|
|
310
|
+
channels << alpha
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def lightness(value)
|
|
314
|
+
value.end_with?("%") ? number(value) / 100.0 : number(value)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def channel(value, scale)
|
|
318
|
+
if value == "none"
|
|
319
|
+
0.0
|
|
320
|
+
else
|
|
321
|
+
(value.end_with?("%") ? number(value) / 100.0 : number(value) / scale)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def parse_alpha(value)
|
|
326
|
+
value.end_with?("%") ? number(value) / 100.0 : number(value)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def hue(value)
|
|
330
|
+
return 0.0 if value == "none"
|
|
331
|
+
|
|
332
|
+
n = number(value)
|
|
333
|
+
case value
|
|
334
|
+
when /rad\z/ then n * 180 / Math::PI
|
|
335
|
+
when /grad\z/ then n * 0.9
|
|
336
|
+
when /turn\z/ then n * 360
|
|
337
|
+
else n
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def number(value)
|
|
342
|
+
Float(value[/-?\d*\.?\d+(?:e[+-]?\d+)?/i] || raise(ParseError, "bad number: #{value.inspect}"))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def hsl_to_rgb(h, s, l)
|
|
346
|
+
return [l, l, l] if s.zero?
|
|
347
|
+
|
|
348
|
+
q = l < 0.5 ? l * (1 + s) : l + s - (l * s)
|
|
349
|
+
p = (2 * l) - q
|
|
350
|
+
[h + (1 / 3.0), h, h - (1 / 3.0)].map { |t| hue_to_rgb(p, q, t) }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def hue_to_rgb(p, q, t)
|
|
354
|
+
t += 1 if t.negative?
|
|
355
|
+
t -= 1 if t > 1
|
|
356
|
+
return p + ((q - p) * 6 * t) if t < 1 / 6.0
|
|
357
|
+
return q if t < 0.5
|
|
358
|
+
return p + ((q - p) * ((2 / 3.0) - t) * 6) if t < 2 / 3.0
|
|
359
|
+
|
|
360
|
+
p
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# OKLab -> gamma-encoded sRGB, gamut mapped per CSS Color 4 §13.2:
|
|
364
|
+
# hold lightness and hue, binary-search chroma, and accept the clipped
|
|
365
|
+
# candidate once it is within a just-noticeable difference (ΔE_OK 0.02)
|
|
366
|
+
# of the unclipped one. Matches what browsers (and Tailwind's docs) show
|
|
367
|
+
# for the saturated v4 palette entries that fall outside sRGB.
|
|
368
|
+
def oklab_to_rgb(l, a, b)
|
|
369
|
+
rgb = oklab_to_linear(l, a, b)
|
|
370
|
+
return rgb.map { |c| to_gamma(c.clamp(0.0, 1.0)) } if in_gamut?(rgb)
|
|
371
|
+
return [l, l, l].map { |c| to_gamma(c.clamp(0.0, 1.0)) } if l <= 0 || l >= 1
|
|
372
|
+
|
|
373
|
+
hue = Math.atan2(b, a)
|
|
374
|
+
min = 0.0
|
|
375
|
+
max = Math.sqrt((a * a) + (b * b))
|
|
376
|
+
min_in_gamut = true
|
|
377
|
+
clipped = rgb.map { |c| c.clamp(0.0, 1.0) }
|
|
378
|
+
|
|
379
|
+
while max - min > EPSILON
|
|
380
|
+
chroma = (min + max) / 2
|
|
381
|
+
current = oklab_to_linear(l, chroma * Math.cos(hue), chroma * Math.sin(hue))
|
|
382
|
+
if min_in_gamut && in_gamut?(current)
|
|
383
|
+
min = chroma
|
|
384
|
+
next
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
clipped = current.map { |c| c.clamp(0.0, 1.0) }
|
|
388
|
+
delta = delta_eok(linear_to_oklab(*clipped), [l, chroma * Math.cos(hue), chroma * Math.sin(hue)])
|
|
389
|
+
if delta < JND
|
|
390
|
+
break if JND - delta < EPSILON
|
|
391
|
+
|
|
392
|
+
min_in_gamut = false
|
|
393
|
+
min = chroma
|
|
394
|
+
else
|
|
395
|
+
max = chroma
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
clipped.map { |c| to_gamma(c) }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def in_gamut?(rgb)
|
|
402
|
+
rgb.all? { |c| c.between?(-0.00001, 1.00001) }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def delta_eok(lab1, lab2)
|
|
406
|
+
Math.sqrt(lab1.zip(lab2).sum { |x, y| (x - y)**2 })
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def linear_to_oklab(r, g, b)
|
|
410
|
+
l = Math.cbrt((0.4122214708 * r) + (0.5363325363 * g) + (0.0514459929 * b))
|
|
411
|
+
m = Math.cbrt((0.2119034982 * r) + (0.6806995451 * g) + (0.1073969566 * b))
|
|
412
|
+
s = Math.cbrt((0.0883024619 * r) + (0.2817188376 * g) + (0.6299787005 * b))
|
|
413
|
+
[
|
|
414
|
+
(0.2104542553 * l) + (0.7936177850 * m) - (0.0040720468 * s),
|
|
415
|
+
(1.9779984951 * l) - (2.4285922050 * m) + (0.4505937099 * s),
|
|
416
|
+
(0.0259040371 * l) + (0.7827717662 * m) - (0.8086757660 * s)
|
|
417
|
+
]
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Björn Ottosson's OKLab -> linear sRGB.
|
|
421
|
+
def oklab_to_linear(l, a, b)
|
|
422
|
+
l_ = l + (0.3963377774 * a) + (0.2158037573 * b)
|
|
423
|
+
m_ = l - (0.1055613458 * a) - (0.0638541728 * b)
|
|
424
|
+
s_ = l - (0.0894841775 * a) - (1.2914855480 * b)
|
|
425
|
+
|
|
426
|
+
l3 = l_**3
|
|
427
|
+
m3 = m_**3
|
|
428
|
+
s3 = s_**3
|
|
429
|
+
|
|
430
|
+
[
|
|
431
|
+
(4.0767416621 * l3) - (3.3077115913 * m3) + (0.2309699292 * s3),
|
|
432
|
+
(-1.2684380046 * l3) + (2.6097574011 * m3) - (0.3413193965 * s3),
|
|
433
|
+
(-0.0041960863 * l3) - (0.7034186147 * m3) + (1.7076147010 * s3)
|
|
434
|
+
]
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
Color::WHITE = Color.parse("#ffffff").freeze
|
|
440
|
+
Color::BLACK = Color.parse("#000000").freeze
|
|
441
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Windsock
|
|
4
|
+
# View and mailer helpers. Included into ActionView by the Railtie; can be
|
|
5
|
+
# mixed into anything else by hand.
|
|
6
|
+
module Helper
|
|
7
|
+
FORMATS = %i[hex rgb rgba css].freeze
|
|
8
|
+
|
|
9
|
+
def tailwind_theme
|
|
10
|
+
Windsock.theme
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# tailwind_color(:brand, 500) => "#3b82f6"
|
|
14
|
+
# tailwind_color(:brand, 500, format: :rgb) => "rgb(59 130 246)"
|
|
15
|
+
# tailwind_color("brand-500", format: :css) => "oklch(62.3% 0.214 259.815)"
|
|
16
|
+
def tailwind_color(*parts, format: :hex)
|
|
17
|
+
tailwind_format(tailwind_single_color(*parts), format)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Black or white, whichever reads better on the given theme color:
|
|
21
|
+
# <a style="background: <%= tailwind_color(:brand, 500) %>; color: <%= tailwind_text_color(:brand, 500) %>">
|
|
22
|
+
def tailwind_text_color(*parts, format: :hex)
|
|
23
|
+
theme = Windsock.theme
|
|
24
|
+
tailwind_format(tailwind_single_color(*parts).text_color(white: theme.white, black: theme.black), format)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tailwind_scale(name)
|
|
28
|
+
Windsock.theme.scale(name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tailwind_value(*parts)
|
|
32
|
+
Windsock.theme.fetch(*parts)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# A view wants one color, never a family. color! may hand back a Scale
|
|
38
|
+
# for tailwind_color(:brand); say so instead of failing on to_hex.
|
|
39
|
+
def tailwind_single_color(*parts)
|
|
40
|
+
color = Windsock.theme.color!(*parts)
|
|
41
|
+
return color if color.is_a?(Color)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "#{color.name.inspect} is a color scale; pick a step: #{color.steps.join(", ")}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tailwind_format(color, format)
|
|
47
|
+
case format
|
|
48
|
+
when :hex then color.to_hex
|
|
49
|
+
when :rgb then color.to_rgb
|
|
50
|
+
when :rgba then color.to_rgba
|
|
51
|
+
when :css then color.source
|
|
52
|
+
else raise ArgumentError, "format must be one of #{FORMATS.join(", ")}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|