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.
This commit is contained in:
Osama Sayegh
2025-01-23 15:54:49 +03:00
committed by GitHub
parent 13f86c99ea
commit 10f34ddf86
10 changed files with 193 additions and 41 deletions

View File

@ -555,8 +555,12 @@ module ApplicationHelper
end end
def dark_scheme_id def dark_scheme_id
cookies[:dark_scheme_id] || current_user&.user_option&.dark_scheme_id || if SiteSetting.use_overhauled_theme_color_palette
SiteSetting.default_dark_mode_color_scheme_id scheme_id
else
cookies[:dark_scheme_id] || current_user&.user_option&.dark_scheme_id ||
SiteSetting.default_dark_mode_color_scheme_id
end
end end
def current_homepage def current_homepage
@ -638,6 +642,7 @@ module ApplicationHelper
result << stylesheet_manager.color_scheme_stylesheet_preload_tag( result << stylesheet_manager.color_scheme_stylesheet_preload_tag(
dark_scheme_id, dark_scheme_id,
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
dark: SiteSetting.use_overhauled_theme_color_palette,
) )
end end
@ -657,6 +662,7 @@ module ApplicationHelper
dark_scheme_id, dark_scheme_id,
"(prefers-color-scheme: dark)", "(prefers-color-scheme: dark)",
self.method(:add_resource_preload_list), self.method(:add_resource_preload_list),
dark: SiteSetting.use_overhauled_theme_color_palette,
) )
end end
@ -668,7 +674,7 @@ module ApplicationHelper
if dark_scheme_id != -1 if dark_scheme_id != -1
result << <<~HTML result << <<~HTML
<meta name="theme-color" media="(prefers-color-scheme: light)" content="##{ColorScheme.hex_for_name("header_background", scheme_id)}"> <meta name="theme-color" media="(prefers-color-scheme: light)" content="##{ColorScheme.hex_for_name("header_background", scheme_id)}">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="##{ColorScheme.hex_for_name("header_background", dark_scheme_id)}"> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="##{ColorScheme.hex_for_name("header_background", dark_scheme_id, dark: SiteSetting.use_overhauled_theme_color_palette)}">
HTML HTML
else else
result << <<~HTML result << <<~HTML

View File

@ -409,16 +409,18 @@ class ColorScheme < ActiveRecord::Base
new_color_scheme new_color_scheme
end end
def self.lookup_hex_for_name(name, scheme_id = nil) 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 = find_by(id: scheme_id) if scheme_id
enabled_color_scheme ||= Theme.where(id: SiteSetting.default_theme_id).first&.color_scheme enabled_color_scheme ||= Theme.where(id: SiteSetting.default_theme_id).first&.color_scheme
(enabled_color_scheme || base).colors.find { |c| c.name == name }.try(:hex) 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 end
def self.hex_for_name(name, scheme_id = nil) def self.hex_for_name(name, scheme_id = nil, dark: false)
hex_cache.defer_get_set(scheme_id ? name + "_#{scheme_id}" : name) do cache_key = scheme_id ? "#{name}_#{scheme_id}" : name
lookup_hex_for_name(name, scheme_id) cache_key += "_dark" if dark
end hex_cache.defer_get_set(cache_key) { lookup_hex_for_name(name, scheme_id, dark:) }
end end
def colors=(arr) def colors=(arr)
@ -450,10 +452,16 @@ class ColorScheme < ActiveRecord::Base
colors || ColorScheme.base_colors colors || ColorScheme.base_colors
end end
def resolved_colors def resolved_colors(dark: false)
from_base = ColorScheme.base_colors from_base = ColorScheme.base_colors
from_custom_scheme = base_colors from_custom_scheme = base_colors
from_db = colors.map { |c| [c.name, c.hex] }.to_h 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) resolved = from_base.merge(from_custom_scheme).except("hover", "selected").merge(from_db)

View File

