diff --git a/app/assets/javascripts/discourse/app/components/d-styles.gjs b/app/assets/javascripts/discourse/app/components/d-styles.gjs
index c8e0085cb36..efc3cca12d9 100644
--- a/app/assets/javascripts/discourse/app/components/d-styles.gjs
+++ b/app/assets/javascripts/discourse/app/components/d-styles.gjs
@@ -5,6 +5,7 @@ import { getURLWithCDN } from "discourse/lib/get-url";
export default class DStyles extends Component {
@service session;
@service site;
+ @service interfaceColor;
get categoryColors() {
return [
@@ -17,7 +18,7 @@ export default class DStyles extends Component {
}
get categoryBackgrounds() {
- const css = [];
+ let css = [];
const darkCss = [];
this.site.categories.forEach((category) => {
@@ -45,7 +46,11 @@ export default class DStyles extends Component {
});
if (darkCss.length > 0) {
- css.push("@media (prefers-color-scheme: dark) {", ...darkCss, "}");
+ if (this.interfaceColor.darkModeForced) {
+ css = darkCss;
+ } else if (!this.interfaceColor.lightModeForced) {
+ css.push("@media (prefers-color-scheme: dark) {", ...darkCss, "}");
+ }
}
return css.join("\n");
diff --git a/app/assets/javascripts/discourse/app/components/header/icons.gjs b/app/assets/javascripts/discourse/app/components/header/icons.gjs
index a49b61afc03..62cc9a735f9 100644
--- a/app/assets/javascripts/discourse/app/components/header/icons.gjs
+++ b/app/assets/javascripts/discourse/app/components/header/icons.gjs
@@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
+import InterfaceColorSelector from "discourse/components/interface-color-selector";
import DAG from "discourse/lib/dag";
import getURL from "discourse/lib/get-url";
import Dropdown from "./dropdown";
@@ -15,6 +16,7 @@ function resetHeaderIcons() {
headerIcons.add("search");
headerIcons.add("hamburger", undefined, { after: "search" });
headerIcons.add("user-menu", undefined, { after: "hamburger" });
+ headerIcons.add("interface-color-selector", undefined, { before: "search" });
}
export function headerIconsDAG() {
@@ -32,6 +34,7 @@ export default class Icons extends Component {
@service sidebarState;
@service header;
@service search;
+ @service interfaceColor;
get showHamburger() {
// NOTE: In this scenario, we are forcing the sidebar on admin users,
@@ -98,6 +101,12 @@ export default class Icons extends Component {
@toggleUserMenu={{@toggleUserMenu}}
/>
{{/if}}
+ {{else if (eq entry.key "interface-color-selector")}}
+ {{#if this.interfaceColor.selectorAvailableInHeader}}
+
+ {{/if}}
{{else if entry.value}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/header/logo.gjs b/app/assets/javascripts/discourse/app/components/header/logo.gjs
index f801a82e4de..86b07c0aa2d 100644
--- a/app/assets/javascripts/discourse/app/components/header/logo.gjs
+++ b/app/assets/javascripts/discourse/app/components/header/logo.gjs
@@ -1,10 +1,34 @@
+import Component from "@glimmer/component";
+import { service } from "@ember/service";
import { and, eq, notEq } from "truth-helpers";
import getURL from "discourse/lib/get-url";
-const Logo =
- {{#if (and @darkUrl (notEq @url @darkUrl))}}
-
-
+export default class Logo extends Component {
+ @service interfaceColor;
+
+ get darkMediaQuery() {
+ if (this.interfaceColor.darkModeForced) {
+ return "all";
+ } else if (this.interfaceColor.lightModeForced) {
+ return "none";
+ } else {
+ return "(prefers-color-scheme: dark)";
+ }
+ }
+
+
+ {{#if (and @darkUrl (notEq @url @darkUrl))}}
+
+
+
+
+ {{else}}
width={{if (eq @key "logo-small") "36"}}
alt={{@title}}
/>
-
- {{else}}
-
- {{/if}}
-;
-
-export default Logo;
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/interface-color-selector.gjs b/app/assets/javascripts/discourse/app/components/interface-color-selector.gjs
new file mode 100644
index 00000000000..60733f8611d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/interface-color-selector.gjs
@@ -0,0 +1,85 @@
+import Component from "@glimmer/component";
+import { fn } from "@ember/helper";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DropdownMenu from "discourse/components/dropdown-menu";
+import { i18n } from "discourse-i18n";
+import DMenu from "float-kit/components/d-menu";
+
+export default class InterfaceColorSelector extends Component {
+ @service interfaceColor;
+
+ get selectorIcon() {
+ if (this.interfaceColor.lightModeForced) {
+ return "sun";
+ } else if (this.interfaceColor.darkModeForced) {
+ return "moon";
+ } else {
+ return "circle-half-stroke";
+ }
+ }
+
+ @action
+ switchToLight(dMenu) {
+ this.interfaceColor.forceLightMode();
+ dMenu.close();
+ }
+
+ @action
+ switchToDark(dMenu) {
+ this.interfaceColor.forceDarkMode();
+ dMenu.close();
+ }
+
+ @action
+ switchToAuto(dMenu) {
+ this.interfaceColor.removeColorModeOverride();
+ dMenu.close();
+ }
+
+
+
+ <:content as |dMenu|>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/components/light-dark-img.gjs b/app/assets/javascripts/discourse/app/components/light-dark-img.gjs
index ddfc16c0272..f405497c183 100644
--- a/app/assets/javascripts/discourse/app/components/light-dark-img.gjs
+++ b/app/assets/javascripts/discourse/app/components/light-dark-img.gjs
@@ -5,6 +5,7 @@ import { getURLWithCDN } from "discourse/lib/get-url";
export default class LightDarkImg extends Component {
@service session;
+ @service interfaceColor;
get isDarkImageAvailable() {
return (
@@ -28,6 +29,16 @@ export default class LightDarkImg extends Component {
return getURLWithCDN(this.args.darkImg.url);
}
+ get darkMediaQuery() {
+ if (this.interfaceColor.darkModeForced) {
+ return "all";
+ } else if (this.interfaceColor.lightModeForced) {
+ return "none";
+ } else {
+ return "(prefers-color-scheme: dark)";
+ }
+ }
+
{{#if this.isDarkImageAvailable}}
@@ -35,7 +46,7 @@ export default class LightDarkImg extends Component {
srcset={{this.darkImgCdnSrc}}
width={{@darkImg.width}}
height={{@darkImg.height}}
- media="(prefers-color-scheme: dark)"
+ media={{this.darkMediaQuery}}
/>
+ {{#if this.interfaceColor.selectorAvailableInSidebar}}
+
+ {{/if}}
+
{{#if this.showManageSectionsButton}}
0;
+ document.querySelectorAll("link.dark-scheme").length > 0;
session.defaultColorSchemeIsDark = setupData.colorSchemeIsDark === "true";
diff --git a/app/assets/javascripts/discourse/app/instance-initializers/interface-color.js b/app/assets/javascripts/discourse/app/instance-initializers/interface-color.js
new file mode 100644
index 00000000000..1d42d3b28c2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/instance-initializers/interface-color.js
@@ -0,0 +1,8 @@
+export default {
+ after: "inject-objects",
+
+ initialize(owner) {
+ const interfaceColor = owner.lookup("service:interface-color");
+ interfaceColor.ensureCorrectMode();
+ },
+};
diff --git a/app/assets/javascripts/discourse/app/services/interface-color.js b/app/assets/javascripts/discourse/app/services/interface-color.js
new file mode 100644
index 00000000000..e7c3a973ffc
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/services/interface-color.js
@@ -0,0 +1,112 @@
+import { tracked } from "@glimmer/tracking";
+import Service, { service } from "@ember/service";
+import cookie, { removeCookie } from "discourse/lib/cookie";
+
+const COOKIE_NAME = "forced_color_mode";
+const DARK = "dark";
+const LIGHT = "light";
+
+export default class InterfaceColor extends Service {
+ @service siteSettings;
+ @service session;
+ @tracked forcedColorMode;
+
+ get lightModeForced() {
+ return this.selectorAvailable && this.forcedColorMode === LIGHT;
+ }
+
+ get darkModeForced() {
+ return this.selectorAvailable && this.forcedColorMode === DARK;
+ }
+
+ get selectorAvailable() {
+ return (
+ this.session.darkModeAvailable && !this.session.defaultColorSchemeIsDark
+ );
+ }
+
+ get selectorAvailableInSidebar() {
+ return (
+ this.selectorAvailable &&
+ this.siteSettings.interface_color_selector === "sidebar_footer"
+ );
+ }
+
+ get selectorAvailableInHeader() {
+ return (
+ this.selectorAvailable &&
+ this.siteSettings.interface_color_selector === "header"
+ );
+ }
+
+ ensureCorrectMode() {
+ if (!this.selectorAvailable) {
+ return;
+ }
+
+ const forcedColorMode = cookie(COOKIE_NAME);
+
+ if (forcedColorMode === LIGHT) {
+ this.forceLightMode({ flipStylesheets: false });
+ } else if (forcedColorMode === DARK) {
+ this.forceDarkMode({ flipStylesheets: false });
+ }
+ }
+
+ forceLightMode({ flipStylesheets = true } = {}) {
+ this.forcedColorMode = LIGHT;
+ cookie(COOKIE_NAME, LIGHT, {
+ path: "/",
+ expires: 365,
+ });
+
+ if (flipStylesheets) {
+ const lightStylesheet = this.#lightColorsStylesheet();
+ const darkStylesheet = this.#darkColorsStylesheet();
+ if (lightStylesheet && darkStylesheet) {
+ lightStylesheet.media = "all";
+ darkStylesheet.media = "none";
+ }
+ }
+ }
+
+ forceDarkMode({ flipStylesheets = true } = {}) {
+ this.forcedColorMode = DARK;
+ cookie(COOKIE_NAME, DARK, {
+ path: "/",
+ expires: 365,
+ });
+
+ if (flipStylesheets) {
+ const lightStylesheet = this.#lightColorsStylesheet();
+ const darkStylesheet = this.#darkColorsStylesheet();
+ if (lightStylesheet && darkStylesheet) {
+ lightStylesheet.media = "none";
+ darkStylesheet.media = "all";
+ }
+ }
+ }
+
+ removeColorModeOverride() {
+ this.forcedColorMode = null;
+ removeCookie(COOKIE_NAME, { path: "/" });
+
+ const lightStylesheet = this.#lightColorsStylesheet();
+ if (lightStylesheet) {
+ lightStylesheet.media = "(prefers-color-scheme: light)";
+ }
+
+ const darkStylesheet = this.#darkColorsStylesheet();
+ if (darkStylesheet) {
+ darkStylesheet.media = "(prefers-color-scheme: dark)";
+ }
+ }
+
+ #lightColorsStylesheet() {
+ return document.querySelector("link.light-scheme");
+ }
+
+ #darkColorsStylesheet() {
+ return document.querySelector("link.dark-scheme");
+ }
+}
diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss
index 37a453725e6..7d253b77e26 100644
--- a/app/assets/stylesheets/common/base/header.scss
+++ b/app/assets/stylesheets/common/base/header.scss
@@ -184,7 +184,8 @@
}
.drop-down-mode & {
- .active .icon {
+ .active .icon,
+ .header-color-scheme-toggle .-expanded {
position: relative;
background-color: var(--secondary);
cursor: default;
@@ -266,6 +267,16 @@
}
}
+.fk-d-menu.interface-color-selector-content.-expanded {
+ margin-top: -0.75em;
+ z-index: z("header");
+ box-shadow: var(--shadow-dropdown);
+
+ .fk-d-menu__inner-content {
+ border-color: transparent;
+ }
+}
+
.header-sidebar-toggle {
button {
margin-right: 1em;
diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb
index 272e1cbe251..250a4ed0318 100644
--- a/app/controllers/stylesheets_controller.rb
+++ b/app/controllers/stylesheets_controller.rb
@@ -25,7 +25,7 @@ class StylesheetsController < ApplicationController
params.permit("theme_id")
manager = Stylesheet::Manager.new(theme_id: params[:theme_id])
- stylesheet = manager.color_scheme_stylesheet_details(params[:id], "all")
+ stylesheet = manager.color_scheme_stylesheet_details(params[:id], fallback_to_base: true)
render json: stylesheet
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 5286856ddfb..a1930e7c57c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -636,13 +636,17 @@ module ApplicationHelper
def discourse_preload_color_scheme_stylesheets
result = +""
- result << stylesheet_manager.color_scheme_stylesheet_preload_tag(scheme_id, "all")
+
+ result << stylesheet_manager.color_scheme_stylesheet_preload_tag(
+ scheme_id,
+ fallback_to_base: true,
+ )
if dark_scheme_id != -1
result << stylesheet_manager.color_scheme_stylesheet_preload_tag(
dark_scheme_id,
- "(prefers-color-scheme: dark)",
dark: SiteSetting.use_overhauled_theme_color_palette,
+ fallback_to_base: false,
)
end
@@ -650,22 +654,37 @@ module ApplicationHelper
end
def discourse_color_scheme_stylesheets
- result = +""
- result << stylesheet_manager.color_scheme_stylesheet_link_tag(
- scheme_id,
- "all",
- self.method(:add_resource_preload_list),
- )
+ light_href =
+ stylesheet_manager.color_scheme_stylesheet_link_tag_href(scheme_id, fallback_to_base: true)
+ add_resource_preload_list(light_href, "style")
+ dark_href = nil
if dark_scheme_id != -1
- result << stylesheet_manager.color_scheme_stylesheet_link_tag(
- dark_scheme_id,
- "(prefers-color-scheme: dark)",
- self.method(:add_resource_preload_list),
- dark: SiteSetting.use_overhauled_theme_color_palette,
- )
+ dark_href =
+ stylesheet_manager.color_scheme_stylesheet_link_tag_href(
+ dark_scheme_id,
+ dark: SiteSetting.use_overhauled_theme_color_palette,
+ fallback_to_base: false,
+ )
end
+ result = +""
+ if dark_href && dark_href != light_href
+ add_resource_preload_list(dark_href, "style")
+
+ result << color_scheme_stylesheet_link_tag(
+ light_href,
+ light_elements_media_query,
+ "light-scheme",
+ )
+ result << color_scheme_stylesheet_link_tag(
+ dark_href,
+ dark_elements_media_query,
+ "dark-scheme",
+ )
+ else
+ result << color_scheme_stylesheet_link_tag(light_href, "all", "light-scheme")
+ end
result.html_safe
end
@@ -673,12 +692,12 @@ module ApplicationHelper
result = +""
if dark_scheme_id != -1
result << <<~HTML
-
-
+
+
HTML
else
result << <<~HTML
-
+
HTML
end
result.html_safe
@@ -703,6 +722,48 @@ module ApplicationHelper
ColorScheme.find_by_id(scheme_id)&.is_dark?
end
+ def forced_light_mode?
+ InterfaceColorSelectorSetting.enabled? && cookies[:forced_color_mode] == "light" &&
+ !dark_color_scheme?
+ end
+
+ def forced_dark_mode?
+ InterfaceColorSelectorSetting.enabled? && cookies[:forced_color_mode] == "dark" &&
+ dark_scheme_id != -1
+ end
+
+ def light_color_hex_for_name(name)
+ ColorScheme.hex_for_name(name, scheme_id)
+ end
+
+ def dark_color_hex_for_name(name)
+ ColorScheme.hex_for_name(
+ name,
+ dark_scheme_id,
+ dark: SiteSetting.use_overhauled_theme_color_palette,
+ )
+ end
+
+ def dark_elements_media_query
+ if forced_light_mode?
+ "none"
+ elsif forced_dark_mode?
+ "all"
+ else
+ "(prefers-color-scheme: dark)"
+ end
+ end
+
+ def light_elements_media_query
+ if forced_light_mode?
+ "all"
+ elsif forced_dark_mode?
+ "none"
+ else
+ "(prefers-color-scheme: light)"
+ end
+ end
+
def preloaded_json
return "{}" if !@application_layout_preloader
@@ -797,4 +858,8 @@ module ApplicationHelper
current_user ? nil : value
end
end
+
+ def color_scheme_stylesheet_link_tag(href, media, css_class)
+ %[]
+ end
end
diff --git a/app/models/interface_color_selector_setting.rb b/app/models/interface_color_selector_setting.rb
new file mode 100644
index 00000000000..068c1683132
--- /dev/null
+++ b/app/models/interface_color_selector_setting.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "enum_site_setting"
+
+class InterfaceColorSelectorSetting < EnumSiteSetting
+ def self.valid_value?(val)
+ values.any? { |v| v[:value].to_s == val.to_s }
+ end
+
+ def self.values
+ @values ||= [
+ { name: "interface_color_selector.disabled", value: "disabled" },
+ { name: "interface_color_selector.sidebar_footer", value: "sidebar_footer" },
+ { name: "interface_color_selector.header", value: "header" },
+ ]
+ end
+
+ def self.translate_names?
+ true
+ end
+
+ def self.enabled?
+ SiteSetting.interface_color_selector != "disabled"
+ end
+end
diff --git a/app/views/common/_discourse_splash.html.erb b/app/views/common/_discourse_splash.html.erb
index 54fde308335..39b22651f91 100644
--- a/app/views/common/_discourse_splash.html.erb
+++ b/app/views/common/_discourse_splash.html.erb
@@ -7,11 +7,27 @@
/* user picked a theme where the "regular" scheme is dark */
<%- if dark_color_scheme? %>
html {
- background-color: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
+ background-color: #<%= light_color_hex_for_name("secondary") %>;
}
- #d-splash {
- --dot-color: #<%= ColorScheme.hex_for_name("tertiary", scheme_id) %>;
+ #d-splash {
+ --dot-color: #<%= light_color_hex_for_name("tertiary") %>;
+ }
+ <%- elsif forced_light_mode? %>
+ html {
+ background-color: #<%= light_color_hex_for_name("secondary") %>;
+ }
+
+ #d-splash {
+ --dot-color: #<%= light_color_hex_for_name("tertiary") %>;
+ }
+ <%- elsif forced_dark_mode? %>
+ html {
+ background-color: #<%= dark_color_hex_for_name("secondary") %>;
+ }
+
+ #d-splash {
+ --dot-color: #<%= dark_color_hex_for_name("tertiary") %>;
}
<%- else %>
/* user picked a theme a light scheme and also enabled a dark scheme */
@@ -19,22 +35,22 @@
/* deal with light scheme first */
@media (prefers-color-scheme: light) {
html {
- background-color: #<%= ColorScheme.hex_for_name("secondary", scheme_id) %>;
+ background-color: #<%= light_color_hex_for_name("secondary") %>;
}
#d-splash {
- --dot-color: #<%= ColorScheme.hex_for_name("tertiary", scheme_id) %>;
+ --dot-color: #<%= light_color_hex_for_name("tertiary") %>;
}
}
/* then deal with dark scheme */
@media (prefers-color-scheme: dark) {
html {
- background-color: #<%= ColorScheme.hex_for_name("secondary", dark_scheme_id) %>;
+ background-color: #<%= dark_color_hex_for_name("secondary") %>;
}
#d-splash {
- --dot-color: #<%= ColorScheme.hex_for_name("tertiary", dark_scheme_id) %>;
+ --dot-color: #<%= dark_color_hex_for_name("tertiary") %>;
}
}
<%- end %>
diff --git a/app/views/static/login.html.erb b/app/views/static/login.html.erb
index d6ab9644eeb..43dbb9b9f7f 100644
--- a/app/views/static/login.html.erb
+++ b/app/views/static/login.html.erb
@@ -1,7 +1,7 @@
<%- if application_logo_url.present? %>
<%- if application_logo_dark_url.present? %>
-
+
<%- end %>
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index ca17794bc5d..9df27d09079 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2563,6 +2563,10 @@ en:
required_at_signup: "Required"
optional_at_signup: "Optional"
hidden_at_signup: "Optional, hidden at signup"
+ interface_color_selector:
+ disabled: "Do not display"
+ sidebar_footer: "Display in sidebar footer"
+ header: "Display in header"
shortcut_modifier_key:
shift: "Shift"
@@ -5018,6 +5022,11 @@ en:
no_results:
title: "No results"
description: 'We couldn’t find anything matching ‘%{filter}’.
Did you want to or the '
+ footer:
+ interface_color_selector:
+ light: "Light"
+ dark: "Dark"
+ auto: "Auto"
welcome_topic_banner:
title: "Create your Welcome Topic"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 454389a50e7..f084829bbe3 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -2532,6 +2532,7 @@ en:
max_prints_per_hour_per_user: "Maximum number of /print page impressions (set to 0 to disable printing)"
full_name_requirement: "Make the full name field a required, optional, or optional but hidden field in the signup form."
+ interface_color_selector: "Configure where the light/dark mode selector for the interface color is available."
enable_names: "Show the user's full name on their profile, user card, and emails. Disable to hide full name everywhere."
display_name_on_posts: "Show a user's full name on their posts in addition to their @username."
show_time_gap_days: "If two posts are made this many days apart, display the time gap in the topic."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 6c48e61b8d7..5807c619bf3 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -488,6 +488,10 @@ basic:
default: false
hidden: true
client: true
+ interface_color_selector:
+ client: true
+ enum: "InterfaceColorSelectorSetting"
+ default: "disabled"
login:
invite_only:
diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb
index dae43888370..b8a30b0b2a9 100644
--- a/lib/middleware/anonymous_cache.rb
+++ b/lib/middleware/anonymous_cache.rb
@@ -18,6 +18,7 @@ module Middleware
t: "key_cache_theme_ids",
ca: "key_compress_anon",
l: "key_locale",
+ cm: "key_forced_color_mode",
}
end
@@ -176,6 +177,11 @@ module Middleware
theme_ids.join(",")
end
+ def key_forced_color_mode
+ val = @request.cookies["forced_color_mode"]
+ %w[light dark].include?(val) ? val : ""
+ end
+
def key_compress_anon
GlobalSetting.compress_anon_cache
end
diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb
index fa8d3152433..45621c36144 100644
--- a/lib/stylesheet/manager.rb
+++ b/lib/stylesheet/manager.rb
@@ -341,20 +341,15 @@ class Stylesheet::Manager
end
end
- def color_scheme_stylesheet_details(color_scheme_id = nil, media, dark: false)
+ def color_scheme_stylesheet_details(color_scheme_id = nil, dark: false, fallback_to_base: true)
theme_id = @theme_id || SiteSetting.default_theme_id
- color_scheme =
- begin
- ColorScheme.find(color_scheme_id)
- rescue StandardError
- # don't load fallback when requesting dark color scheme
- return false if media != "all"
+ color_scheme = ColorScheme.find_by(id: color_scheme_id)
- get_theme(theme_id)&.color_scheme || ColorScheme.base
- end
-
- return false if !color_scheme
+ if !color_scheme
+ return if !fallback_to_base
+ color_scheme = get_theme(theme_id)&.color_scheme || ColorScheme.base
+ end
target = COLOR_SCHEME_STYLESHEET.to_sym
current_hostname = Discourse.current_hostname
@@ -382,8 +377,12 @@ class Stylesheet::Manager
end
end
- def color_scheme_stylesheet_preload_tag(color_scheme_id = nil, media = "all", dark: false)
- stylesheet = color_scheme_stylesheet_details(color_scheme_id, media, dark:)
+ def color_scheme_stylesheet_preload_tag(
+ color_scheme_id = nil,
+ dark: false,
+ fallback_to_base: true
+ )
+ stylesheet = color_scheme_stylesheet_details(color_scheme_id, dark:, fallback_to_base:)
return "" if !stylesheet
@@ -392,21 +391,15 @@ class Stylesheet::Manager
%[].html_safe
end
- def color_scheme_stylesheet_link_tag(
+ def color_scheme_stylesheet_link_tag_href(
color_scheme_id = nil,
- media = "all",
- preload_callback = nil,
- dark: false
+ dark: false,
+ fallback_to_base: true
)
- stylesheet = color_scheme_stylesheet_details(color_scheme_id, media, dark:)
+ stylesheet = color_scheme_stylesheet_details(color_scheme_id, dark:, fallback_to_base:)
- return "" if !stylesheet
+ return if !stylesheet
- href = stylesheet[:new_href]
- preload_callback.call(href, "style") if preload_callback
-
- css_class = media == "all" ? "light-scheme" : "dark-scheme"
-
- %[].html_safe
+ stylesheet[:new_href]
end
end
diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb
index 95adf349023..faa044fe283 100644
--- a/lib/svg_sprite.rb
+++ b/lib/svg_sprite.rb
@@ -222,6 +222,7 @@ module SvgSprite
square-full
square-plus
star
+ sun
table
table-cells
table-columns
diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js
index 4c09b9d2e90..ef0cdff26cd 100644
--- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js
+++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js
@@ -166,7 +166,9 @@ class ChatSetupInit {
api.addCardClickListenerSelector(".chat-drawer-outlet");
if (this.chatService.userCanChat) {
- api.headerIcons.add("chat", ChatHeaderIcon);
+ api.headerIcons.add("chat", ChatHeaderIcon, {
+ before: "interface-color-selector",
+ });
}
api.addStyleguideSection?.({
diff --git a/spec/lib/middleware/anonymous_cache_spec.rb b/spec/lib/middleware/anonymous_cache_spec.rb
index e58bba8255c..be8522065c4 100644
--- a/spec/lib/middleware/anonymous_cache_spec.rb
+++ b/spec/lib/middleware/anonymous_cache_spec.rb
@@ -169,6 +169,45 @@ RSpec.describe Middleware::AnonymousCache do
expect(helper.cached).to eq(nil)
end
+ it "includes the forced color mode in the cache key" do
+ dark_helper =
+ new_helper("ANON_CACHE_DURATION" => 10, "HTTP_COOKIE" => "forced_color_mode=dark")
+ dark_helper.cache([200, { "HELLO" => "WORLD" }, ["dark mode"]])
+
+ light_helper =
+ new_helper("ANON_CACHE_DURATION" => 10, "HTTP_COOKIE" => "forced_color_mode=light")
+ expect(light_helper.cached).to eq(nil)
+
+ light_helper.cache([200, { "HELLO" => "WORLD" }, ["light mode"]])
+
+ auto_helper = new_helper("ANON_CACHE_DURATION" => 10)
+ expect(auto_helper.cached).to eq(nil)
+
+ auto_helper.cache([200, { "HELLO" => "WORLD" }, ["auto color mode"]])
+
+ unknown_helper =
+ new_helper("ANON_CACHE_DURATION" => 10, "HTTP_COOKIE" => "forced_color_mode=blada")
+ expect(unknown_helper.cached).to eq(
+ [200, { "HELLO" => "WORLD", "X-Discourse-Cached" => "true" }, ["auto color mode"]],
+ )
+
+ dark_helper =
+ new_helper("ANON_CACHE_DURATION" => 10, "HTTP_COOKIE" => "forced_color_mode=dark")
+ light_helper =
+ new_helper("ANON_CACHE_DURATION" => 10, "HTTP_COOKIE" => "forced_color_mode=light")
+ auto_helper = new_helper("ANON_CACHE_DURATION" => 10)
+
+ expect(dark_helper.cached).to eq(
+ [200, { "HELLO" => "WORLD", "X-Discourse-Cached" => "true" }, ["dark mode"]],
+ )
+ expect(light_helper.cached).to eq(
+ [200, { "HELLO" => "WORLD", "X-Discourse-Cached" => "true" }, ["light mode"]],
+ )
+ expect(auto_helper.cached).to eq(
+ [200, { "HELLO" => "WORLD", "X-Discourse-Cached" => "true" }, ["auto color mode"]],
+ )
+ end
+
it "returns cached data for cached requests" do
helper.is_mobile = true
expect(helper.cached).to eq(nil)
diff --git a/spec/lib/stylesheet/manager_spec.rb b/spec/lib/stylesheet/manager_spec.rb
index ca3359cdbbc..a304452fa5c 100644
--- a/spec/lib/stylesheet/manager_spec.rb
+++ b/spec/lib/stylesheet/manager_spec.rb
@@ -609,25 +609,25 @@ RSpec.describe Stylesheet::Manager do
describe "color_scheme_stylesheets" do
it "returns something by default" do
- link = manager.color_scheme_stylesheet_link_tag
- expect(link).to include("color_definitions_base")
+ href = manager.color_scheme_stylesheet_link_tag_href
+ expect(href).to include("color_definitions_base")
end
it "does not crash when no default theme is set" do
SiteSetting.default_theme_id = -1
- link = manager.color_scheme_stylesheet_link_tag
+ href = manager.color_scheme_stylesheet_link_tag_href
- expect(link).to include("color_definitions_base")
+ expect(href).to include("color_definitions_base")
end
it "loads base scheme when defined scheme id is missing" do
- link = manager.color_scheme_stylesheet_link_tag(125)
- expect(link).to include("color_definitions_base")
+ href = manager.color_scheme_stylesheet_link_tag_href(125)
+ expect(href).to include("color_definitions_base")
end
- it "loads nothing when defined dark scheme id is missing" do
- link = manager.color_scheme_stylesheet_link_tag(125, "(prefers-color-scheme: dark)")
- expect(link).to eq("")
+ it "loads nothing when fallback_to_base is false" do
+ href = manager.color_scheme_stylesheet_link_tag_href(125, fallback_to_base: false)
+ expect(href).to eq(nil)
end
it "uses the correct color scheme from the default site theme" do
@@ -635,8 +635,8 @@ RSpec.describe Stylesheet::Manager do
theme = Fabricate(:theme, color_scheme_id: cs.id)
SiteSetting.default_theme_id = theme.id
- link = manager.color_scheme_stylesheet_link_tag()
- expect(link).to include("/stylesheets/color_definitions_funky_#{cs.id}_")
+ href = manager.color_scheme_stylesheet_link_tag_href
+ expect(href).to include("/stylesheets/color_definitions_funky_#{cs.id}_")
end
it "uses the correct color scheme when a non-default theme is selected and it uses the base 'Light' scheme" do
@@ -647,8 +647,9 @@ RSpec.describe Stylesheet::Manager do
user_theme = Fabricate(:theme, color_scheme_id: nil)
- link = manager(user_theme.id).color_scheme_stylesheet_link_tag(nil, "all")
- expect(link).to include("/stylesheets/color_definitions_base_")
+ href =
+ manager(user_theme.id).color_scheme_stylesheet_link_tag_href(nil, fallback_to_base: true)
+ expect(href).to include("/stylesheets/color_definitions_base_")
stylesheet =
Stylesheet::Manager::Builder.new(
@@ -662,9 +663,9 @@ RSpec.describe Stylesheet::Manager do
end
it "uses the correct scheme when a valid scheme id is used" do
- link = manager.color_scheme_stylesheet_link_tag(ColorScheme.first.id)
+ href = manager.color_scheme_stylesheet_link_tag_href(ColorScheme.first.id)
slug = Slug.for(ColorScheme.first.name) + "_" + ColorScheme.first.id.to_s
- expect(link).to include("/stylesheets/color_definitions_#{slug}_")
+ expect(href).to include("/stylesheets/color_definitions_#{slug}_")
end
it "does not fail with a color scheme name containing spaces and special characters" do
@@ -672,8 +673,8 @@ RSpec.describe Stylesheet::Manager do
theme = Fabricate(:theme, color_scheme_id: cs.id)
SiteSetting.default_theme_id = theme.id
- link = manager.color_scheme_stylesheet_link_tag
- expect(link).to include("/stylesheets/color_definitions_funky-bunch_#{cs.id}_")
+ href = manager.color_scheme_stylesheet_link_tag_href
+ expect(href).to include("/stylesheets/color_definitions_funky-bunch_#{cs.id}_")
end
it "generates the dark mode of a color scheme when the dark option is specified" do
@@ -774,25 +775,14 @@ RSpec.describe Stylesheet::Manager do
end
it "includes updated font definitions" do
- details1 = manager.color_scheme_stylesheet_details(nil, "all")
+ details1 = manager.color_scheme_stylesheet_details(nil, fallback_to_base: true)
SiteSetting.base_font = DiscourseFonts.fonts[2][:key]
- details2 = manager.color_scheme_stylesheet_details(nil, "all")
+ details2 = manager.color_scheme_stylesheet_details(nil, fallback_to_base: true)
expect(details1[:new_href]).not_to eq(details2[:new_href])
end
- it "calls the preload callback when set" do
- preload_list = []
- cs = Fabricate(:color_scheme, name: "Funky")
- theme = Fabricate(:theme, color_scheme_id: cs.id)
- preload_callback = ->(href, type) { preload_list << [href, type] }
-
- expect {
- manager.color_scheme_stylesheet_link_tag(theme.id, "all", preload_callback)
- }.to change(preload_list, :size).by(1)
- end
-
context "with theme colors" do
let(:theme) do
Fabricate(:theme).tap do |t|
@@ -913,10 +903,10 @@ RSpec.describe Stylesheet::Manager do
cs = Fabricate(:color_scheme, name: "Grün")
cs2 = Fabricate(:color_scheme, name: "어두운")
- link = manager.color_scheme_stylesheet_link_tag(cs.id)
- expect(link).to include("/stylesheets/color_definitions_grun_#{cs.id}_")
- link2 = manager.color_scheme_stylesheet_link_tag(cs2.id)
- expect(link2).to include("/stylesheets/color_definitions_scheme_#{cs2.id}_")
+ href = manager.color_scheme_stylesheet_link_tag_href(cs.id)
+ expect(href).to include("/stylesheets/color_definitions_grun_#{cs.id}_")
+ href2 = manager.color_scheme_stylesheet_link_tag_href(cs2.id)
+ expect(href2).to include("/stylesheets/color_definitions_scheme_#{cs2.id}_")
end
end
end
diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb
index 426c4aa803d..9677146d26c 100644
--- a/spec/models/color_scheme_spec.rb
+++ b/spec/models/color_scheme_spec.rb
@@ -20,12 +20,12 @@ RSpec.describe ColorScheme do
manager = Stylesheet::Manager.new(theme_id: theme.id)
href = manager.stylesheet_data(:desktop_theme)[0][:new_href]
- colors_href = manager.color_scheme_stylesheet_details(scheme.id, "all")
+ colors_href = manager.color_scheme_stylesheet_details(scheme.id, fallback_to_base: true)
ColorSchemeRevisor.revise(scheme, colors: [{ name: "primary", hex: "bbb" }])
href2 = manager.stylesheet_data(:desktop_theme)[0][:new_href]
- colors_href2 = manager.color_scheme_stylesheet_details(scheme.id, "all")
+ colors_href2 = manager.color_scheme_stylesheet_details(scheme.id, fallback_to_base: true)
expect(href).not_to eq(href2)
expect(colors_href).not_to eq(colors_href2)
diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb
index 0453a10c231..b4f8101f8e1 100644
--- a/spec/requests/application_controller_spec.rb
+++ b/spec/requests/application_controller_spec.rb
@@ -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
diff --git a/spec/system/interface_color_selector_spec.rb b/spec/system/interface_color_selector_spec.rb
new file mode 100644
index 00000000000..c708c26f844
--- /dev/null
+++ b/spec/system/interface_color_selector_spec.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+
+describe "Interface color selector", type: :system do
+ let!(:light_scheme) { ColorScheme.find_by(base_scheme_id: "Solarized Light") }
+ let!(:dark_scheme) { ColorScheme.find_by(base_scheme_id: "Dark") }
+
+ let(:selector_in_sidebar) do
+ PageObjects::Components::InterfaceColorSelector.new(".sidebar-footer-actions")
+ end
+ let(:selector_in_header) do
+ PageObjects::Components::InterfaceColorSelector.new(".d-header-icons")
+ end
+
+ let(:interface_color_mode) { PageObjects::Components::InterfaceColorMode.new }
+
+ let(:home_logo) { PageObjects::Components::HomeLogo.new }
+
+ let(:sidebar) { PageObjects::Components::NavigationMenu::Sidebar.new }
+
+ fab!(:user)
+
+ fab!(:dark_mode_image) { Fabricate(:image_upload, color: "white", width: 400, height: 120) }
+ fab!(:light_mode_image) { Fabricate(:image_upload, color: "black", width: 400, height: 120) }
+
+ fab!(:small_dark_mode_image) { Fabricate(:image_upload, color: "white", width: 120, height: 120) }
+ fab!(:small_light_mode_image) do
+ Fabricate(:image_upload, color: "black", width: 120, height: 120)
+ end
+
+ fab!(:category) do
+ Fabricate(
+ :category,
+ uploaded_logo: small_light_mode_image,
+ uploaded_logo_dark: small_dark_mode_image,
+ uploaded_background: light_mode_image,
+ uploaded_background_dark: dark_mode_image,
+ )
+ end
+
+ before do
+ SiteSetting.interface_color_selector = "sidebar_footer"
+ SiteSetting.default_dark_mode_color_scheme_id = dark_scheme.id
+
+ SiteSetting.logo = light_mode_image
+ SiteSetting.logo_dark = dark_mode_image
+ end
+
+ it "is not available when there's no default dark scheme" do
+ SiteSetting.default_dark_mode_color_scheme_id = -1
+
+ visit("/")
+
+ expect(selector_in_sidebar).to be_not_available
+ end
+
+ it "is not available when the default theme's scheme is the same as the site's default dark scheme" do
+ Theme.find(SiteSetting.default_theme_id).update!(color_scheme_id: dark_scheme.id)
+
+ visit("/")
+
+ expect(selector_in_sidebar).to be_not_available
+ end
+
+ it "is not available if the user uses the same scheme for dark mode as the light mode" do
+ user.user_option.update!(color_scheme_id: light_scheme.id, dark_scheme_id: -1)
+ sign_in(user)
+
+ visit("/")
+
+ expect(selector_in_sidebar).to be_not_available
+ end
+
+ it "can switch between light, dark and auto modes without requiring a full page refresh" do
+ visit("/")
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.light_option.click
+
+ expect(interface_color_mode).to have_light_mode_forced
+ expect(home_logo).to have_light_logo_forced
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.dark_option.click
+
+ expect(interface_color_mode).to have_dark_mode_forced
+ expect(home_logo).to have_dark_logo_forced
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.auto_option.click
+
+ expect(interface_color_mode).to have_auto_color_mode
+ expect(home_logo).to have_auto_color_mode
+ end
+
+ it "uses the right category logos when switching color modes" do
+ visit("/")
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.dark_option.click
+
+ sidebar.click_section_link(category.name)
+
+ expect(page).to have_css('.category-logo picture source[media="all"]', visible: false)
+
+ styles = find("#d-styles", visible: false)["innerHTML"]
+ expect(styles).to include(
+ "body.category-#{category.slug} { background-image: url(#{dark_mode_image.url}); }",
+ )
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.light_option.click
+
+ expect(page).to have_css('.category-logo picture source[media="none"]', visible: false)
+
+ styles = find("#d-styles", visible: false)["innerHTML"]
+ expect(styles).to include(
+ "body.category-#{category.slug} { background-image: url(#{light_mode_image.url}); }",
+ )
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.auto_option.click
+
+ expect(page).to have_css(
+ '.category-logo picture source[media="(prefers-color-scheme: dark)"]',
+ visible: false,
+ )
+
+ styles = find("#d-styles", visible: false)["innerHTML"]
+ expect(styles).to include(
+ "body.category-#{category.slug} { background-image: url(#{light_mode_image.url}); }",
+ )
+ expect(styles).to include(<<~CSS)
+ @media (prefers-color-scheme: dark) {
+ body.category-#{category.slug} { background-image: url(#{dark_mode_image.url}); }
+ }
+ CSS
+ end
+
+ it "maintains the user's preference across page refreshes" do
+ visit("/")
+
+ selector_in_sidebar.expand
+ selector_in_sidebar.dark_option.click
+
+ expect(interface_color_mode).to have_dark_mode_forced
+
+ visit(category.url)
+
+ expect(interface_color_mode).to have_dark_mode_forced
+
+ expect(page).to have_css('.category-logo picture source[media="all"]', visible: false)
+
+ styles = find("#d-styles", visible: false)["innerHTML"]
+ expect(styles).to include(
+ "body.category-#{category.slug} { background-image: url(#{dark_mode_image.url}); }",
+ )
+ end
+
+ it "can be configured to appear in the header instead of the sidebar footer" do
+ SiteSetting.interface_color_selector = "header"
+
+ visit("/")
+
+ expect(selector_in_sidebar).to be_not_available
+ expect(selector_in_header).to be_available
+ end
+end
diff --git a/spec/system/page_objects/components/home_logo.rb b/spec/system/page_objects/components/home_logo.rb
new file mode 100644
index 00000000000..2b1856bdc36
--- /dev/null
+++ b/spec/system/page_objects/components/home_logo.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class HomeLogo < PageObjects::Components::Base
+ def has_dark_logo_forced?
+ has_css?(".title picture source[media=\"all\"]", visible: false)
+ end
+
+ def has_light_logo_forced?
+ has_css?(".title picture source[media=\"none\"]", visible: false)
+ end
+
+ def has_auto_color_mode?
+ has_css?(".title picture source[media=\"(prefers-color-scheme: dark)\"]", visible: false)
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/interface_color_mode.rb b/spec/system/page_objects/components/interface_color_mode.rb
new file mode 100644
index 00000000000..776d90e7d0f
--- /dev/null
+++ b/spec/system/page_objects/components/interface_color_mode.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class InterfaceColorMode < PageObjects::Components::Base
+ def has_light_mode_forced?
+ has_light_scheme_with_media?("all") && has_dark_scheme_with_media?("none")
+ end
+
+ def has_dark_mode_forced?
+ has_light_scheme_with_media?("none") && has_dark_scheme_with_media?("all")
+ end
+
+ def has_auto_color_mode?
+ has_light_scheme_with_media?("(prefers-color-scheme: light)") &&
+ has_dark_scheme_with_media?("(prefers-color-scheme: dark)")
+ end
+
+ private
+
+ def has_light_scheme_with_media?(media)
+ has_css?("link.light-scheme[media=\"#{media}\"]", visible: false)
+ end
+
+ def has_dark_scheme_with_media?(media)
+ has_css?("link.dark-scheme[media=\"#{media}\"]", visible: false)
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/interface_color_selector.rb b/spec/system/page_objects/components/interface_color_selector.rb
new file mode 100644
index 00000000000..55e3a79217a
--- /dev/null
+++ b/spec/system/page_objects/components/interface_color_selector.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class InterfaceColorSelector < PageObjects::Components::Base
+ attr_reader :container_selector
+
+ SELECTOR = ".interface-color-selector"
+
+ def initialize(container_selector)
+ @container_selector = container_selector
+ end
+
+ def available?
+ find(container_selector).has_css?(SELECTOR)
+ end
+
+ def not_available?
+ find(container_selector).has_no_css?(SELECTOR)
+ end
+
+ def expand
+ find(container_selector).find(SELECTOR).click
+ end
+
+ def light_option
+ find("#{SELECTOR}__light-option")
+ end
+
+ def dark_option
+ find("#{SELECTOR}__dark-option")
+ end
+
+ def auto_option
+ find("#{SELECTOR}__auto-option")
+ end
+ end
+ end
+end