From 66256c15bd81c633d4a363acd262766618573396 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 1 Feb 2023 09:55:21 +0000 Subject: [PATCH] UX: Calculate missing hover/selected colors from existing colors (#20105) `--d-hover` is calculated to be equivalent to primary-100 in light mode, or primary-low in dark mode `--d-selected` is calculated to be equivalent to primary-low in light mode, or primary-100 in dark mode `lib/color_math` is introduced to provide some utilities for making these calculations. --- app/models/color_scheme.rb | 38 +++++---- lib/color_math.rb | 132 +++++++++++++++++++++++++++++++ spec/lib/color_math_spec.rb | 31 ++++++++ spec/models/color_scheme_spec.rb | 28 +++++++ 4 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 lib/color_math.rb create mode 100644 spec/lib/color_math_spec.rb diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index a5df54564e5..f9c2a5098dc 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -448,13 +448,27 @@ class ColorScheme < ActiveRecord::Base end def resolved_colors - resolved = ColorScheme.base_colors.dup - if base_scheme_id && base_scheme_id != "Light" - if scheme = CUSTOM_SCHEMES[base_scheme_id.to_sym] - scheme.each { |name, value| resolved[name] = value } - end - end - colors.each { |c| resolved[c.name] = c.hex } + from_base = base_colors.except("hover", "selected") + from_db = colors.map { |c| [c.name, c.hex] }.to_h + + resolved = from_base.merge(from_db) + + # Equivalent to primary-100 in light mode, or primary-low in dark mode + resolved["hover"] ||= ColorMath.dark_light_diff( + resolved["primary"], + resolved["secondary"], + 0.94, + -0.78, + ) + + # Equivalent to primary-low in light mode, or primary-100 in dark mode + resolved["selected"] ||= ColorMath.dark_light_diff( + resolved["primary"], + resolved["secondary"], + 0.9, + -0.8, + ) + resolved end @@ -495,8 +509,8 @@ class ColorScheme < ActiveRecord::Base def is_dark? return if colors.to_a.empty? - primary_b = brightness(colors_by_name["primary"].hex) - secondary_b = brightness(colors_by_name["secondary"].hex) + primary_b = ColorMath.brightness(resolved_colors["primary"]) + secondary_b = ColorMath.brightness(resolved_colors["secondary"]) primary_b > secondary_b end @@ -504,12 +518,6 @@ class ColorScheme < ActiveRecord::Base def is_wcag? base_scheme_id&.start_with?("WCAG") end - - # Equivalent to dc-color-brightness() in variables.scss - def brightness(color) - rgb = color.scan(/../).map { |c| c.to_i(16) } - (rgb[0].to_i * 299 + rgb[1].to_i * 587 + rgb[2].to_i * 114) / 1000.0 - end end # == Schema Information diff --git a/lib/color_math.rb b/lib/color_math.rb new file mode 100644 index 00000000000..074b9cc4da9 --- /dev/null +++ b/lib/color_math.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module ColorMath + # Equivalent to dc-color-brightness() in variables.scss + def self.brightness(color) + rgb = Converters.hex_to_rgb(color) + (rgb[0].to_i * 299 + rgb[1].to_i * 587 + rgb[2].to_i * 114) / 1000.0 + end + + # Equivalent to dark-light-diff() in variables.scss + def self.dark_light_diff(adjusted_color, comparison_color, lightness, darkness) + if brightness(adjusted_color) < brightness(comparison_color) + scale_color_lightness(adjusted_color, lightness) + else + scale_color_lightness(adjusted_color, darkness) + end + end + + # Equivalent to scale_color(color, lightness: ) in sass + def self.scale_color_lightness(color, adjustment) + rgb = Converters.hex_to_rgb(color) + h, s, l = Converters.rgb_to_hsl(*rgb) + + l = + if adjustment > 0 + l + (100 - l) * adjustment + else + l + l * adjustment + end + + rgb = Converters.hsl_to_rgb(h, s, l) + Converters.rgb_to_hex(rgb) + end + + module Converters + # Adapted from https://github.com/anilyanduri/color_math + # + # The MIT License (MIT) + # + # Copyright (c) 2016 Anil Yanduri + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + + def self.hex_to_rgb(color) + color = color.gsub(/(.)/, '\1\1') if color.length == 3 + raise new RuntimeError("Hex color must be 6 characters") if color.length != 6 + color.scan(/../).map { |c| c.to_i(16) } + end + + def self.rgb_to_hex(rgb) + rgb.map { |c| c.to_s(16).rjust(2, "0") }.join("") + end + + def self.rgb_to_hsl(r, g, b) + r /= 255.0 + g /= 255.0 + b /= 255.0 + max = [r, g, b].max + min = [r, g, b].min + h = (max + min) / 2.0 + s = (max + min) / 2.0 + l = (max + min) / 2.0 + + if (max == min) + h = 0 + s = 0 # achromatic + else + d = max - min + s = l >= 0.5 ? d / (2.0 - max - min) : d / (max + min) + case max + when r + h = (g - b) / d + (g < b ? 6.0 : 0) + when g + h = (b - r) / d + 2.0 + when b + h = (r - g) / d + 4.0 + end + h /= 6.0 + end + [(h * 360).round, (s * 100).round, (l * 100).round] + end + + def self.hsl_to_rgb(h, s, l) + h = h / 360.0 + s = s / 100.0 + l = l / 100.0 + + r = 0.0 + g = 0.0 + b = 0.0 + + if (s == 0.0) + r = l.to_f + g = l.to_f + b = l.to_f #achromatic + else + q = l < 0.5 ? l * (1 + s) : l + s - l * s + p = 2 * l - q + r = hue_to_rgb(p, q, h + 1 / 3.0) + g = hue_to_rgb(p, q, h) + b = hue_to_rgb(p, q, h - 1 / 3.0) + end + + [(r * 255).round, (g * 255).round, (b * 255).round] + end + + def self.hue_to_rgb(p, q, t) + t += 1 if (t < 0) + t -= 1 if (t > 1) + return(p + (q - p) * 6 * t) if (t < 1 / 6.0) + return q if (t < 1 / 2.0) + return(p + (q - p) * (2 / 3.0 - t) * 6) if (t < 2 / 3.0) + p + end + end +end diff --git a/spec/lib/color_math_spec.rb b/spec/lib/color_math_spec.rb new file mode 100644 index 00000000000..82b12119848 --- /dev/null +++ b/spec/lib/color_math_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe ColorMath do + describe "#brightness" do + it "works" do + expect(ColorMath.brightness("000")).to eq(0) + expect(ColorMath.brightness("fff")).to eq(255) + end + end + + describe "#scale_color_lightness" do + it "works" do + expect(ColorMath.scale_color_lightness("000", 0.5)).to eq("808080") + expect(ColorMath.scale_color_lightness("fff", -0.5)).to eq("808080") + end + + it "works with non-greyscale colors" do + expect(ColorMath.scale_color_lightness("f00", 0.5)).to eq("ff8080") + end + end + + describe "#dark_light_diff" do + it "darkens by requested amount if target color is darker than comparison" do + expect(ColorMath.dark_light_diff("fff", "eee", 0, -0.5)).to eq("808080") + end + + it "lightens by requested amount if target color is lighter than comparison" do + expect(ColorMath.dark_light_diff("000", "eee", 0.5, 0)).to eq("808080") + end + end +end diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb index 7e1bd06e16a..987933c1e0c 100644 --- a/spec/models/color_scheme_spec.rb +++ b/spec/models/color_scheme_spec.rb @@ -134,4 +134,32 @@ RSpec.describe ColorScheme do ) end end + + describe "#resolved_colors" do + it "merges database colors with base scheme" do + color_scheme = ColorScheme.new + color_scheme.color_scheme_colors << ColorSchemeColor.new(name: "primary", hex: "121212") + resolved = color_scheme.resolved_colors + expect(resolved["primary"]).to eq("121212") + expect(resolved["secondary"]).to eq(ColorScheme.base_colors["secondary"]) + end + + it "calculates 'hover' and 'selected' from existing db colors in dark mode" do + color_scheme = ColorScheme.new + color_scheme.color_scheme_colors << ColorSchemeColor.new(name: "primary", hex: "ddd") + color_scheme.color_scheme_colors << ColorSchemeColor.new(name: "secondary", hex: "222") + resolved = color_scheme.resolved_colors + expect(resolved["hover"]).to eq("313131") + expect(resolved["selected"]).to eq("2c2c2c") + end + + it "calculates 'hover' and 'selected' from existing db colors in light mode" do + color_scheme = ColorScheme.new + color_scheme.color_scheme_colors << ColorSchemeColor.new(name: "primary", hex: "222") + color_scheme.color_scheme_colors << ColorSchemeColor.new(name: "secondary", hex: "fff") + resolved = color_scheme.resolved_colors + expect(resolved["hover"]).to eq("f2f2f2") + expect(resolved["selected"]).to eq("e9e9e9") + end + end end