Files
discourse/app/models/color_scheme.rb
Osama Sayegh 10f34ddf86 DEV: Backend support for light/dark mode in color palettes (#30893)
We're embarking on a project for overhauling the color palette and theme
systems in Discourse. As part of this project, we're making each color
palette include light and dark modes instead of the status quo of
requiring 2 separate color palettes to implement light and dark modes.

This commit is a first step towards that goal; it adds a code path for
generating and serving `color_definitions` stylesheets using the
built-in dark variant of a color palette. All of this code path is
behind a default-off site setting `use_overhauled_theme_color_palette`,
so there's no change in behavior unless the setting is enabled.

Internal topic: t/141467.
2025-01-23 15:54:49 +03:00

549 lines
16 KiB
Ruby

# frozen_string_literal: true
class ColorScheme < ActiveRecord::Base
BUILT_IN_SCHEMES = {
Dark: {
"primary" => "dddddd",
"secondary" => "222222",
"tertiary" => "099dd7",
"quaternary" => "c14924",
"header_background" => "111111",
"header_primary" => "dddddd",
"highlight" => "a87137",
"selected" => "052e3d",
"hover" => "313131",
"danger" => "e45735",
"success" => "1ca551",
"love" => "fa6c8d",
},
# By @itsbhanusharma
Neutral: {
"primary" => "000000",
"secondary" => "ffffff",
"tertiary" => "51839b",
"quaternary" => "b85e48",
"header_background" => "333333",
"header_primary" => "f3f3f3",
"highlight" => "ecec70",
"selected" => "e6e6e6",
"hover" => "f0f0f0",
"danger" => "b85e48",
"success" => "518751",
"love" => "fa6c8d",
},
# By @Flower_Child
"Grey Amber": {
"primary" => "d9d9d9",
"secondary" => "3d4147",
"tertiary" => "fdd459",
"quaternary" => "fdd459",
"header_background" => "36393e",
"header_primary" => "d9d9d9",
"highlight" => "fdd459",
"selected" => "272727",
"hover" => "2F2F30",
"danger" => "e45735",
"success" => "fdd459",
"love" => "fdd459",
},
# By @rafafotes
"Shades of Blue": {
"primary" => "203243",
"secondary" => "eef4f7",
"tertiary" => "416376",
"quaternary" => "5e99b9",
"header_background" => "86bddb",
"header_primary" => "203243",
"highlight" => "86bddb",
"selected" => "bee0f2",
"hover" => "d2efff",
"danger" => "bf3c3c",
"success" => "70db82",
"love" => "fc94cb",
},
# By @mikechristopher
Latte: {
"primary" => "f2e5d7",
"secondary" => "262322",
"tertiary" => "f7f2ed",
"quaternary" => "d7c9aa",
"header_background" => "d7c9aa",
"header_primary" => "262322",
"highlight" => "d7c9aa",
"selected" => "3e2a14",
"hover" => "4c3319",
"danger" => "db9584",
"success" => "78be78",
"love" => "8f6201",
},
# By @Flower_Child
Summer: {
"primary" => "874342",
"secondary" => "fffff4",
"tertiary" => "fe9896",
"quaternary" => "fcc9d0",
"header_background" => "96ccbf",
"header_primary" => "fff1e7",
"highlight" => "f3c07f",
"selected" => "f5eaea",
"hover" => "f9f3f3",
"danger" => "cfebdc",
"success" => "fcb4b5",
"love" => "f3c07f",
},
# By @Flower_Child
"Dark Rose": {
"primary" => "ca9cb2",
"secondary" => "3a2a37",
"tertiary" => "fdd459",
"quaternary" => "7e566a",
"header_background" => "a97189",
"header_primary" => "d9b2bb",
"highlight" => "bd36a3",
"selected" => "2a1620",
"hover" => "331b27",
"danger" => "6c3e63",
"success" => "d9b2bb",
"love" => "d9b2bb",
},
WCAG: {
"primary" => "000000",
"primary-medium" => "696969",
"primary-low-mid" => "909090",
"secondary" => "ffffff",
"tertiary" => "0033CC",
"quaternary" => "3369FF",
"header_background" => "ffffff",
"header_primary" => "000000",
"highlight" => "ffff00",
"highlight-high" => "0036E6",
"highlight-medium" => "e0e9ff",
"highlight-low" => "e0e9ff",
"selected" => "E2E9FE",
"hover" => "F0F4FE",
"danger" => "BB1122",
"success" => "3d854d",
"love" => "9D256B",
},
"WCAG Dark": {
"primary" => "ffffff",
"primary-medium" => "999999",
"primary-low-mid" => "888888",
"secondary" => "0c0c0c",
"tertiary" => "759AFF",
"quaternary" => "759AFF",
"header_background" => "000000",
"header_primary" => "ffffff",
"highlight" => "3369FF",
"selected" => "0d2569",
"hover" => "002382",
"danger" => "FF697A",
"success" => "70B880",
"love" => "9D256B",
},
# By @zenorocha
Dracula: {
"primary_very_low" => "373A47",
"primary_low" => "414350",
"primary_low_mid" => "8C8D94",
"primary_medium" => "A3A4AA",
"primary_high" => "CCCCCF",
"primary" => "f2f2f2",
"primary-50" => "3F414E",
"primary-100" => "535460",
"primary-200" => "666972",
"primary-300" => "7A7C84",
"primary-400" => "8D8F96",
"primary-500" => "A2A3A9",
"primary-600" => "B6B7BC",
"primary-700" => "C7C7C7",
"primary-800" => "DEDFE0",
"primary-900" => "F5F5F5",
"secondary_low" => "CCCCCF",
"secondary_medium" => "91939A",
"secondary_high" => "6A6C76",
"secondary_very_high" => "3D404C",
"secondary" => "2d303e",
"tertiary_low" => "4A4463",
"tertiary_medium" => "6E5D92",
"tertiary" => "bd93f9",
"tertiary_high" => "9275C1",
"quaternary_low" => "6AA8BA",
"quaternary" => "8be9fd",
"header_background" => "373A47",
"header_primary" => "f2f2f2",
"highlight_low" => "686D55",
"highlight_medium" => "52592B",
"highlight_high" => "C0C879",
"selected" => "4A4463",
"hover" => "61597f",
"danger_low" => "957279",
"danger" => "ff5555",
"success_low" => "386D50",
"success_medium" => "44B366",
"success" => "50fa7b",
"love_low" => "6C4667",
"love" => "ff79c6",
},
# By @altercation
"Solarized Light": {
"primary_very_low" => "F0ECD7",
"primary_low" => "D6D8C7",
"primary_low_mid" => "A4AFA5",
"primary_medium" => "7E918C",
"primary_high" => "4C6869",
"primary" => "002B36",
"primary-50" => "F0EBDA",
"primary-100" => "DAD8CA",
"primary-200" => "B2B9B3",
"primary-300" => "839496",
"primary-400" => "76898C",
"primary-500" => "697F83",
"primary-600" => "627A7E",
"primary-700" => "556F74",
"primary-800" => "415F66",
"primary-900" => "21454E",
"secondary_low" => "325458",
"secondary_medium" => "6C8280",
"secondary_high" => "97A59D",
"secondary_very_high" => "E8E6D3",
"secondary" => "FCF6E1",
"tertiary_low" => "D6E6DE",
"tertiary_medium" => "7EBFD7",
"tertiary" => "0088cc",
"tertiary_high" => "329ED0",
"quaternary" => "e45735",
"header_background" => "FCF6E1",
"header_primary" => "002B36",
"highlight_low" => "FDF9AD",
"highlight_medium" => "E3D0A3",
"highlight" => "F2F481",
"highlight_high" => "BCAA7F",
"selected" => "E8E6D3",
"hover" => "F0EBDA",
"danger_low" => "F8D9C2",
"danger" => "e45735",
"success_low" => "CFE5B9",
"success_medium" => "4CB544",
"success" => "009900",
"love_low" => "FCDDD2",
"love" => "fa6c8d",
},
# By @altercation
"Solarized Dark": {
"primary_very_low" => "0D353F",
"primary_low" => "193F47",
"primary_low_mid" => "798C88",
"primary_medium" => "97A59D",
"primary_high" => "B5BDB1",
"primary" => "FCF6E1",
"primary-50" => "21454E",
"primary-100" => "415F66",
"primary-200" => "556F74",
"primary-300" => "627A7E",
"primary-400" => "697F83",
"primary-500" => "76898C",
"primary-600" => "839496",
"primary-700" => "B2B9B3",
"primary-800" => "DAD8CA",
"primary-900" => "F0EBDA",
"secondary_low" => "B5BDB1",
"secondary_medium" => "81938D",
"secondary_high" => "4E6A6B",
"secondary_very_high" => "143B44",
"secondary" => "002B36",
"tertiary_low" => "003E54",
"tertiary_medium" => "00557A",
"tertiary" => "1a97d5",
"tertiary_high" => "006C9F",
"quaternary_low" => "944835",
"quaternary" => "e45735",
"header_background" => "002B36",
"header_primary" => "FCF6E1",
"highlight_low" => "4D6B3D",
"highlight_medium" => "464C33",
"highlight" => "F2F481",
"highlight_high" => "BFCA47",
"selected" => "143B44",
"hover" => "21454E",
"danger_low" => "443836",
"danger_medium" => "944835",
"danger" => "e45735",
"success_low" => "004C26",
"success_medium" => "007313",
"success" => "009900",
"love_low" => "4B3F50",
"love" => "fa6c8d",
},
}
LIGHT_THEME_ID = "Light"
def self.base_color_scheme_colors
base_with_hash = []
base_colors.each { |name, color| base_with_hash << { name: name, hex: "#{color}" } }
list = [{ id: LIGHT_THEME_ID, colors: base_with_hash }]
BUILT_IN_SCHEMES.each do |k, v|
colors = []
v.each { |name, color| colors << { name: name, hex: "#{color}" } }
list.push(id: k.to_s, colors: colors)
end
list
end
def self.hex_cache
@hex_cache ||= DistributedCache.new("scheme_hex_for_name")
end
attr_accessor :is_base
attr_accessor :skip_publish
has_many :color_scheme_colors, -> { order("id ASC") }, dependent: :destroy
alias_method :colors, :color_scheme_colors
before_save :bump_version
after_save_commit :publish_discourse_stylesheet, unless: :skip_publish
after_save_commit :dump_caches
after_destroy :dump_caches
belongs_to :theme
has_one :theme_color_scheme
has_one :owning_theme, class_name: "Theme", through: :theme_color_scheme, source: :theme
default_scope do
where("color_schemes.id NOT IN (SELECT color_scheme_id FROM theme_color_schemes)")
end
validates_associated :color_scheme_colors
BASE_COLORS_FILE = "#{Rails.root}/app/assets/stylesheets/common/foundation/colors.scss"
COLOR_TRANSFORMATION_FILE =
"#{Rails.root}/app/assets/stylesheets/common/foundation/color_transformations.scss"
@mutex = Mutex.new
def self.base_colors
return @base_colors if @base_colors
@mutex.synchronize do
return @base_colors if @base_colors
base_colors = {}
File
.readlines(BASE_COLORS_FILE)
.each do |line|
matches = /\$([\w]+):\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})(?:[;]|\s)/.match(line.strip)
base_colors[matches[1]] = matches[2] if matches
end
@base_colors = base_colors
end
@base_colors
end
def self.color_transformation_variables
return @transformation_variables if @transformation_variables
@mutex.synchronize do
return @transformation_variables if @transformation_variables
transformation_variables = []
File
.readlines(COLOR_TRANSFORMATION_FILE)
.each do |line|
matches = /\$([\w\-_]+):.*/.match(line.strip)
transformation_variables.append(matches[1]) if matches
end
@transformation_variables = transformation_variables
end
@transformation_variables
end
def self.base_color_schemes
base_color_scheme_colors.map do |hash|
scheme =
new(
name: I18n.t("color_schemes.#{hash[:id].downcase.gsub(" ", "_")}"),
base_scheme_id: hash[:id],
)
scheme.colors = hash[:colors].map { |k| { name: k[:name], hex: k[:hex] } }
scheme.is_base = true
scheme
end
end
def self.base
return @base_color_scheme if @base_color_scheme
@base_color_scheme = new(name: I18n.t("color_schemes.base_theme_name"))
@base_color_scheme.colors = base_colors.map { |name, hex| { name: name, hex: hex } }
@base_color_scheme.is_base = true
@base_color_scheme
end
def self.is_base?(scheme_name)
base_color_scheme_colors.map { |c| c[:id] }.include?(scheme_name)
end
# create_from_base will create a new ColorScheme that overrides Discourse's base color scheme with the given colors.
def self.create_from_base(params)
new_color_scheme = new(name: params[:name])
new_color_scheme.via_wizard = true if params[:via_wizard]
new_color_scheme.base_scheme_id = params[:base_scheme_id]
new_color_scheme.user_selectable = true
colors =
BUILT_IN_SCHEMES[params[:base_scheme_id].to_sym]&.map do |name, hex|
{ name: name, hex: hex }
end if params[:base_scheme_id]
colors ||= base.colors_hashes
# Override base values
params[:colors].each do |name, hex|
c = colors.find { |x| x[:name].to_s == name.to_s }
c[:hex] = hex
end if params[:colors]
new_color_scheme.colors = colors
new_color_scheme.skip_publish if params[:skip_publish]
new_color_scheme.save
new_color_scheme
end
def self.lookup_hex_for_name(name, scheme_id = nil, dark: false)
enabled_color_scheme = find_by(id: scheme_id) if scheme_id
enabled_color_scheme ||= Theme.where(id: SiteSetting.default_theme_id).first&.color_scheme
color_record = (enabled_color_scheme || base).colors.find { |c| c.name == name }
return if !color_record
dark ? color_record.dark_hex || color_record.hex : color_record.hex
end
def self.hex_for_name(name, scheme_id = nil, dark: false)
cache_key = scheme_id ? "#{name}_#{scheme_id}" : name
cache_key += "_dark" if dark
hex_cache.defer_get_set(cache_key) { lookup_hex_for_name(name, scheme_id, dark:) }
end
def colors=(arr)
@colors_by_name = nil
arr.each { |c| self.color_scheme_colors << ColorSchemeColor.new(name: c[:name], hex: c[:hex]) }
end
def colors_by_name
@colors_by_name ||=
self
.colors
.inject({}) do |sum, c|
sum[c.name] = c
sum
end
end
def clear_colors_cache
@colors_by_name = nil
end
def colors_hashes
color_scheme_colors.map { |c| { name: c.name, hex: c.hex } }
end
def base_colors
colors = nil
colors = BUILT_IN_SCHEMES[base_scheme_id.to_sym] if base_scheme_id && base_scheme_id != "Light"
colors || ColorScheme.base_colors
end
def resolved_colors(dark: false)
from_base = ColorScheme.base_colors
from_custom_scheme = base_colors
from_db =
colors
.map do |c|
hex = dark ? (c.dark_hex || c.hex) : c.hex
[c.name, hex]
end
.to_h
resolved = from_base.merge(from_custom_scheme).except("hover", "selected").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
def publish_discourse_stylesheet
self.class.publish_discourse_stylesheets!(self.id) if self.id
end
def self.publish_discourse_stylesheets!(id = nil)
Stylesheet::Manager.clear_color_scheme_cache!
theme_ids = []
if id
theme_ids = Theme.where(color_scheme_id: id).pluck(:id)
else
theme_ids = Theme.all.pluck(:id)
end
if theme_ids.present?
Stylesheet::Manager.cache.clear
Theme.notify_theme_change(
theme_ids,
with_scheme: true,
clear_manager_cache: false,
all_themes: true,
)
end
end
def dump_caches
self.class.hex_cache.clear
ApplicationSerializer.expire_cache_fragment!("user_color_schemes")
end
def bump_version
self.version += 1 if self.id
end
def is_dark?
return if colors.to_a.empty?
primary_b = ColorMath.brightness(resolved_colors["primary"])
secondary_b = ColorMath.brightness(resolved_colors["secondary"])
primary_b > secondary_b
end
def is_wcag?
base_scheme_id&.start_with?("WCAG")
end
end
# == Schema Information
#
# Table name: color_schemes
#
# id :integer not null, primary key
# name :string not null
# version :integer default(1), not null
# created_at :datetime not null
# updated_at :datetime not null
# via_wizard :boolean default(FALSE), not null
# base_scheme_id :string
# theme_id :integer
# user_selectable :boolean default(FALSE), not null
#