From 7b5bc89b7c162912e41b3fc94ce8cd61befef079 Mon Sep 17 00:00:00 2001 From: Ben Halpern Date: Tue, 1 Sep 2020 05:16:31 -0400 Subject: [PATCH] Allow admins to set brand color (#10097) * Allow admins to set brand color * Remove extra line * Fix linting * Update app/controllers/admin/configs_controller.rb Co-authored-by: Michael Kohl * Move wcag compare to own class * Remove unnecessary spacing Co-authored-by: Michael Kohl --- Gemfile | 1 + Gemfile.lock | 2 ++ .../stylesheets/components/buttons.scss | 16 +++++++++++ app/controllers/admin/configs_controller.rb | 20 +++++++++++++ app/lib/constants/site_config.rb | 4 +++ app/models/site_config.rb | 1 + app/services/color/accessibility.rb | 11 +++++++ app/views/admin/configs/show.html.erb | 11 ++++++- app/views/shell/_top.html.erb | 11 ++++++- spec/requests/admin/configs_spec.rb | 22 ++++++++++++++ spec/services/color/accessibility_spec.rb | 27 ++++++++++++++++++ vendor/cache/wcag_color_contrast-0.1.0.gem | Bin 0 -> 7168 bytes 12 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 app/services/color/accessibility.rb create mode 100644 spec/services/color/accessibility_spec.rb create mode 100644 vendor/cache/wcag_color_contrast-0.1.0.gem diff --git a/Gemfile b/Gemfile index 50715b048..046c6f72c 100644 --- a/Gemfile +++ b/Gemfile @@ -108,6 +108,7 @@ gem "uglifier", "~> 4.2" # Uglifier minifies JavaScript files gem "ulid", "~> 1.2" # Universally Unique Lexicographically Sortable Identifier implementation for Ruby gem "validate_url", "~> 1.0" # Library for validating urls in Rails gem "vault", "~> 0.15" # Used to store secrets +gem "wcag_color_contrast", "~> 0.1" # Detect contrast of colors to determine readability and a11y. gem "webpacker", "~> 5.2.1" # Use webpack to manage app-like JavaScript modules in Rails group :development do diff --git a/Gemfile.lock b/Gemfile.lock index f4c5ee119..a2f6da871 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -815,6 +815,7 @@ GEM equalizer (~> 0.0, >= 0.0.9) warden (1.2.8) rack (>= 2.0.6) + wcag_color_contrast (0.1.0) web-console (4.0.4) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -995,6 +996,7 @@ DEPENDENCIES validate_url (~> 1.0) vault (~> 0.15) vcr (~> 6.0) + wcag_color_contrast (~> 0.1) web-console (~> 4.0) webdrivers (~> 4.4) webmock (~> 3.8) diff --git a/app/assets/stylesheets/components/buttons.scss b/app/assets/stylesheets/components/buttons.scss index e1a2d1699..37f585369 100644 --- a/app/assets/stylesheets/components/buttons.scss +++ b/app/assets/stylesheets/components/buttons.scss @@ -12,6 +12,10 @@ --brand-github-bg: #24292e; --brand-github-color: #fff; --brand-github-bg-hover: #000; + + --brand-facebook-bg: #4267B2; + --brand-facebook-color: #fff; + --brand-facebook-bg-hover: #476fbf; } // Basic styling @@ -261,6 +265,18 @@ --color-inverted-hover: var(--brand-github-color); } +.crayons-btn--brand-facebook { + --bg: var(--brand-facebook-bg); + --bg-hover: var(--brand-facebook-bg-hover); + --color: var(--brand-facebook-color); + --color-hover: var(--brand-facebook-color); + --bg-inverted: var(--brand-facebook-bg); + --bg-inverted-hover: var(--brand-facebook-bg-hover); + --color-inverted: var(--brand-facebook-color); + --color-inverted-hover: var(--brand-facebook-color); +} + + // Icon alone .crayons-btn--icon, .crayons-btn--icon-rounded { diff --git a/app/controllers/admin/configs_controller.rb b/app/controllers/admin/configs_controller.rb index dd9d5fb53..0d8d11a47 100644 --- a/app/controllers/admin/configs_controller.rb +++ b/app/controllers/admin/configs_controller.rb @@ -3,6 +3,7 @@ module Admin layout "admin" before_action :extra_authorization_and_confirmation, only: [:create] + before_action :validate_inputs, only: [:create] def show @confirmation_text = confirmation_text @@ -54,6 +55,7 @@ module Admin facebook_key facebook_secret allow_email_password_registration + primary_brand_color_hex ] allowed_params = allowed_params | @@ -87,6 +89,13 @@ module Admin raise_confirmation_mismatch_error if params.require(:confirmation) != confirmation_text end + def validate_inputs + errors = [] + errors << "Brand color must be darker for accessibility." if brand_contrast_too_low + errors << "Brand color must be be a 6 character hex (starting with #)." if brand_color_not_hex + redirect_to admin_config_path, alert: "😭 #{errors.join(',')}" if errors.any? + end + def clean_up_params config = params[:site_config] %i[sidebar_tags suggested_tags suggested_users].each do |param| @@ -103,6 +112,17 @@ module Admin Rails.cache.delete_matched(ApplicationConfig["RELEASE_FOOTPRINT"]) # Delete all caches tied to this key. end + # Validations + def brand_contrast_too_low + hex = params[:site_config][:primary_brand_color_hex] + hex.present? && Color::Accessibility.new(hex).low_contrast? + end + + def brand_color_not_hex + hex = params[:site_config][:primary_brand_color_hex] + hex.present? && !hex.match?(/\A#(\h{6}|\h{3})\z/) + end + def campaign_params %i[ campaign_featured_tags diff --git a/app/lib/constants/site_config.rb b/app/lib/constants/site_config.rb index a44af0be1..c4448ebef 100644 --- a/app/lib/constants/site_config.rb +++ b/app/lib/constants/site_config.rb @@ -101,6 +101,10 @@ module Constants description: "Determines which default feed the users sees (rich content, more minimal, etc.)", placeholder: "basic, rich, or compact" }, + primary_brand_color_hex: { + description: "Determines background/border of buttons etc. Must be dark enough to contrast with white text.", + placeholder: "#0a0a0a" + }, github_key: { description: "The \"Client ID\" portion of the GitHub Oauth Apps portal", placeholder: "" diff --git a/app/models/site_config.rb b/app/models/site_config.rb index 764627dd1..dafee7215 100644 --- a/app/models/site_config.rb +++ b/app/models/site_config.rb @@ -153,6 +153,7 @@ class SiteConfig < RailsSettings::Base field :public, type: :boolean, default: 0 # The default font for all users that have not chosen a custom font yet field :default_font, type: :string, default: "sans_serif" + field :primary_brand_color_hex, type: :string, default: "#3b49df" # Broadcast field :welcome_notifications_live_at, type: :date diff --git a/app/services/color/accessibility.rb b/app/services/color/accessibility.rb new file mode 100644 index 000000000..a2c381e77 --- /dev/null +++ b/app/services/color/accessibility.rb @@ -0,0 +1,11 @@ +module Color + class Accessibility + def initialize(hex) + @hex = hex.delete("#") + end + + def low_contrast?(compared_color = "ffffff", min_contrast = 4.5) + WCAGColorContrast.ratio(@hex, compared_color.delete("#")) < min_contrast + end + end +end diff --git a/app/views/admin/configs/show.html.erb b/app/views/admin/configs/show.html.erb index 05f1afd09..ba24a205b 100644 --- a/app/views/admin/configs/show.html.erb +++ b/app/views/admin/configs/show.html.erb @@ -931,7 +931,7 @@
<%= render partial: "card_header", locals: { - header: "User Experience", + header: "User Experience and Brand", state: "collapse", target: "uxBodyContainer", expanded: "false" @@ -956,6 +956,15 @@ class: "form-control selectpicker" %>
<%= Constants::SiteConfig::DETAILS[:default_font][:description] %>
+
+ <%= f.label :primary_brand_color_hex %> + <%= f.text_field :primary_brand_color_hex, + class: "form-control", + value: SiteConfig.primary_brand_color_hex, + pattern: "^#+([a-fA-F0-9]{6})$", + placeholder: Constants::SiteConfig::DETAILS[:primary_brand_color_hex][:placeholder] %> +
<%= Constants::SiteConfig::DETAILS[:primary_brand_color_hex][:description] %>
+
<%= admin_config_label :public %> <%= f.check_box :public, checked: SiteConfig.public %> diff --git a/app/views/shell/_top.html.erb b/app/views/shell/_top.html.erb index b00a45225..502ba964c 100644 --- a/app/views/shell/_top.html.erb +++ b/app/views/shell/_top.html.erb @@ -55,7 +55,16 @@ class="<%= SiteConfig.default_font.tr("_", "-") %>-article-body" data-pusher-key="<%= ApplicationConfig["PUSHER_KEY"] %>" data-app-name="<%= ApplicationConfig["APP_NAME"] %>"> -
+
+ +
<% if user_signed_in? %> <%= render "layouts/user_config" %> <% end %> diff --git a/spec/requests/admin/configs_spec.rb b/spec/requests/admin/configs_spec.rb index 46590355d..56a16b706 100644 --- a/spec/requests/admin/configs_spec.rb +++ b/spec/requests/admin/configs_spec.rb @@ -589,6 +589,28 @@ RSpec.describe "/admin/config", type: :request do expect(SiteConfig.feed_style).to eq(feed_style) end + it "updates the brand color if proper hex" do + hex = "#0a0a0a" # dark enough + post "/admin/config", params: { site_config: { primary_brand_color_hex: hex }, + confirmation: confirmation_message } + expect(SiteConfig.primary_brand_color_hex).to eq(hex) + end + + it "does not update brand color if hex not contrasting enough" do + hex = "#bd746f" # not dark enough + post "/admin/config", params: { site_config: { primary_brand_color_hex: hex }, + confirmation: confirmation_message } + expect(SiteConfig.primary_brand_color_hex).not_to eq(hex) + end + + it "does not update brand color if hex not a hex with proper format" do + hex = "0a0a0a" # dark enough, but not proper format + post "/admin/config", params: { site_config: { primary_brand_color_hex: hex }, + confirmation: confirmation_message } + expect(SiteConfig.primary_brand_color_hex).not_to eq(hex) + end + + it "updates public to true" do is_public = true post "/admin/config", params: { site_config: { public: is_public }, diff --git a/spec/services/color/accessibility_spec.rb b/spec/services/color/accessibility_spec.rb new file mode 100644 index 000000000..cb26f95b1 --- /dev/null +++ b/spec/services/color/accessibility_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe Color::Accessibility, type: :service do + it "determines low contrast with default compared color" do + expect(described_class.new("#8a9bb8").low_contrast?).to be true + end + + it "determines low contrast with compared color input" do + expect(described_class.new("#041d4a").low_contrast?("#1a3669")).to be true + end + + it "determines low contrast with compared color and rate" do + expect(described_class.new("#225CC9").low_contrast?("#ffffff", 10.0)).to be true + end + + it "determines sufficient contrast with default compared color" do + expect(described_class.new("#041d4a").low_contrast?).to be false + end + + it "determines sufficient contrast with compared color input" do + expect(described_class.new("#041d4a").low_contrast?("#e6eaf0")).to be false + end + + it "determines sufficient contrast with compared color and rate" do + expect(described_class.new("#225CC9").low_contrast?("#ffffff", 1.0)).to be false + end +end diff --git a/vendor/cache/wcag_color_contrast-0.1.0.gem b/vendor/cache/wcag_color_contrast-0.1.0.gem new file mode 100644 index 0000000000000000000000000000000000000000..f0417e2e33d15d431f5cdcc2449fece6aa12164b GIT binary patch literal 7168 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 Gfqwzv$h`>w literal 0 HcmV?d00001