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

@ -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");

View File

@ -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}}
<li class="header-dropdown-toggle header-color-scheme-toggle">
<InterfaceColorSelector />
</li>
{{/if}}
{{else if entry.value}}
<entry.value />
{{/if}}

View File

@ -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 = <template>
{{#if (and @darkUrl (notEq @url @darkUrl))}}
<picture>
<source srcset={{getURL @darkUrl}} media="(prefers-color-scheme: dark)" />
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)";
}
}
<template>
{{#if (and @darkUrl (notEq @url @darkUrl))}}
<picture>
<source srcset={{getURL @darkUrl}} media={{this.darkMediaQuery}} />
<img
id="site-logo"
class={{@key}}
src={{getURL @url}}
width={{if (eq @key "logo-small") "36"}}
alt={{@title}}
/>
</picture>
{{else}}
<img
id="site-logo"
class={{@key}}
@ -12,16 +36,6 @@ const Logo = <template>
width={{if (eq @key "logo-small") "36"}}
alt={{@title}}
/>
</picture>
{{else}}
<img
id="site-logo"
class={{@key}}
src={{getURL @url}}
width={{if (eq @key "logo-small") "36"}}
alt={{@title}}
/>
{{/if}}
</template>;
export default Logo;
{{/if}}
</template>
}

View File

@ -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();
}
<template>
<DMenu
@icon={{this.selectorIcon}}
@triggerClass="btn-flat sidebar-footer-actions-button"
@identifier="interface-color-selector"
@animated={{false}}
class="interface-color-selector icon"
>
<:content as |dMenu|>
<DropdownMenu as |dropdown|>
<dropdown.item>
<DButton
class="btn-default interface-color-selector__light-option"
@icon="sun"
@translatedLabel={{i18n
"sidebar.footer.interface_color_selector.light"
}}
@action={{fn this.switchToLight dMenu}}
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-default interface-color-selector__dark-option"
@icon="moon"
@translatedLabel={{i18n
"sidebar.footer.interface_color_selector.dark"
}}
@action={{fn this.switchToDark dMenu}}
/>
</dropdown.item>
<dropdown.item>
<DButton
class="btn-default interface-color-selector__auto-option"
@icon="circle-half-stroke"
@translatedLabel={{i18n
"sidebar.footer.interface_color_selector.auto"
}}
@action={{fn this.switchToAuto dMenu}}
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View File

@ -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)";
}
}
<template>
{{#if this.isDarkImageAvailable}}
<picture>
@ -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}}
/>
<CdnImg
...attributes

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import InterfaceColorSelector from "discourse/components/interface-color-selector";
import KeyboardShortcutsHelp from "discourse/components/modal/keyboard-shortcuts-help";
import SidebarSectionForm from "discourse/components/modal/sidebar-section-form";
import PluginOutlet from "discourse/components/plugin-outlet";
@ -15,6 +16,7 @@ export default class SidebarFooter extends Component {
@service site;
@service siteSettings;
@service sidebarState;
@service interfaceColor;
get showManageSectionsButton() {
return this.currentUser && this.sidebarState.isCurrentPanel(MAIN_PANEL);
@ -48,6 +50,10 @@ export default class SidebarFooter extends Component {
<div class="sidebar-footer-actions">
<PluginOutlet @name="sidebar-footer-actions" />
{{#if this.interfaceColor.selectorAvailableInSidebar}}
<InterfaceColorSelector />
{{/if}}
{{#if this.showManageSectionsButton}}
<DButton
@icon="plus"

View File

@ -77,8 +77,7 @@ export default {
}
session.darkModeAvailable =
document.querySelectorAll('link[media="(prefers-color-scheme: dark)"]')
.length > 0;
document.querySelectorAll("link.dark-scheme").length > 0;
session.defaultColorSchemeIsDark = setupData.colorSchemeIsDark === "true";

View File

@ -0,0 +1,8 @@
export default {
after: "inject-objects",
initialize(owner) {
const interfaceColor = owner.lookup("service:interface-color");
interfaceColor.ensureCorrectMode();
},
};

View File

@ -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");
}
}

View File

@ -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;

View File

@ -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

View File

@ -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
<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, dark: SiteSetting.use_overhauled_theme_color_palette)}">
<meta name="theme-color" media="#{light_elements_media_query}" content="##{light_color_hex_for_name("header_background")}">
<meta name="theme-color" media="#{dark_elements_media_query}" content="##{dark_color_hex_for_name("header_background")}">
HTML
else
result << <<~HTML
<meta name="theme-color" media="all" content="##{ColorScheme.hex_for_name("header_background", scheme_id)}">
<meta name="theme-color" media="all" content="##{light_color_hex_for_name("header_background")}">
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)
%[<link href="#{href}" media="#{media}" rel="stylesheet" class="#{css_class}"/>]
end
end

View File

@ -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

View File

@ -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 %>

View File

@ -1,7 +1,7 @@
<%- if application_logo_url.present? %>
<picture class="logo-container">
<%- if application_logo_dark_url.present? %>
<source srcset="<%= application_logo_dark_url %>" media="(prefers-color-scheme: dark)" />
<source srcset="<%= application_logo_dark_url %>" media="<%= dark_elements_media_query %>" />
<%- end %>
<img src="<%= application_logo_url %>" alt="<%= SiteSetting.title %>" class="site-logo" />
</picture>

View File

@ -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}’.<br><br>Did you want to <a class="sidebar-additional-filter-settings" href="%{settings_filter_url}">search site settings</a> or the <a class="sidebar-additional-filter-users" href="%{user_list_filter_url}">admin user list?</a>'
footer:
interface_color_selector:
light: "Light"
dark: "Dark"
auto: "Auto"
welcome_topic_banner:
title: "Create your Welcome Topic"

View File

@ -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."

View File

@ -488,6 +488,10 @@ basic:
default: false
hidden: true
client: true
interface_color_selector:
client: true
enum: "InterfaceColorSelectorSetting"
default: "disabled"
login:
invite_only:

View File

@ -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

View File

@ -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
%[<link href="#{href}" rel="preload" as="style"/>].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"
%[<link href="#{href}" media="#{media}" rel="stylesheet" class="#{css_class}"/>].html_safe
stylesheet[:new_href]
end
end

View File

@ -222,6 +222,7 @@ module SvgSprite
square-full
square-plus
star
sun
table
table-cells
table-columns

View File

@ -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?.({

View File

@ -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)

View File

@ -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

View File

@ -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)

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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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