diff --git a/app/helpers/color_helper.rb b/app/helpers/color_helper.rb new file mode 100644 index 000000000..6447f2e15 --- /dev/null +++ b/app/helpers/color_helper.rb @@ -0,0 +1,93 @@ +module ColorHelper + def hex_to_rgb(hex_color) + # Remove the '#' character if it's there + hex_color = hex_color.tr('#', '') + + # Split the hex color string into the RGB components + r = hex_color[0..1].to_i(16) + g = hex_color[2..3].to_i(16) + b = hex_color[4..5].to_i(16) + + [r, g, b] + end + + def rgb_to_hex(red, green, blue) + hex = [red, green, blue].map do |color| + color = 255 if color > 255 + color = 0 if color.negative? + + color.round.to_s(16).rjust(2, '0') + end.join.upcase + + "##{hex}".downcase + end + + def get_luminance(color) + return 0 if color == 'transparent' + + r, g, b = hex_to_rgb(color) + (0.2126 * channel_to_luminance(r)) + (0.7152 * channel_to_luminance(g)) + (0.0722 * channel_to_luminance(b)) + end + + def get_contrast(color1, color2) + luminance1 = get_luminance(color1) + luminance2 = get_luminance(color2) + + if luminance1 > luminance2 + (luminance1 + 0.05) / (luminance2 + 0.05) + else + (luminance2 + 0.05) / (luminance1 + 0.05) + end + end + + def mix(color1, color2, weight) + # Parse colors and normalize + r1, g1, b1 = hex_to_rgb(color1).map { |c| c / 255.0 } + r2, g2, b2 = hex_to_rgb(color2).map { |c| c / 255.0 } + + # Mixing algorithm from Sass implementation + weight1, weight2 = get_weight_for_mixing(weight) + + # Calculate final color values + r = ((r1 * weight1) + (r2 * weight2)) * 255 + g = ((g1 * weight1) + (g2 * weight2)) * 255 + b = ((b1 * weight1) + (b2 * weight2)) * 255 + + # Convert final values to a CSS RGBA color string + rgb_to_hex(r, g, b) + end + + def adjust_color_for_contrast(color, background_color, target_ratio = 3.1) + max_iterations = 20 + adjusted_color = color + + max_iterations.times do + current_ratio = get_contrast(adjusted_color, background_color) + break if current_ratio >= target_ratio + + adjustment_direction = get_luminance(adjusted_color) < 0.5 ? '#ffffff' : '#151718' + adjusted_color = mix(adjusted_color, adjustment_direction, 0.05) + end + + adjusted_color + end + + private + + def get_weight_for_mixing(weight) + normalized_weight = (weight * 2) - 1 + weight2 = (normalized_weight + 1) / 2 + weight1 = 1 - weight2 + + [weight1, weight2] + end + + def channel_to_luminance(channel) + channel /= 255.0 + if channel <= 0.03928 + channel / 12.92 + else + ((channel + 0.055) / 1.055)**2.4 + end + end +end diff --git a/spec/helpers/color_helper_spec.rb b/spec/helpers/color_helper_spec.rb new file mode 100644 index 000000000..762ed2a97 --- /dev/null +++ b/spec/helpers/color_helper_spec.rb @@ -0,0 +1,168 @@ +require 'rails_helper' + +# the values for these test cases are based off https://github.com/ricokahler/color2k/ + +RSpec.describe ColorHelper do + describe '#hex_to_rgb' do + it 'converts a hex color to RGB' do + pairs = [ + ['#fefce8', [254, 252, 232]], + ['#fef9c3', [254, 249, 195]], + ['#fde047', [253, 224, 71]], + ['#ffe047', [255, 224, 71]], + ['#ffff47', [255, 255, 71]], + ['#000f47', [0, 15, 71]] + ] + + pairs.each do |pair| + expect(helper.hex_to_rgb(pair[0])).to eq(pair[1]) + end + end + end + + describe '#rgb_to_hex' do + it 'converts RGB to a hex color' do + pairs = [ + ['#fefce8', [254, 252, 232]], + ['#fef9c3', [254, 249, 195]], + ['#fde047', [253, 224, 71]], + ['#ffe047', [255, 224, 71]], + ['#ffff47', [255, 255, 71]], + ['#000f47', [0, 15, 71]] + ] + + pairs.each do |pair| + expect(helper.rgb_to_hex(*pair[1])).to eq(pair[0]) + end + end + end + + describe '#get_luminance' do + it 'gets luminance' do + pairs = [ + ['#fefce8', 0.9651783305430648], + ['#fef9c3', 0.9276232470289122], + ['#fde047', 0.7464888809386645], + ['#ffe047', 0.7502624139378438], + ['#ffff47', 0.9323493232745587], + ['#000f47', 0.007965800403950861] + ] + + pairs.each do |pair| + expect(helper.get_luminance(pair[0])).to be_within(0.0001).of(pair[1]) + end + end + end + + describe '#get_contrast' do + it 'gets contrast' do + pairs = [ + ['#fff7ed', '#082f49', 13.071630965576853], + ['#ffedd5', '#0c4a6e', 8.25405437296673], + ['#fed7aa', '#075985', 5.587542088216559], + ['#fdba74', '#0369a1', 3.518444977234888], + ['#fb923c', '#0284c7', 1.8094825558825534], + ['#f97316', '#0ea5e9', 1.01141749353127], + ['#ea580c', '#38bdf8', 1.66155548862194], + ['#c2410c', '#7dd3fc', 3.1059042598363495], + ['#9a3412', '#bae6fd', 5.506356434201567], + ['#7c2d12', '#e0f2fe', 8.166263099299048], + ['#431407', '#f0f9ff', 14.681391442562134] + ] + + pairs.each do |pair| + expect(helper.get_contrast(pair[0], pair[1])).to be_within(0.0001).of(pair[2]) + end + end + end + + describe '#mix' do + it 'mixes at 0.5' do + pairs = [ + ['#fff7ed', '#082f49', '#84939b'], + ['#ffedd5', '#0c4a6e', '#869ca2'], + ['#fed7aa', '#075985', '#839898'], + ['#fdba74', '#0369a1', '#80928b'], + ['#fb923c', '#0284c7', '#7e8b82'], + ['#f97316', '#0ea5e9', '#848c80'], + ['#ea580c', '#38bdf8', '#918b82'], + ['#c2410c', '#7dd3fc', '#a08a84'], + ['#9a3412', '#bae6fd', '#aa8d88'], + ['#7c2d12', '#e0f2fe', '#ae9088'], + ['#431407', '#f0f9ff', '#9a8783'] + ] + + pairs.each do |pair| + expect(helper.mix(pair[0], pair[1], 0.5)).to eq(pair[2]) + end + end + + it 'mixes at 0.2' do + pairs = [ + ['#fff7ed', '#082f49', '#cecfcc'], + ['#ffedd5', '#0c4a6e', '#ceccc0'], + ['#fed7aa', '#075985', '#cdbea3'], + ['#fdba74', '#0369a1', '#cbaa7d'], + ['#fb923c', '#0284c7', '#c98f58'], + ['#f97316', '#0ea5e9', '#ca7d40'], + ['#ea580c', '#38bdf8', '#c66c3b'], + ['#c2410c', '#7dd3fc', '#b45e3c'], + ['#9a3412', '#bae6fd', '#a05841'], + ['#7c2d12', '#e0f2fe', '#905441'], + ['#431407', '#f0f9ff', '#664239'] + ] + + pairs.each do |pair| + expect(helper.mix(pair[0], pair[1], 0.2)).to eq(pair[2]) + end + end + end + + describe '#adjust_color_for_contrast' do + target_ratio = 3.1 + + it 'adjusts a color to meet the contrast ratio against a light background' do + color = '#ff0000' + background_color = '#ffffff' + adjusted_color = helper.adjust_color_for_contrast(color, background_color, target_ratio) + ratio = helper.get_contrast(adjusted_color, background_color) + + expect(ratio).to be >= target_ratio + end + + it 'adjusts a color to meet the contrast ratio against a dark background' do + color = '#00ff00' + background_color = '#000000' + adjusted_color = helper.adjust_color_for_contrast(color, background_color, target_ratio) + ratio = helper.get_contrast(adjusted_color, background_color) + + expect(ratio).to be >= target_ratio + end + + it 'returns a string representation of the color' do + color = '#00ff00' + background_color = '#000000' + adjusted_color = helper.adjust_color_for_contrast(color, background_color, target_ratio) + + expect(adjusted_color).to be_a(String) + end + + it 'handles cases where the color already meets the contrast ratio' do + color = '#000000' + background_color = '#ffffff' + adjusted_color = helper.adjust_color_for_contrast(color, background_color, target_ratio) + ratio = helper.get_contrast(adjusted_color, background_color) + + expect(ratio).to be >= target_ratio + expect(adjusted_color).to eq(color) + end + + it 'does not modify a color that already exceeds the contrast ratio' do + color = '#000000' + background_color = '#ffffff' + adjusted_color = helper.adjust_color_for_contrast(color, background_color, target_ratio) + + expect(adjusted_color).to eq(color) + end + end +end