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.
@@ -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