@ -4,6 +4,7 @@ class ColorSchemeColor < ActiveRecord::Base
belongs_to :color_scheme belongs_to :color_scheme
validates :hex, format: { with: /\A([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/ } validates :hex, format: { with: /\A([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/ }
validates :dark_hex, format: { with: /\A([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/ }, allow_nil: true
def hex_with_hash def hex_with_hash
"##{hex}" "##{hex}"
@ -20,6 +21,7 @@ end
# color_scheme_id :integer not null # color_scheme_id :integer not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# dark_hex :string(6)
# #
# Indexes # Indexes
# #

View File

@ -3522,3 +3522,6 @@ experimental:
type: group_list type: group_list
list_type: compact list_type: compact
area: "group_permissions" area: "group_permissions"
use_overhauled_theme_color_palette:
default: false
hidden: true

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddDarkHexToColorSchemeColor < ActiveRecord::Migration[7.2]
def change
add_column :color_scheme_colors, :dark_hex, :string, limit: 6
end
end

View File

@ -145,19 +145,21 @@ module Stylesheet
if @color_scheme_id if @color_scheme_id
colors = colors =
begin begin
ColorScheme.find(@color_scheme_id).resolved_colors ColorScheme.find(@color_scheme_id).resolved_colors(dark: @dark)
rescue StandardError rescue StandardError
ColorScheme.base_colors ColorScheme.base_colors
end end
elsif (@theme_id && !theme.component) elsif (@theme_id && !theme.component)
colors = theme&.color_scheme&.resolved_colors || ColorScheme.base_colors colors = theme&.color_scheme&.resolved_colors(dark: @dark) || ColorScheme.base_colors
else else
# this is a slightly ugly backwards compatibility fix, # this is a slightly ugly backwards compatibility fix,
# we shouldn't be using the default theme color scheme for components # we shouldn't be using the default theme color scheme for components
# (most components use CSS custom properties which work fine without this) # (most components use CSS custom properties which work fine without this)
colors = colors =
Theme.find_by_id(SiteSetting.default_theme_id)&.color_scheme&.resolved_colors || Theme
ColorScheme.base_colors .find_by_id(SiteSetting.default_theme_id)
&.color_scheme
&.resolved_colors(dark: @dark) || ColorScheme.base_colors
end end
colors.each { |n, hex| contents << "$#{n}: ##{hex} !default; " } colors.each { |n, hex| contents << "$#{n}: ##{hex} !default; " }
@ -178,6 +180,7 @@ module Stylesheet
@theme = options[:theme] @theme = options[:theme]
@theme_id = options[:theme_id] @theme_id = options[:theme_id]
@color_scheme_id = options[:color_scheme_id] @color_scheme_id = options[:color_scheme_id]
@dark = options[:dark]
if @theme && !@theme_id if @theme && !@theme_id
# make up an id so other stuff does not bail out # make up an id so other stuff does not bail out

View File

@ -42,10 +42,11 @@ class Stylesheet::Manager
cache.clear_regex(/#{plugin}/) cache.clear_regex(/#{plugin}/)
end end
def self.color_scheme_cache_key(color_scheme, theme_id = nil) def self.color_scheme_cache_key(color_scheme, theme_id = nil, dark: false)
color_scheme_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s color_scheme_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s
theme_string = theme_id ? "_theme#{theme_id}" : "" theme_string = theme_id ? "_theme#{theme_id}" : ""
"#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}_#{theme_string}_#{Discourse.current_hostname}" dark_string = dark ? "_dark" : ""
"#{COLOR_SCHEME_STYLESHEET}_#{color_scheme_name}_#{theme_string}_#{Discourse.current_hostname}#{dark_string}"
end end
def self.precompile_css def self.precompile_css
@ -114,14 +115,17 @@ class Stylesheet::Manager
theme = manager.get_theme(theme_id) theme = manager.get_theme(theme_id)
[theme_color_scheme, *color_schemes].compact.uniq.each do |scheme| [theme_color_scheme, *color_schemes].compact.uniq.each do |scheme|
$stderr.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{theme.name} (#{scheme.name})" [true, false].each do |dark|
mode = dark ? "dark" : "light"
Stylesheet::Manager::Builder.new( $stderr.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{theme.name} (#{scheme.name}) (#{mode})"
target: COLOR_SCHEME_STYLESHEET, Stylesheet::Manager::Builder.new(
theme: theme, target: COLOR_SCHEME_STYLESHEET,
color_scheme: scheme, theme: theme,
manager: manager, color_scheme: scheme,
).compile(force: true) manager: manager,
dark:,
).compile(force: true)
end
end end
clear_color_scheme_cache! clear_color_scheme_cache!
@ -336,7 +340,7 @@ class Stylesheet::Manager
end end
end end
def color_scheme_stylesheet_details(color_scheme_id = nil, media) def color_scheme_stylesheet_details(color_scheme_id = nil, media, dark: false)
theme_id = @theme_id || SiteSetting.default_theme_id theme_id = @theme_id || SiteSetting.default_theme_id
color_scheme = color_scheme =
@ -353,10 +357,10 @@ class Stylesheet::Manager
target = COLOR_SCHEME_STYLESHEET.to_sym target = COLOR_SCHEME_STYLESHEET.to_sym
current_hostname = Discourse.current_hostname current_hostname = Discourse.current_hostname
cache_key = self.class.color_scheme_cache_key(color_scheme, theme_id) cache_key = self.class.color_scheme_cache_key(color_scheme, theme_id, dark:)
cache.defer_get_set(cache_key) do cache.defer_get_set(cache_key) do
stylesheet = { color_scheme_id: color_scheme.id } stylesheet = { color_scheme_id: color_scheme.id, dark: }
theme = get_theme(theme_id) theme = get_theme(theme_id)
@ -366,6 +370,7 @@ class Stylesheet::Manager
theme: get_theme(theme_id), theme: get_theme(theme_id),
color_scheme: color_scheme, color_scheme: color_scheme,
manager: self, manager: self,
dark:,
) )
builder.compile unless File.exist?(builder.stylesheet_fullpath) builder.compile unless File.exist?(builder.stylesheet_fullpath)
@ -376,8 +381,8 @@ class Stylesheet::Manager
end end
end end
def color_scheme_stylesheet_preload_tag(color_scheme_id = nil, media = "all") def color_scheme_stylesheet_preload_tag(color_scheme_id = nil, media = "all", dark: false)
stylesheet = color_scheme_stylesheet_details(color_scheme_id, media) stylesheet = color_scheme_stylesheet_details(color_scheme_id, media, dark:)
return "" if !stylesheet return "" if !stylesheet
@ -386,8 +391,13 @@ class Stylesheet::Manager
%[<link href="#{href}" rel="preload" as="style"/>].html_safe %[<link href="#{href}" rel="preload" as="style"/>].html_safe
end end
def color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = "all", preload_callback = nil) def color_scheme_stylesheet_link_tag(
stylesheet = color_scheme_stylesheet_details(color_scheme_id, media) color_scheme_id = nil,
media = "all",
preload_callback = nil,
dark: false
)
stylesheet = color_scheme_stylesheet_details(color_scheme_id, media, dark:)
return "" if !stylesheet return "" if !stylesheet

View File

@ -3,11 +3,12 @@
class Stylesheet::Manager::Builder class Stylesheet::Manager::Builder
attr_reader :theme attr_reader :theme
def initialize(target: :desktop, theme: nil, color_scheme: nil, manager:) def initialize(target: :desktop, theme: nil, color_scheme: nil, manager:, dark: false)
@target = target @target = target
@theme = theme @theme = theme
@color_scheme = color_scheme @color_scheme = color_scheme
@manager = manager @manager = manager
@dark = dark
end end
def compile(opts = {}) def compile(opts = {})
@ -46,6 +47,7 @@ class Stylesheet::Manager::Builder
source_map_file: source_map_url_relative_from_stylesheet, source_map_file: source_map_url_relative_from_stylesheet,
color_scheme_id: @color_scheme&.id, color_scheme_id: @color_scheme&.id,
load_paths: load_paths, load_paths: load_paths,
dark: @dark,
) )
rescue SassC::SyntaxError, SassC::NotRenderedError => e rescue SassC::SyntaxError, SassC::NotRenderedError => e
if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s) if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s)
@ -119,13 +121,14 @@ class Stylesheet::Manager::Builder
end end
def qualified_target def qualified_target
dark_string = @dark ? "_dark" : ""
if is_theme? if is_theme?
"#{@target}_#{theme&.id}" "#{@target}_#{theme&.id}"
elsif @color_scheme elsif @color_scheme
"#{@target}_#{scheme_slug}_#{@color_scheme&.id}_#{@theme&.id}" "#{@target}_#{scheme_slug}_#{@color_scheme&.id}_#{@theme&.id}#{dark_string}"
else else
scheme_string = theme&.color_scheme ? "_#{theme.color_scheme.id}" : "" scheme_string = theme&.color_scheme ? "_#{theme.color_scheme.id}" : ""
"#{@target}#{scheme_string}" "#{@target}#{scheme_string}#{dark_string}"
end end
end end
@ -245,8 +248,9 @@ class Stylesheet::Manager::Builder
digest_string = "#{current_hostname}-" digest_string = "#{current_hostname}-"
if cs if cs
theme_color_defs = resolve_baked_field(:common, :color_definitions) theme_color_defs = resolve_baked_field(:common, :color_definitions)
dark_string = @dark ? "-dark" : ""
digest_string += digest_string +=
"#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}" "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}#{dark_string}"
else else
digest_string += "defaults-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}" digest_string += "defaults-#{Stylesheet::Manager.fs_asset_cachebuster}-#{fonts}"

View File

@ -877,12 +877,20 @@ RSpec.describe ApplicationHelper do
describe "#discourse_theme_color_meta_tags" do describe "#discourse_theme_color_meta_tags" do
before do before do
light = Fabricate(:color_scheme) light = Fabricate(:color_scheme)
light.color_scheme_colors << ColorSchemeColor.new(name: "header_background", hex: "abcdef") light.color_scheme_colors << ColorSchemeColor.new(
name: "header_background",
hex: "abcdef",
dark_hex: "fedcba",
)
light.save! light.save!
helper.request.cookies["color_scheme_id"] = light.id helper.request.cookies["color_scheme_id"] = light.id
dark = Fabricate(:color_scheme) dark = Fabricate(:color_scheme)
dark.color_scheme_colors << ColorSchemeColor.new(name: "header_background", hex: "defabc") dark.color_scheme_colors << ColorSchemeColor.new(
name: "header_background",
hex: "defabc",
dark_hex: "cbafed",
)
dark.save! dark.save!
helper.request.cookies["dark_scheme_id"] = dark.id helper.request.cookies["dark_scheme_id"] = dark.id
end end
@ -902,6 +910,17 @@ RSpec.describe ApplicationHelper do
<meta name="theme-color" media="all" content="#abcdef"> <meta name="theme-color" media="all" content="#abcdef">
HTML HTML
end end
context "when use_overhauled_theme_color_palette setting is true" do
before { SiteSetting.use_overhauled_theme_color_palette = true }
it "renders a light and dark theme-color meta tag using the light and dark palettes of the same color scheme record" do
expect(helper.discourse_theme_color_meta_tags).to eq(<<~HTML)
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#abcdef">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#fedcba">
HTML
end
end
end end
describe "#discourse_color_scheme_meta_tag" do describe "#discourse_color_scheme_meta_tag" do
@ -944,4 +963,27 @@ RSpec.describe ApplicationHelper do
HTML HTML
end end
end end
describe "#dark_scheme_id" do
fab!(:dark_scheme) { Fabricate(:color_scheme) }
fab!(:light_scheme) { Fabricate(:color_scheme) }
before do
helper.request.cookies["color_scheme_id"] = light_scheme.id
helper.request.cookies["dark_scheme_id"] = dark_scheme.id
end
it "returns the value set in the dark_scheme_id cookie" do
expect(helper.dark_scheme_id).to eq(dark_scheme.id)
end
context "when use_overhauled_theme_color_palette is true" do
before { SiteSetting.use_overhauled_theme_color_palette = true }
it "returns the same value as #scheme_id" do
expect(helper.dark_scheme_id).to eq(helper.scheme_id)
expect(helper.scheme_id).to eq(light_scheme.id)
end
end
end
end end

View File

@ -676,6 +676,73 @@ RSpec.describe Stylesheet::Manager do
expect(link).to include("/stylesheets/color_definitions_funky-bunch_#{cs.id}_") expect(link).to include("/stylesheets/color_definitions_funky-bunch_#{cs.id}_")
end end
it "generates the dark mode of a color scheme when the dark option is specified" do
scheme = ColorScheme.create_from_base(name: "Neutral", base_scheme_id: "Neutral")
ColorSchemeRevisor.revise(
scheme,
colors: [{ name: "primary", hex: "CABFAF", dark_hex: "FAFCAB" }],
)
theme = Fabricate(:theme)
manager = manager(theme.id)
dark_stylesheet =
Stylesheet::Manager::Builder.new(
target: :color_definitions,
theme: theme,
color_scheme: scheme,
manager: manager,
dark: true,
).compile
light_stylesheet =
Stylesheet::Manager::Builder.new(
target: :color_definitions,
theme: theme,
color_scheme: scheme,
manager: manager,
).compile
expect(light_stylesheet).to include("--primary: #CABFAF;")
expect(light_stylesheet).to include("color_definitions_neutral_#{scheme.id}_#{theme.id}")
expect(light_stylesheet).not_to include(
"color_definitions_neutral_#{scheme.id}_#{theme.id}_dark",
)
expect(dark_stylesheet).to include("--primary: #FAFCAB;")
expect(dark_stylesheet).to include("color_definitions_neutral_#{scheme.id}_#{theme.id}_dark")
end
it "uses the light colors as fallback if the dark scheme doesn't define them" do
scheme = ColorScheme.create_from_base(name: "Neutral", base_scheme_id: "Neutral")
ColorSchemeRevisor.revise(scheme, colors: [{ name: "primary", hex: "BACFAB", dark_hex: nil }])
theme = Fabricate(:theme)
manager = manager(theme.id)
dark_stylesheet =
Stylesheet::Manager::Builder.new(
target: :color_definitions,
theme: theme,
color_scheme: scheme,
manager: manager,
dark: true,
).compile
light_stylesheet =
Stylesheet::Manager::Builder.new(
target: :color_definitions,
theme: theme,
color_scheme: scheme,
manager: manager,
).compile
expect(light_stylesheet).to include("--primary: #BACFAB;")
expect(light_stylesheet).to include("color_definitions_neutral_#{scheme.id}_#{theme.id}")
expect(light_stylesheet).not_to include(
"color_definitions_neutral_#{scheme.id}_#{theme.id}_dark",
)
expect(dark_stylesheet).to include("--primary: #BACFAB;")
expect(dark_stylesheet).to include("color_definitions_neutral_#{scheme.id}_#{theme.id}_dark")
end
it "updates outputted colors when updating a color scheme" do it "updates outputted colors when updating a color scheme" do
scheme = ColorScheme.create_from_base(name: "Neutral", base_scheme_id: "Neutral") scheme = ColorScheme.create_from_base(name: "Neutral", base_scheme_id: "Neutral")
theme = Fabricate(:theme) theme = Fabricate(:theme)
@ -905,7 +972,7 @@ RSpec.describe Stylesheet::Manager do
# Ensure we force compile each theme only once # Ensure we force compile each theme only once
expect(output.scan(/#{child_theme_with_css.name}/).length).to eq(2) expect(output.scan(/#{child_theme_with_css.name}/).length).to eq(2)
expect(StylesheetCache.count).to eq(22) # (3 themes * 2 targets) + 16 color schemes (2 themes * 8 color schemes (7 defaults + 1 theme scheme)) expect(StylesheetCache.count).to eq(38) # (3 themes * 2 targets) + 32 color schemes (2 themes * 8 color schemes (7 defaults + 1 theme scheme) * 2 (light and dark mode per scheme))
end end
it "generates precompiled CSS - core and themes" do it "generates precompiled CSS - core and themes" do
@ -913,7 +980,7 @@ RSpec.describe Stylesheet::Manager do
Stylesheet::Manager.precompile_theme_css Stylesheet::Manager.precompile_theme_css
results = StylesheetCache.pluck(:target) results = StylesheetCache.pluck(:target)
expect(results.size).to eq(30) # 11 core targets + 9 theme + 10 color schemes expect(results.size).to eq(46) # 8 core targets + 6 theme + 32 color schemes (light and dark mode per scheme)
theme_targets.each do |tar| theme_targets.each do |tar|
expect( expect(
@ -929,7 +996,7 @@ RSpec.describe Stylesheet::Manager do
Stylesheet::Manager.precompile_theme_css Stylesheet::Manager.precompile_theme_css
results = StylesheetCache.pluck(:target) results = StylesheetCache.pluck(:target)
expect(results.size).to eq(30) # 11 core targets + 9 theme + 10 color schemes expect(results.size).to eq(46) # 8 core targets + 6 theme + 32 color schemes (light and dark mode per scheme)
expect(results).to include("color_definitions_#{scheme1.name}_#{scheme1.id}_#{user_theme.id}") expect(results).to include("color_definitions_#{scheme1.name}_#{scheme1.id}_#{user_theme.id}")
expect(results).to include( expect(results).to include(