feat: vanilla CSS color scheme changes (#3996)

* feat: vanilla CSS color scheme changes
* chore: scheme mixin
* chore: remove darkmode & colored header less variables
* feat: high contrast schemes
This commit is contained in:
Sami Mazouz 2024-06-22 08:05:07 +01:00 committed by GitHub
parent 379298acb0
commit b91caec30b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 979 additions and 207 deletions

View File

@ -1,3 +1,11 @@
[data-theme^=light] {
.light-contents-vars(@color: @body-bg-light; @control-color: @body-bg-light; @name: 'flagged-post');
}
[data-theme^=dark] {
.light-contents-vars(@color: @body-bg-dark; @control-color: @body-bg-dark; @name: 'flagged-post');
}
.Post--flagged {
--border-width: 2px;
padding-top: 0 !important;
@ -16,7 +24,7 @@
padding: 10px;
border-radius: var(--border-radius) var(--border-radius) 0 0;
overflow: hidden;
.light-contents(@color: @body-bg; @control-color: @body-bg);
.light-contents(@name: 'flagged-post');
display: flex;
align-items: center;

View File

@ -22,12 +22,6 @@
background-color: transparent;
}
// @TODO add to core
.Checkbox--switch.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ButtonGroup--full {
width: 100%;
display: flex;

View File

@ -1,9 +1,26 @@
@following-bg: #ffea7b;
@following-color: #de8e00;
:root {
--following-bg: #ffea7b;
--following-color: #de8e00;
--following-bg: @following-bg;
--following-color: @following-color;
--ignoring-bg: #aaa;
}
[data-theme^=light] {
.Button--color-vars(@following-color, #fff2ae, 'button--follow');
}
[data-theme=light-hc], [data-theme=dark-hc] {
@following-color-hc: darken(@following-color, 23%);
--following-color: @following-color-hc;
.Button--color-vars(@following-color-hc, #fff2ae, 'button--follow');
}
[data-theme^=dark] {
.Button--color-vars(#784d00, #fbb94c, 'button--follow');
}
.Badge--following {
--badge-bg: var(--following-bg);
--badge-color: var(--following-color);
@ -12,12 +29,7 @@
--badge-bg: var(--ignoring-bg);
}
.SubscriptionMenu-button--follow {
& when (@config-dark-mode = false) {
.Button--color(#de8e00, #fff2ae);
}
& when (@config-dark-mode = true) {
.Button--color(#784d00, #fbb94c);
}
.Button--color-auto('button--follow');
}
.SubscriptionMenu .Dropdown-menu {
min-width: 260px;

View File

@ -1,5 +1,9 @@
:root {
.Button--color-vars(@control-bg, @control-color, 'button-toggled');
[data-theme^=light] {
.Button--color-vars(@control-bg-light, @control-color-light, 'button-toggled');
}
[data-theme^=dark] {
.Button--color-vars(@control-bg-dark, @control-color-dark, 'button-toggled');
}
.Button--toggled {

View File

@ -9,6 +9,7 @@ import ItemList from '../../common/utils/ItemList';
import type Mithril from 'mithril';
import Form from '../../common/components/Form';
import FieldSet from '../../common/components/FieldSet';
import ThemeMode from '../../common/components/ThemeMode';
export default class AppearancePage extends AdminPage {
headerInfo() {
@ -97,11 +98,29 @@ export default class AppearancePage extends AdminPage {
);
items.add(
'dark-mode',
this.buildSettingComponent({
type: 'switch',
setting: 'theme_dark_mode',
label: app.translator.trans('core.admin.appearance.dark_mode_label'),
'theme-modes',
this.buildSettingComponent(function () {
return (
<div className="Form-group">
<label>{app.translator.trans('core.admin.appearance.color_scheme_label')}</label>
<div className="ThemeMode-list">
{ThemeMode.colorSchemes.map((mode) => (
<ThemeMode
mode={mode.id}
label={mode.label || app.translator.trans('core.admin.appearance.color_schemes.' + mode.id.replace('-', '_') + '_mode_label')}
onclick={() => {
this.setting('color_scheme')(mode.id);
this.setting('allow_user_color_scheme')(mode.id === 'auto' ? '1' : '0');
app.setColorScheme(mode.id);
}}
selected={this.setting('color_scheme')() === mode.id}
/>
))}
</div>
</div>
);
}),
60
);
@ -112,6 +131,10 @@ export default class AppearancePage extends AdminPage {
type: 'switch',
setting: 'theme_colored_header',
label: app.translator.trans('core.admin.appearance.colored_header_label'),
onchange: (value: boolean) => {
this.setting('theme_colored_header')(value ? '1' : '0');
app.setColoredHeader(value);
},
}),
50
);

View File

@ -38,6 +38,7 @@ import IHistory from './IHistory';
import IExtender from './extenders/IExtender';
import AccessToken from './models/AccessToken';
import SearchManager from './SearchManager';
import { ColorScheme } from './components/ThemeMode';
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
@ -245,6 +246,8 @@ export default class Application {
data!: ApplicationData;
allowUserColorScheme!: boolean;
private _title: string = '';
private _titleCount: number = 0;
@ -356,9 +359,61 @@ export default class Application {
document.body.classList.add('ontouchstart' in window ? 'touch' : 'no-touch');
this.initColorScheme();
liveHumanTimes();
}
private initColorScheme(forumDefault: string | null = null): void {
forumDefault ??= document.documentElement.getAttribute('data-theme') ?? 'auto';
this.allowUserColorScheme = forumDefault === 'auto';
const userConfiguredPreference = this.session.user?.preferences()?.colorScheme;
let scheme;
if (this.allowUserColorScheme) {
scheme = userConfiguredPreference;
}
scheme ||= forumDefault;
this.setColorScheme(scheme);
// Listen for browser color scheme changes and update the theme accordingly
if (this.allowUserColorScheme) {
this.watchSystemColorSchemePreference(() => {
this.initColorScheme(forumDefault);
});
}
}
getSystemColorSchemePreference(): ColorScheme | string {
let colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (window.matchMedia('(prefers-contrast: more)').matches) {
colorScheme += '-hc';
}
return colorScheme;
}
watchSystemColorSchemePreference(callback: () => void): void {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', callback);
window.matchMedia('(prefers-contrast: more)').addEventListener('change', callback);
}
setColorScheme(scheme: ColorScheme | string): void {
if (scheme === ColorScheme.Auto) {
scheme = this.getSystemColorSchemePreference();
}
document.documentElement.setAttribute('data-theme', scheme);
}
setColoredHeader(value: boolean): void {
document.documentElement.setAttribute('data-colored-header', value ? 'true' : 'false');
}
/**
* Get the API response document that has been preloaded into the application.
*/

View File

@ -0,0 +1,96 @@
import Component, { type ComponentAttrs } from '../../common/Component';
import type Mithril from 'mithril';
import classList from '../../common/utils/classList';
export interface IThemeModeAttrs extends ComponentAttrs {
label: string;
mode: string;
selected?: boolean;
alternate?: boolean;
}
export enum ColorScheme {
Auto = 'auto',
Light = 'light',
Dark = 'dark',
LightHighContrast = 'light-hc',
DarkHighContrast = 'dark-hc',
}
export type ColorSchemeData = {
id: ColorScheme | string;
label?: string | null;
};
export default class ThemeMode<CustomAttrs extends IThemeModeAttrs = IThemeModeAttrs> extends Component<CustomAttrs> {
static colorSchemes: ColorSchemeData[] = [
{ id: ColorScheme.Auto },
{ id: ColorScheme.Light },
{ id: ColorScheme.Dark },
{ id: ColorScheme.LightHighContrast },
{ id: ColorScheme.DarkHighContrast },
];
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
const { mode, selected, className, alternate, label, ...attrs } = vnode.attrs;
return (
<label
className={classList('ThemeMode', className, `ThemeMode--${mode}`, { 'ThemeMode--active': selected, 'ThemeMode--switch': alternate })}
{...attrs}
>
<div
className="ThemeMode-container"
data-theme={mode === 'auto' ? 'light' : mode}
data-colored-header={document.documentElement.getAttribute('data-colored-header')}
>
<div className="ThemeMode-preview">
<div className="ThemeMode-header">
<div className="ThemeMode-header-text"></div>
<div className="ThemeMode-header-icon"></div>
<div className="ThemeMode-header-icon"></div>
</div>
<div className="ThemeMode-hero">
<div className="ThemeMode-hero-title"></div>
<div className="ThemeMode-hero-desc"></div>
</div>
<div className="ThemeMode-main">
<div className="ThemeMode-sidebar">
<div className="ThemeMode-startDiscussion">
<div className="ThemeMode-startDiscussion-text"></div>
</div>
<div className="ThemeMode-items">
{Array.from({ length: 3 }).map((_, i) => (
<div className="ThemeMode-sidebar-line" key={i}>
<div className="ThemeMode-sidebar-icon"></div>
<div className="ThemeMode-sidebar-text"></div>
</div>
))}
</div>
</div>
<div className="ThemeMode-content">
<div className="ThemeMode-toolbar">
<div className="ThemeMode-button"></div>
<div className="ThemeMode-button"></div>
</div>
<div className="ThemeMode-items">
{Array.from({ length: 3 }).map((_, i) => (
<div className="ThemeMode-content-item" key={i}>
<div className="ThemeMode-content-item-author"></div>
<div className="ThemeMode-content-item-meta">
<div className="ThemeMode-content-item-title"></div>
<div className="ThemeMode-content-item-excerpt"></div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{mode === 'auto' ? <ThemeMode mode={mode === 'auto' ? 'dark' : null} alternate={true} selected={selected} {...attrs} /> : null}
</div>
{!alternate ? <div className="ThemeMode-legend">{label}</div> : null}
</label>
);
}
}

View File

@ -0,0 +1,20 @@
import Application from '../Application';
import IExtender, { IExtensionModule } from './IExtender';
import ThemeModeComponent, { type ColorSchemeData } from '../components/ThemeMode';
export default class ThemeMode implements IExtender {
private readonly colorSchemes: ColorSchemeData[] = [];
public add(mode: string, label: string): this {
this.colorSchemes.push({
id: mode,
label,
});
return this;
}
extend(app: Application, extension: IExtensionModule): void {
ThemeModeComponent.colorSchemes = Array.from(new Set([...ThemeModeComponent.colorSchemes, ...this.colorSchemes]));
}
}

View File

@ -4,6 +4,7 @@ import Routes from './Routes';
import Store from './Store';
import Search from './Search';
import Notification from './Notification';
import ThemeMode from './ThemeMode';
const extenders = {
Model,
@ -12,6 +13,7 @@ const extenders = {
Store,
Search,
Notification,
ThemeMode,
};
export default extenders;

View File

@ -11,6 +11,9 @@ import listItems from '../../common/helpers/listItems';
import extractText from '../../common/utils/extractText';
import type Mithril from 'mithril';
import classList from '../../common/utils/classList';
import ThemeMode from '../../common/components/ThemeMode';
import { camelCaseToSnakeCase } from '../../common/utils/string';
import { ComponentAttrs } from '../../common/Component';
/**
* The `SettingsPage` component displays the user's settings control panel, in
@ -18,6 +21,7 @@ import classList from '../../common/utils/classList';
*/
export default class SettingsPage<CustomAttrs extends IUserPageAttrs = IUserPageAttrs> extends UserPage<CustomAttrs> {
discloseOnlineLoading?: boolean;
colorSchemeLoading?: boolean;
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
super.oninit(vnode);
@ -35,20 +39,35 @@ export default class SettingsPage<CustomAttrs extends IUserPageAttrs = IUserPage
);
}
sectionProps(): Record<string, ComponentAttrs> {
return {
account: { className: 'FieldSet--col' },
colorScheme: {
className: 'FieldSet--col',
visible: () => app.allowUserColorScheme,
},
};
}
/**
* Build an item list for the user's settings controls.
*/
settingsItems() {
const items = new ItemList<Mithril.Children>();
['account', 'notifications', 'privacy'].forEach((section, index) => {
['account', 'notifications', 'privacy', 'colorScheme'].forEach((section, index) => {
const sectionItems = `${section}Items` as 'accountItems' | 'notificationsItems' | 'privacyItems';
const { className, visible, ...props } = this.sectionProps()[section] || {};
if (visible && visible() === false) return;
items.add(
section,
<FieldSet
className={classList(`Settings-${section}`, { 'FieldSet--col': section === 'account' })}
label={app.translator.trans(`core.forum.settings.${section}_heading`)}
className={classList(`Settings-${section} FieldSet--min`, className || '')}
label={app.translator.trans(`core.forum.settings.${camelCaseToSnakeCase(section)}_heading`)}
{...props}
>
{this[sectionItems]().toArray()}
</FieldSet>,
@ -122,4 +141,35 @@ export default class SettingsPage<CustomAttrs extends IUserPageAttrs = IUserPage
return items;
}
/**
* Color schemes.
*/
colorSchemeItems() {
const items = new ItemList<Mithril.Children>();
ThemeMode.colorSchemes.forEach((mode) => {
items.add(
mode.id,
<ThemeMode
mode={mode.id}
label={mode.label || app.translator.trans('core.forum.settings.color_schemes.' + mode.id.replace('-', '_') + '_mode_label')}
selected={this.user!.preferences()?.colorScheme === mode.id}
loading={this.colorSchemeLoading}
onclick={() => {
this.colorSchemeLoading = true;
this.user!.savePreferences({ colorScheme: mode.id }).then(() => {
this.colorSchemeLoading = false;
app.setColorScheme(mode.id);
m.redraw();
});
}}
/>,
100
);
});
return items;
}
}

View File

@ -82,7 +82,7 @@
&--shaded {
background: var(--body-bg-shaded);
& when (@config-dark-mode = true) {
[data-theme^=dark] & {
background: var(--body-bg-light);
}
}

View File

@ -227,10 +227,8 @@
// the page toolbar that we styled earlier. We lay its contents out
// horizontally.
@media @phone {
.App-drawer {
& when (@config-colored-header = true) {
.light-contents(@name: 'header-colored');
}
[data-colored-header=true] .App-drawer {
.light-contents(@name: 'header-colored');
}
}
@ -258,7 +256,7 @@
box-shadow: 0 2px 6px var(--shadow-color);
}
& when (@config-colored-header = true) {
[data-colored-header=true] & {
.light-contents(@name: 'header-colored');
}
}

View File

@ -42,6 +42,11 @@
}
}
.Checkbox--switch.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.Checkbox--switch .Checkbox-display {
width: 50px;
height: 28px;

View File

@ -66,6 +66,10 @@
}
}
.FieldSet--min .FieldSet-items > * {
width: auto;
}
.FieldSet--col .FieldSet-items, .Form-controls {
flex-wrap: wrap;
flex-direction: row;

View File

@ -0,0 +1,211 @@
.ThemeMode {
display: block;
cursor: pointer;
max-width: 150px;
&-container {
border-radius: var(--border-radius);
border: 2px solid var(--control-bg);
overflow: hidden;
position: relative;
}
&:hover &-container {
border-color: var(--control-color);
}
&--active &-container, &--active:hover &-container {
border-color: var(--primary-color);
}
&--switch {
position: absolute;
top: 0;
left: 0;
clip-path: polygon(0% 0%, 100% 0%, 0% 100%);
}
&--switch &-container {
border-radius: 0;
border: none;
}
}
.ThemeMode-preview {
height: 150px;
width: 150px;
display: flex;
flex-direction: column;
background-color: var(--body-bg);
}
.ThemeMode-list {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.ThemeMode-header {
height: 25px;
flex-shrink: 0;
background-color: var(--header-bg);
display: flex;
align-items: center;
justify-content: flex-end;
padding-inline-end: 15px;
gap: 8px;
&-text {
height: 2px;
width: 15px;
background: var(--header-colored-color);
}
&-icon {
height: 5px;
width: 5px;
background: var(--header-colored-color);
border-radius: 100%;
}
}
.ThemeMode-hero {
background-color: var(--control-bg);
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 6px;
}
.ThemeMode-hero-title {
width: 25%;
height: 2px;
background-color: var(--control-color);
border-radius: var(--border-radius);
}
.ThemeMode-hero-desc {
width: 50%;
height: 1px;
background-color: var(--control-color);
}
.ThemeMode-main {
padding: 14px 0;
width: 75%;
flex-grow: 1;
margin: 0 auto;
display: grid;
grid-template-columns: 30px 1fr;
}
.ThemeMode-startDiscussion {
border-radius: calc(var(--border-radius) - 2px);
width: 100%;
height: 10px;
background-color: var(--primary-color);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
&-text {
height: 2px;
width: 40%;
background-color: var(--button-primary-color);
}
}
.ThemeMode-items {
display: flex;
flex-direction: column;
gap: 3px;
}
.ThemeMode-sidebar-line {
display: flex;
gap: 4px;
width: 100%;
align-items: center;
}
.ThemeMode-sidebar-text {
height: 1px;
background-color: var(--control-color);
flex-grow: 1;
}
.ThemeMode-sidebar-icon {
content: "";
display: block;
border-radius: 100%;
height: 3px;
width: 3px;
background-color: var(--control-color);
}
.ThemeMode-button {
height: 10px;
width: 20px;
background-color: var(--control-bg);
}
.ThemeMode-toolbar {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.ThemeMode-content {
padding: 0 8px;
}
.ThemeMode-content-item {
height: 10px;
width: 100%;
display: flex;
align-items: center;
}
.ThemeMode-toolbar :last-child {
width: 10px;
}
.ThemeMode-content-item-author {
background-color: var(--control-bg);
height: 9px;
width: 9px;
border-radius: 100%;
margin-inline-end: 4px;
}
.ThemeMode-content-item-title {
width: 25px;
height: 1px;
background-color: var(--discussion-title-color);
margin-bottom: 3px;
}
.ThemeMode-content-item-excerpt {
height: 1px;
width: 45px;
background-color: var(--muted-more-color);
}
.ThemeMode-container[data-theme=light], .ThemeMode-container[data-theme=dark] {
.ThemeMode-hero-title,
.ThemeMode-hero-desc,
.ThemeMode-sidebar-line,
.ThemeMode-content-item-title,
.ThemeMode-content-item-excerpt {
opacity: 0.4;
}
}
.ThemeMode-legend {
padding: 8px 0;
color: var(--control-color);
}

View File

@ -30,5 +30,6 @@
@import "Select";
@import "Table";
@import "TextEditor";
@import "ThemeMode";
@import "Tooltip";
@import "ValidationError";

View File

@ -33,6 +33,10 @@
background: var(~"--@{name}-control-bg-fadedin", fadein(@control-bg, 5%));
color: var(~"--@{name}-control-color", @control-color);
}
.Dropdown-menu>li>a, .Dropdown-menu>li>button, .Dropdown-menu>li>span {
color: var(--text-color);
}
}
.light-contents-vars(@color: #fff; @control-bg: fade(#000, 10%); @control-color: #fff; @name: 'light-content') {

View File

@ -1,65 +1,182 @@
:root {
.scheme(@mode) {
@body-bg: ~"body-bg-@{mode}";
@text-color: ~"text-color-@{mode}";
@heading-color: ~"heading-color-@{mode}";
@muted-color: ~"muted-color-@{mode}";
@muted-more-color: ~"muted-more-color-@{mode}";
@shadow-color: ~"shadow-color-@{mode}";
@control-bg: ~"control-bg-@{mode}";
@control-color: ~"control-color-@{mode}";
@control-danger-bg: ~"control-danger-bg-@{mode}";
@control-danger-color: ~"control-danger-color-@{mode}";
@header-bg: ~"header-bg-@{mode}";
@header-color: ~"header-color-@{mode}";
@header-control-bg: ~"header-control-bg-@{mode}";
@header-control-color: ~"header-control-color-@{mode}";
@header-bg-colored: ~"header-bg-colored-@{mode}";
@header-color-colored: ~"header-color-colored-@{mode}";
@header-control-bg-colored: ~"header-control-bg-colored-@{mode}";
@header-control-color-colored: ~"header-control-color-colored-@{mode}";
@overlay-bg: ~"overlay-bg-@{mode}";
@code-bg: ~"code-bg-@{mode}";
@code-color: ~"code-color-@{mode}";
// ---------------------------------
// COLORS
--primary-color: @primary-color;
--secondary-color: @secondary-color;
--body-bg: @@body-bg;
--body-bg-shaded: darken(@@body-bg, 3%);
--body-bg-light: lighten(@@body-bg, 5%);
--body-bg-faded: fade(@@body-bg, 93%);
--text-color: @@text-color;
--heading-color: @@heading-color;
--body-bg: @body-bg;
--body-bg-shaded: darken(@body-bg, 3%);
--body-bg-light: lighten(@body-bg, 5%);
--body-bg-faded: fade(@body-bg, 93%);
--text-color: @text-color;
--link-color: @link-color;
--heading-color: @heading-color;
--muted-color: @@muted-color;
--muted-color-light: lighten(@@muted-color, 10%);
--muted-color-dark: darken(@@muted-color, 50%);
--muted-more-color: @@muted-more-color;
--muted-color: @muted-color;
--muted-color-light: lighten(@muted-color, 10%);
--muted-color-dark: darken(@muted-color, 50%);
--muted-more-color: @muted-more-color;
--shadow-color: @@shadow-color;
--shadow-color: @shadow-color;
--control-bg: @control-bg;
--control-bg-light: lighten(@control-bg, 3%);
--control-bg-shaded: darken(@control-bg, 4%);
--control-color: @control-color;
--control-danger-bg: @control-danger-bg;
--control-danger-color: @control-danger-color;
--control-success-bg: @control-success-bg;
--control-success-color: @control-success-color;
--control-warning-bg: @control-warning-bg;
--control-warning-color: @control-warning-color;
--control-body-bg-mix: mix(@control-bg, @body-bg, 50%);
--control-muted-color: lighten(@control-color, 40%);
--error-color: @error-color;
// ---------------------------------
// UTILITIES
--text-on-dark: @text-on-dark;
--text-on-light: @text-on-light;
& when (@config-dark-mode = true) {
--yiq-threshold: 108;
}
& when (@config-dark-mode = false) {
--yiq-threshold: 150;
}
--control-bg: @@control-bg;
--control-bg-light: lighten(@@control-bg, 3%);
--control-bg-shaded: darken(@@control-bg, 4%);
--control-color: @@control-color;
--control-danger-bg: @@control-danger-bg;
--control-danger-color: @@control-danger-color;
--control-body-bg-mix: mix(@@control-bg, @@body-bg, 50%);
--control-muted-color: lighten(@@control-color, 40%);
// ---------------------------------
// COMPONENT COLORS
--header-bg: @header-bg;
--header-color: @header-color;
--header-control-bg: @header-control-bg;
--header-control-color: @header-control-color;
--header-bg: @@header-bg;
--header-color: @@header-color;
--header-control-bg: @@header-control-bg;
--header-control-color: @@header-control-color;
--overlay-bg: @overlay-bg;
--code-bg: @code-bg;
--code-color: @code-color;
[data-colored-header=true]& {
--header-bg: @@header-bg-colored;
--header-color: @@header-color-colored;
--header-control-bg: @@header-control-bg-colored;
--header-control-color: @@header-control-color-colored;
.light-contents-vars(@@header-color-colored, @@header-control-bg-colored, @@header-control-color-colored, 'header-colored');
}
--overlay-bg: @@overlay-bg;
--code-bg: @@code-bg;
--code-color: @@code-color;
--discussion-title-color: mix(@@heading-color, @@body-bg, 55%);
.Button--color-vars(@@control-color, @@control-bg, 'button');
.Button--color-vars(@@body-bg, @primary-color, 'button-primary');
.Button--color-vars(@@control-danger-color, @@control-danger-bg, 'control-danger');
.Button--color-vars(@@muted-more-color, fade(@@muted-more-color, 30%), 'muted-more');
.Button--color-vars(@@control-color, @@body-bg, 'button-inverted');
}
// ---------------------------------
// Light colors
[data-theme^=light] {
.scheme(light);
// ---------------------------------
// UTILITIES
--yiq-threshold: 150;
}
// High contrast
[data-theme=light-hc] {
@control-color: darken(@control-color-light, 20%);
@muted-color: darken(@muted-color-light, 20%);
--control-color: @control-color;
--control-muted-color: lighten(@control-color, 20%);
--muted-color: @muted-color;
--muted-color-light: lighten(@muted-color, 10%);
--muted-color-dark: darken(@muted-color, 50%);
--muted-more-color: darken(@muted-more-color-light, 20%);
--discussion-title-color: darken(@discussion-title-color-light, 20%);
.Button--color-vars(@control-color, @control-bg-light, 'button');
.Button--color-vars(@control-color, @body-bg-light, 'button-inverted');
}
// ---------------------------------
// Dark colors
[data-theme^=dark] {
.scheme(dark);
// ---------------------------------
// UTILITIES
--yiq-threshold: 108;
}
// High contrast
[data-theme=dark-hc] {
@control-color: lighten(@control-color-dark, 20%);
@muted-color: lighten(@muted-color-dark, 20%);
--control-color: @control-color;
--control-muted-color: darken(@control-color, 20%);
--muted-color: @muted-color;
--muted-color-light: darken(@muted-color, 10%);
--muted-color-dark: lighten(@muted-color, 50%);
--muted-more-color: lighten(@muted-more-color-dark, 20%);
--discussion-title-color: lighten(@discussion-title-color-dark, 20%);
.Button--color-vars(@control-color, @control-bg-dark, 'button');
.Button--color-vars(@control-color, @body-bg-dark, 'button-inverted');
}
// COMMON LIGHT/DARK HIGH CONTRAST COLORS
[data-theme=dark-hc], [data-theme=light-hc] {
.Button--color-vars(darken(@body-bg-dark, 10), @primary-color, 'button-primary');
[data-colored-header=true]& {
--header-bg: @header-bg-colored-dark;
--header-color: @header-color-colored-dark;
--header-control-bg: @header-control-bg-colored-dark;
--header-control-color: darken(@header-control-color-colored-dark, 15);
.light-contents-vars(@header-color-colored-dark, @header-control-bg-colored-dark, darken(@header-control-color-colored-dark, 15), 'header-colored');
}
--yiq-threshold: 80;
}
// ---------------------------------
// Common light/dark colors
:root {
--primary-color: @primary-color;
--secondary-color: @secondary-color;
--link-color: @link-color;
--control-success-bg: @control-success-bg;
--control-success-color: @control-success-color;
--control-warning-bg: @control-warning-bg;
--control-warning-color: @control-warning-color;
--error-color: @error-color;
--text-on-dark: @text-on-dark;
--text-on-light: @text-on-light;
--alert-bg: @alert-bg;
--alert-color: @alert-color;
@ -76,34 +193,30 @@
--validation-error-color: @validation-error-color;
--avatar-bg: var(--control-bg);
--badge-bg: var(--muted-color);
--badge-color: #fff;
--badge-hidden-bg: #888;
--usercard-bg: var(--control-bg);
--hero-bg: @hero-bg;
--hero-color: @hero-color;
--tooltip-bg: @tooltip-bg;
--tooltip-color: @tooltip-color;
--loading-indicator-color: var(--muted-color);
--online-user-circle-color: @online-user-circle-color;
--discussion-title-color: mix(@heading-color, @body-bg, 55%);
--discussion-list-item-bg-hover: var(--control-body-bg-mix);
.Button--color-vars(@control-color, @control-bg, 'button');
.Button--color-vars(@body-bg, @primary-color, 'button-primary');
.Button--color-vars(@control-danger-color, @control-danger-bg, 'control-danger');
.Button--color-vars(@control-success-color, @control-success-bg, 'control-success');
.Button--color-vars(@control-warning-color, @control-warning-bg, 'control-warning');
.Button--color-vars(@muted-more-color, fade(@muted-more-color, 30%), 'muted-more');
.Button--color-vars(@control-color, @body-bg, 'button-inverted');
--avatar-bg: var(--control-bg);
--badge-bg: var(--muted-color);
--badge-color: #fff;
--badge-hidden-bg: #888;
--usercard-bg: var(--control-bg);
--hero-bg: var(--control-bg);
--hero-color: var(--control-color);
.light-contents-vars();
.light-contents-vars(@header-color, @header-control-bg, @header-control-color, 'header-colored');
.Button--color-vars(@control-success-color, @control-success-bg, 'control-success');
.Button--color-vars(@control-warning-color, @control-warning-bg, 'control-warning');
}
:root {
// ---------------------------------
// LAYOUT

View File

@ -3,88 +3,95 @@
@config-primary-color: #536F90;
@config-secondary-color: #536F90;
@config-dark-mode: false;
@config-colored-header: false;
// ---------------------------------
// COLORS
@primary-color: @config-primary-color;
@secondary-color: @config-secondary-color;
@primary-hue: hue(@primary-color);
@primary-sat: saturation(@primary-color);
@secondary-hue: hue(@secondary-color);
@secondary-sat: saturation(@secondary-color);
// SCHEMES
@body-bg-light: #fff;
@body-bg-dark: hsl(@secondary-hue, min(20%, @secondary-sat), 10%);
// Derive the primary/secondary colors from the config variables. In dark mode,
// we make the user-set colors a bit darker, otherwise they will stand out too
// much.
.define-colors(@config-dark-mode);
.define-colors(false) {
@primary-color: @config-primary-color;
@secondary-color: @config-secondary-color;
@muted-color-light: hsl(@secondary-hue, min(20%, @secondary-sat), 50%);
@muted-color-dark: hsl(@secondary-hue, min(15%, @secondary-sat), 50%);
@muted-more-color-light: #aaa;
@muted-more-color-dark: hsl(@secondary-hue, min(10%, @secondary-sat), 40%);
@body-bg: @body-bg-light;
@text-color: #111;
@link-color: saturate(@primary-color, 10%);
@heading-color: @text-color;
@muted-color: hsl(@secondary-hue, min(20%, @secondary-sat), 50%);
@muted-more-color: #aaa;
@shadow-color: rgba(0, 0, 0, 0.35);
@link-color: saturate(@primary-color, 10%);
@control-bg: hsl(@secondary-hue, min(50%, @secondary-sat), 93%);
@control-color: @muted-color;
@control-danger-bg: #fdd;
@control-danger-color: #d66;
@control-success-bg: #B4F1AF;
@control-success-color: #33722D;
@control-warning-bg: #fff2ae;
@control-warning-color: #ad6c00;
@control-bg-light: hsl(@secondary-hue, min(50%, @secondary-sat), 93%);
@control-bg-dark: hsl(@secondary-hue, min(20%, @secondary-sat), 13%);
@overlay-bg: fade(@secondary-color, 90%);
@control-color-light: @muted-color-light;
@control-color-dark: @muted-color-dark;
@code-bg: darken(@body-bg, 3%);
@code-color: lighten(@text-color, 30%);
}
.define-colors(true) {
@primary-color: @config-primary-color;
@secondary-color: @config-secondary-color;
@control-danger-bg-light: #fdd;
@control-danger-bg-dark: #411;
@control-danger-color-light: #d66;
@control-danger-color-dark: #a88;
@body-bg: @body-bg-dark;
@text-color: #ddd;
@link-color: saturate(@primary-color, 10%);
@heading-color: @text-color;
@muted-color: hsl(@secondary-hue, min(15%, @secondary-sat), 50%);
@muted-more-color: hsl(@secondary-hue, min(10%, @secondary-sat), 40%);
@shadow-color: rgba(0, 0, 0, 0.5);
@text-color-light: #111;
@text-color-dark: #ddd;
@control-bg: hsl(@secondary-hue, min(20%, @secondary-sat), 13%);
@control-color: @muted-color;
@control-danger-bg: #411;
@control-danger-color: #a88;
@control-success-bg: #B4F1AF;
@control-success-color: #33722D;
@control-warning-bg: #fff2ae;
@control-warning-color: #ad6c00;
@heading-color-light: @text-color-light;
@heading-color-dark: @text-color-dark;
@overlay-bg: fade(darken(@body-bg, 5%), 90%);
@shadow-color-light: rgba(0, 0, 0, 0.35);
@shadow-color-dark: rgba(0, 0, 0, 0.5);
@code-bg: darken(@body-bg, 3%);
@code-color: #fff;
}
@overlay-bg-light: fade(@secondary-color, 90%);
@overlay-bg-dark: fade(darken(@body-bg-dark, 5%), 90%);
@code-bg-light: darken(@body-bg-light, 3%);
@code-bg-dark: darken(@body-bg-dark, 3%);
@code-color-light: lighten(@text-color-light, 30%);
@code-color-dark: #fff;
// Beyond dark or light mode, we need stable colors depending on the luminosity
// of the parents element's background. This allow not to change the color
// variable depending on the dark/light mode to get the same final color, and
// thus to simplify the logic.
@text-on-dark: @body-bg-light;
@text-on-light: @body-bg-dark;
@hero-bg: @control-bg;
@hero-color: @control-color;
@hero-muted-color: @control-color;
@discussion-title-color-light: mix(@heading-color-light, @body-bg-light, 55%);
@discussion-title-color-dark: mix(@heading-color-dark, @body-bg-dark, 55%);
// Header colors depend on whether the header is colored,
// and whether we are on light or dark modes.
@header-bg-light: @body-bg-light;
@header-color-light: @primary-color;
@header-control-bg-light: @control-bg-light;
@header-control-color-light: @control-color-light;
@header-bg-dark: @body-bg-dark;
@header-color-dark: @primary-color;
@header-control-bg-dark: @control-bg-dark;
@header-control-color-dark: @control-color-dark;
@header-bg-colored-light: @primary-color;
@header-color-colored-light: @body-bg-light;
@header-control-bg-colored-light: mix(#000, @header-bg-colored-light, 10%);
@header-control-color-colored-light: mix(@body-bg-light, @header-bg-colored-light, 60%);
@header-bg-colored-dark: @primary-color;
@header-color-colored-dark: @body-bg-dark;
@header-control-bg-colored-dark: mix(#000, @header-bg-colored-dark, 10%);
@header-control-color-colored-dark: mix(@body-bg-dark, @header-bg-colored-dark, 60%);
// COMMON COLORS
@control-success-bg: #B4F1AF;
@control-success-color: #33722D;
@control-warning-bg: #fff2ae;
@control-warning-color: #ad6c00;
@error-color: #d83e3e;
@ -99,20 +106,6 @@
@validation-error-color: @error-color;
.define-header(@config-colored-header);
.define-header(false) {
@header-bg: @body-bg;
@header-color: @primary-color;
@header-control-bg: @control-bg;
@header-control-color: @control-color;
}
.define-header(true) {
@header-bg: @primary-color;
@header-color: @body-bg;
@header-control-bg: mix(#000, @header-bg, 10%);
@header-control-color: mix(@body-bg, @header-bg, 60%);
}
// ---------------------------------
// LAYOUT

View File

@ -32,7 +32,7 @@
// dropdown menu – but the drawer may have .light-contents() applied to
// it. In this case we will need to reset the button's styles back to
// normal.
& when (@config-colored-header = true) {
[data-colored-header=true] & {
color: var(--control-color);
&:hover,
@ -196,7 +196,7 @@
.add-keyboard-focus-ring-offset(4px);
// Needs more specificity to fix hover/focus styles not applying in dropdown
.HeaderListItem & when (@config-colored-header = true) {
[data-colored-header=true] .HeaderListItem & {
color: var(--control-color);
&:hover,

View File

@ -1,3 +1,22 @@
.Page--cols {
&-container {
display: flex;
}
&-content {
width: var(--content-width);
}
&-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
}
&-content, &-sidebar {
margin-top: 30px;
}
}
.Page {
--content-width: 100%;
--sidebar-width: 190px;
@ -17,22 +36,3 @@
}
}
}
.Page--cols {
&-container {
display: flex;
}
&-content {
width: var(--content-width);
}
&-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
}
&-content, &-sidebar {
margin-top: 30px;
}
}

View File

@ -58,6 +58,7 @@ core:
# These translations are used in the Appearance page.
appearance:
allow_user_color_scheme_label: Allow users to choose their own color scheme
colored_header_label: Colored Header
colors_heading: Colors
colors_primary_label: Primary Color
@ -70,7 +71,6 @@ core:
custom_styles_cannot_use_less_features: "The @import and data-uri features are not allowed in custom LESS."
custom_styles_heading: Custom Styles
custom_styles_text: Customize your forum's appearance by adding your own Less/CSS code to be applied on top of Flarum's default styles.
dark_mode_label: Dark Mode
description: "Customize your forum's colors, logos, and other variables."
edit_css_button: Edit Custom CSS
edit_footer_button: => core.ref.custom_footer_title
@ -81,6 +81,13 @@ core:
logo_heading: Logo
logo_text: Upload an image to be displayed in place of the forum title.
title: Appearance
color_scheme_label: Color Scheme (default)
color_schemes:
auto_mode_label: User system or configured preference
dark_hc_mode_label: => core.ref.dark_hc_mode_label
dark_mode_label: => core.ref.dark_mode_label
light_hc_mode_label: => core.ref.light_hc_mode_label
light_mode_label: => core.ref.light_mode_label
# These translations are used in the Basics page.
basics:
@ -609,6 +616,13 @@ core:
account_heading: Account
change_email_button: => core.ref.change_email
change_password_button: => core.ref.change_password
color_scheme_heading: Color Scheme
color_schemes:
auto_mode_label: System preference
dark_hc_mode_label: => core.ref.dark_hc_mode_label
dark_mode_label: => core.ref.dark_mode_label
light_hc_mode_label: => core.ref.light_hc_mode_label
light_mode_label: => core.ref.light_mode_label
notification_checkbox_a11y_label_template: 'Receive "{description}" notifications via {method}'
notifications_heading: => core.ref.notifications
notify_by_email_heading: => core.ref.email
@ -965,6 +979,8 @@ core:
custom_footer_title: Edit Custom Footer
custom_header_text: "Add HTML to be displayed at the very top of the page, above Flarum's own header."
custom_header_title: Edit Custom Header
dark_hc_mode_label: Dark High Contrast Mode
dark_mode_label: Dark Mode
delete: Delete
delete_forever: Delete Forever
discussions: Discussions # Referenced by flarum-statistics.yml
@ -976,6 +992,8 @@ core:
icon: Icon
icon_text: "Enter the name of any <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
invalid_login_message: Your login details were incorrect.
light_hc_mode_label: Light High Contrast Mode
light_mode_label: Light Mode
load_more: Load More
loading: Loading...
log_in: Log In

View File

@ -0,0 +1,56 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$darkMode = $schema->getConnection()
->table('settings')
->where('key', 'theme_dark_mode')
->first();
$schema->getConnection()
->table('settings')
->insert([
[
'key' => 'color_scheme',
'value' => $darkMode === '1' ? 'dark' : 'auto',
],
[
'key' => 'allow_user_color_scheme',
'value' => '1',
]
]);
$schema->getConnection()
->table('settings')
->where('key', 'theme_dark_mode')
->delete();
},
'down' => function (Builder $schema) {
$themeMode = $schema->getConnection()
->table('settings')
->where('key', 'color_scheme')
->first();
$schema->getConnection()
->table('settings')
->insert([
'key' => 'theme_dark_mode',
'value' => $themeMode === 'dark' ? '1' : '0',
]);
$schema->getConnection()
->table('settings')
->whereIn('key', ['color_scheme', 'allow_user_color_scheme'])
->delete();
}
];

View File

@ -36,6 +36,7 @@ class Frontend implements ExtenderInterface
private array $preloadArrs = [];
private ?string $titleDriver = null;
private array $jsDirectory = [];
private array $extraDocumentAttributes = [];
/**
* @param string $frontend: The name of the frontend.
@ -187,6 +188,35 @@ class Frontend implements ExtenderInterface
return $this;
}
/**
* Adds document root attributes.
*
* @example ['data-test' => 'value']
* @example ['data-test' => function (ServerRequestInterface $request) { return 'value'; }]
*
* @param array<string, string|callable> $attributes
*/
public function extraDocumentAttributes(array $attributes): self
{
$this->extraDocumentAttributes[] = $attributes;
return $this;
}
/**
* Adds document root classes.
*
* Can either be a string or an array of strings.
*
* An array can be of a format acceptable by the @class blade directive.
*
* @example ['class1', 'class2' => true, 'class3' => false]
*/
public function extraDocumentClasses(string|array|callable $classes): self
{
return $this->extraDocumentAttributes(['class' => $classes]);
}
public function extend(Container $container, Extension $extension = null): void
{
$this->registerAssets($container, $this->getModuleName($extension));
@ -194,6 +224,7 @@ class Frontend implements ExtenderInterface
$this->registerContent($container);
$this->registerPreloads($container);
$this->registerTitleDriver($container);
$this->registerDocumentAttributes($container);
}
private function registerAssets(Container $container, string $moduleName): void
@ -330,4 +361,23 @@ class Frontend implements ExtenderInterface
});
}
}
private function registerDocumentAttributes(Container $container): void
{
if (empty($this->extraDocumentAttributes)) {
return;
}
$container->resolving(
"flarum.frontend.$this->frontend",
function (ActualFrontend $frontend, Container $container) {
$frontend->content(function (Document $document) use ($container) {
foreach ($this->extraDocumentAttributes as $classes) {
$classes = is_callable($classes) ? ContainerUtil::wrapCallback($classes, $container) : $classes;
$document->extraAttributes[] = $classes;
}
}, 111);
}
);
}
}

View File

@ -135,6 +135,15 @@ class Document implements Renderable
*/
public array $preloads = [];
/**
* Document extra attributes.
*
* @var array<string, string|callable|array>
*/
public array $extraAttributes = [
'class' => [],
];
/**
* We need the versioner to get the revisions of split chunks.
*/
@ -171,6 +180,8 @@ class Document implements Renderable
'js' => $this->makeJs(),
'head' => $this->makeHead(),
'foot' => $this->makeFoot(),
'extraAttributes' => $this->makeExtraAttributes(),
'extraClasses' => $this->makeExtraClasses(),
'revisions' => $this->versioner->allRevisions(),
'debug' => $this->config->inDebugMode(),
]);
@ -208,6 +219,50 @@ class Document implements Renderable
}, $this->preloads);
}
protected function makeExtraClasses(): array
{
$classes = [];
$extraClasses = $this->extraAttributes['class'] ?? [];
foreach ($extraClasses as $class) {
if (is_callable($class)) {
$class = $class($this->request);
}
$classes = array_merge($classes, (array) $class);
}
return $classes;
}
protected function makeExtraAttributes(): string
{
$attributes = [];
foreach ($this->extraAttributes as $key => $value) {
if ($key === 'class') {
continue;
}
if (is_callable($value)) {
$value = $value($this->request);
}
$attributes[$key] = $value;
}
return array_reduce(array_keys($attributes), function (string $carry, string $key) use ($attributes): string {
$value = $attributes[$key];
if (is_array($value)) {
$value = implode(' ', $value);
}
return $carry.' '.$key.'="'.e($value).'"';
}, '');
}
protected function makeHead(): string
{
$head = array_map(function ($url) {

View File

@ -17,6 +17,7 @@ use Flarum\Foundation\Paths;
use Flarum\Frontend\Compiler\Source\SourceCollector;
use Flarum\Frontend\Driver\BasicTitleDriver;
use Flarum\Frontend\Driver\TitleDriverInterface;
use Flarum\Http\RequestUtil;
use Flarum\Http\SlugManager;
use Flarum\Http\UrlGenerator;
use Flarum\Locale\LocaleManager;
@ -24,6 +25,7 @@ use Flarum\Settings\SettingsRepositoryInterface;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Psr\Http\Message\ServerRequestInterface;
class FrontendServiceProvider extends AbstractServiceProvider
{
@ -95,6 +97,16 @@ class FrontendServiceProvider extends AbstractServiceProvider
$default_preloads,
$document->preloads,
);
/** @var SettingsRepositoryInterface $settings */
$settings = $container->make(SettingsRepositoryInterface::class);
// Add document classes/attributes for design use cases.
$document->extraAttributes['data-theme'] = $settings->get('color_scheme');
$document->extraAttributes['data-colored-header'] = $settings->get('theme_colored_header') ? 'true' : 'false';
$document->extraAttributes['class'][] = function (ServerRequestInterface $request) {
return RequestUtil::getActor($request)->isGuest() ? 'guest-user' : 'logged-in';
};
}, 160);
return $frontend;
@ -152,18 +164,6 @@ class FrontendServiceProvider extends AbstractServiceProvider
'config-secondary-color' => [
'key' => 'theme_secondary_color',
],
'config-dark-mode' => [
'key' => 'theme_dark_mode',
'callback' => function ($value) {
return $value ? 'true' : 'false';
},
],
'config-colored-header' => [
'key' => 'theme_colored_header',
'callback' => function ($value) {
return $value ? 'true' : 'false';
},
],
];
});

View File

@ -62,7 +62,7 @@ class WriteSettings implements Step
'mail_from' => 'noreply@localhost',
'slug_driver_Flarum\User\User' => 'default',
'theme_colored_header' => '0',
'theme_dark_mode' => '0',
'color_scheme' => 'auto',
'theme_primary_color' => '#4D698E',
'theme_secondary_color' => '#4D698E',
'welcome_message' => 'Enjoy your new forum! Hop over to discuss.flarum.org if you have any questions, or to join our community!',

View File

@ -124,6 +124,7 @@ class UserServiceProvider extends AbstractServiceProvider
User::registerPreference('discloseOnline', 'boolval', true);
User::registerPreference('indexProfile', 'boolval', true);
User::registerPreference('locale');
User::registerPreference('colorScheme', 'strval', 'auto');
User::registerVisibilityScoper(new ScopeUserVisibility(), 'view');
}

View File

@ -1,6 +1,5 @@
<!doctype html>
<html @if ($direction) dir="{{ $direction }}" @endif
@if ($language) lang="{{ $language }}" @endif>
<html @if ($direction) dir="{{ $direction }}" @endif @if ($language) lang="{{ $language }}" @endif @class($extraClasses) {!! $extraAttributes !!}>
<head>
<meta charset="utf-8">
<title>{{ $title }}</title>