FEATURE: Dark/light mode selector (#31086)

This commit makes the
[color-scheme-toggle](https://github.com/discourse/discourse-color-scheme-toggle)
theme component a core feature with improvements and bug fixes. The
theme component will be updated to become a no-op if the core feature is
enabled.

Noteworthy changes:

* the color mode selector has a new "Auto" option that makes the site
render in the same color mode as the user's system preference
* the splash screen respects the color mode selected by the user
* dark/light variants of category logos and background images are now
picked correctly based on the selected color mode
* a new `interface_color_selector` site setting to disable the selector
or choose its location between the sidebar footer or header

Internal topic: t/139465.

---------

Co-authored-by: Ella <ella.estigoy@gmail.com>
This commit is contained in:
Osama Sayegh
2025-02-07 03:28:34 +03:00
committed by GitHub
parent 8c968c588c
commit 284e708e67
30 changed files with 1065 additions and 111 deletions

View File

@ -672,6 +672,105 @@ RSpec.describe ApplicationController do
expect(response.status).to eq(200)
expect(response.body).not_to include("d-splash")
end
context "with color schemes" do
let!(:light_scheme) { ColorScheme.find_by(base_scheme_id: "Solarized Light") }
let!(:dark_scheme) { ColorScheme.find_by(base_scheme_id: "Dark") }
before do
SiteSetting.default_dark_mode_color_scheme_id = dark_scheme.id
SiteSetting.interface_color_selector = "sidebar_footer"
Theme.find_by(id: SiteSetting.default_theme_id).update!(color_scheme_id: light_scheme.id)
end
context "when light mode is forced" do
before { cookies[:forced_color_mode] = "light" }
it "uses the light scheme colors and doesn't include the prefers-color-scheme media query" do
get "/"
style = css_select("#d-splash style").to_s
expect(style).not_to include("prefers-color-scheme")
secondary = light_scheme.colors.find { |color| color.name == "secondary" }.hex
tertiary = light_scheme.colors.find { |color| color.name == "tertiary" }.hex
expect(style).to include(<<~CSS.indent(6))
html {
background-color: ##{secondary};
}
CSS
expect(style).to include(<<~CSS.indent(6))
#d-splash {
--dot-color: ##{tertiary};
}
CSS
end
end
context "when dark mode is forced" do
before { cookies[:forced_color_mode] = "dark" }
it "uses the dark scheme colors and doesn't include the prefers-color-scheme media query" do
get "/"
style = css_select("#d-splash style").to_s
expect(style).not_to include("prefers-color-scheme")
secondary = dark_scheme.colors.find { |color| color.name == "secondary" }.hex
tertiary = dark_scheme.colors.find { |color| color.name == "tertiary" }.hex
expect(style).to include(<<~CSS.indent(6))
html {
background-color: ##{secondary};
}
CSS
expect(style).to include(<<~CSS.indent(6))
#d-splash {
--dot-color: ##{tertiary};
}
CSS
end
end
context "when no color mode is forced" do
before { cookies[:forced_color_mode] = nil }
it "includes both dark and light colors inside prefers-color-scheme media queries" do
get "/"
style = css_select("#d-splash style").to_s
light_secondary = light_scheme.colors.find { |color| color.name == "secondary" }.hex
light_tertiary = light_scheme.colors.find { |color| color.name == "tertiary" }.hex
dark_secondary = dark_scheme.colors.find { |color| color.name == "secondary" }.hex
dark_tertiary = dark_scheme.colors.find { |color| color.name == "tertiary" }.hex
expect(style).to include(<<~CSS.indent(6))
@media (prefers-color-scheme: light) {
html {
background-color: ##{light_secondary};
}
#d-splash {
--dot-color: ##{light_tertiary};
}
}
CSS
expect(style).to include(<<~CSS.indent(6))
@media (prefers-color-scheme: dark) {
html {
background-color: ##{dark_secondary};
}
#d-splash {
--dot-color: ##{dark_tertiary};
}
}
CSS
end
end
end
end
describe "Delegated auth" do
@ -1526,4 +1625,193 @@ RSpec.describe ApplicationController do
expect(response.headers["X-Discourse-Route"]).to eq("users/show")
end
end
describe "color definition stylesheets" do
let!(:dark_scheme) { ColorScheme.find_by(base_scheme_id: "Dark") }
before do
SiteSetting.default_dark_mode_color_scheme_id = dark_scheme.id
SiteSetting.interface_color_selector = "sidebar_footer"
end
context "with early hints" do
before { global_setting :early_hint_header_mode, "preload" }
it "includes stylesheet links in the header" do
get "/"
expect(response.headers["Link"]).to include("color_definitions_base")
expect(response.headers["Link"]).to include("color_definitions_dark")
end
end
context "when the default theme's scheme is the same as the site's default dark scheme" do
before { Theme.find(SiteSetting.default_theme_id).update!(color_scheme_id: dark_scheme.id) }
it "includes a single color stylesheet that has media=all" do
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
context "when light mode is forced" do
before { cookies[:forced_color_mode] = "light" }
it "includes a light stylesheet with media=all and a dark stylesheet with media=none" do
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(2)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
dark_stylesheet = color_stylesheets.find { |tag| tag[:class] == "dark-scheme" }
expect(light_stylesheet[:media]).to eq("all")
expect(dark_stylesheet[:media]).to eq("none")
end
context "when the dark scheme no longer exists" do
it "includes only a light stylesheet with media=all" do
dark_scheme.destroy!
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
context "when all schemes are deleted" do
it "includes only a light stylesheet with media=all" do
ColorScheme.destroy_all
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
end
context "when dark mode is forced" do
before { cookies[:forced_color_mode] = "dark" }
it "includes a light stylesheet with media=none and a dark stylesheet with media=all" do
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(2)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
dark_stylesheet = color_stylesheets.find { |tag| tag[:class] == "dark-scheme" }
expect(light_stylesheet[:media]).to eq("none")
expect(dark_stylesheet[:media]).to eq("all")
end
context "when the dark scheme no longer exists" do
it "includes only a light stylesheet with media=all" do
dark_scheme.destroy!
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
context "when all schemes are deleted" do
it "includes only a light stylesheet with media=all" do
ColorScheme.destroy_all
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
end
context "when color mode is automatic" do
before { cookies[:forced_color_mode] = nil }
it "includes a light stylesheet with media=(prefers-color-scheme: light) and a dark stylesheet with media=(prefers-color-scheme: dark)" do
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(2)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
dark_stylesheet = color_stylesheets.find { |tag| tag[:class] == "dark-scheme" }
expect(light_stylesheet[:media]).to eq("(prefers-color-scheme: light)")
expect(dark_stylesheet[:media]).to eq("(prefers-color-scheme: dark)")
end
context "when the dark scheme no longer exists" do
it "includes only a light stylesheet with media=all" do
dark_scheme.destroy!
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
context "when all schemes are deleted" do
it "includes only a light stylesheet with media=all" do
ColorScheme.destroy_all
get "/"
color_stylesheets =
css_select("link").select { |tag| tag[:href].include?("color_definitions") }
expect(color_stylesheets.size).to eq(1)
light_stylesheet = color_stylesheets.find { |tag| tag[:class] == "light-scheme" }
expect(light_stylesheet[:media]).to eq("all")
end
end
end
end
end