mirror of
https://github.com/discourse/discourse.git
synced 2025-05-24 23:31:21 +08:00
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:
@ -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");
|
||||
|
@ -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}}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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";
|
||||
|
||||
|
@ -0,0 +1,8 @@
|
||||
export default {
|
||||
after: "inject-objects",
|
||||
|
||||
initialize(owner) {
|
||||
const interfaceColor = owner.lookup("service:interface-color");
|
||||
interfaceColor.ensureCorrectMode();
|
||||
},
|
||||
};
|
112
app/assets/javascripts/discourse/app/services/interface-color.js
Normal file
112
app/assets/javascripts/discourse/app/services/interface-color.js
Normal 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");
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
25
app/models/interface_color_selector_setting.rb
Normal file
25
app/models/interface_color_selector_setting.rb
Normal 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
|
@ -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 %>
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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."
|
||||
|
@ -488,6 +488,10 @@ basic:
|
||||
default: false
|
||||
hidden: true
|
||||
client: true
|
||||
interface_color_selector:
|
||||
client: true
|
||||
enum: "InterfaceColorSelectorSetting"
|
||||
default: "disabled"
|
||||
|
||||
login:
|
||||
invite_only:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -222,6 +222,7 @@ module SvgSprite
|
||||
square-full
|
||||
square-plus
|
||||
star
|
||||
sun
|
||||
table
|
||||
table-cells
|
||||
table-columns
|
||||
|
@ -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?.({
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
167
spec/system/interface_color_selector_spec.rb
Normal file
167
spec/system/interface_color_selector_spec.rb
Normal 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
|
19
spec/system/page_objects/components/home_logo.rb
Normal file
19
spec/system/page_objects/components/home_logo.rb
Normal 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
|
30
spec/system/page_objects/components/interface_color_mode.rb
Normal file
30
spec/system/page_objects/components/interface_color_mode.rb
Normal 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
|
@ -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
|
Reference in New Issue
Block a user