diff --git a/extensions/flags/less/forum.less b/extensions/flags/less/forum.less
index 9b89587c0c..caf53d2eb6 100644
--- a/extensions/flags/less/forum.less
+++ b/extensions/flags/less/forum.less
@@ -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;
diff --git a/extensions/package-manager/less/admin.less b/extensions/package-manager/less/admin.less
index 7c290b2ff2..c58a01d7f1 100755
--- a/extensions/package-manager/less/admin.less
+++ b/extensions/package-manager/less/admin.less
@@ -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;
diff --git a/extensions/subscriptions/less/forum.less b/extensions/subscriptions/less/forum.less
index c35f76e0a8..de55a0ead2 100644
--- a/extensions/subscriptions/less/forum.less
+++ b/extensions/subscriptions/less/forum.less
@@ -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;
diff --git a/extensions/tags/less/forum/ToggleButton.less b/extensions/tags/less/forum/ToggleButton.less
index 35d43a2228..74db40c135 100644
--- a/extensions/tags/less/forum/ToggleButton.less
+++ b/extensions/tags/less/forum/ToggleButton.less
@@ -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 {
diff --git a/framework/core/js/src/admin/components/AppearancePage.tsx b/framework/core/js/src/admin/components/AppearancePage.tsx
index 9dc3389dd4..91681c745b 100644
--- a/framework/core/js/src/admin/components/AppearancePage.tsx
+++ b/framework/core/js/src/admin/components/AppearancePage.tsx
@@ -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 (
+
+
+
+ {ThemeMode.colorSchemes.map((mode) => (
+ {
+ 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}
+ />
+ ))}
+
+
+ );
}),
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
);
diff --git a/framework/core/js/src/common/Application.tsx b/framework/core/js/src/common/Application.tsx
index 2e362e9699..7d0a13af38 100644
--- a/framework/core/js/src/common/Application.tsx
+++ b/framework/core/js/src/common/Application.tsx
@@ -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.
*/
diff --git a/framework/core/js/src/common/components/ThemeMode.tsx b/framework/core/js/src/common/components/ThemeMode.tsx
new file mode 100644
index 0000000000..b226cad0f8
--- /dev/null
+++ b/framework/core/js/src/common/components/ThemeMode.tsx
@@ -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 extends Component {
+ static colorSchemes: ColorSchemeData[] = [
+ { id: ColorScheme.Auto },
+ { id: ColorScheme.Light },
+ { id: ColorScheme.Dark },
+ { id: ColorScheme.LightHighContrast },
+ { id: ColorScheme.DarkHighContrast },
+ ];
+
+ view(vnode: Mithril.Vnode): Mithril.Children {
+ const { mode, selected, className, alternate, label, ...attrs } = vnode.attrs;
+
+ return (
+
+ );
+ }
+}
diff --git a/framework/core/js/src/common/extenders/ThemeMode.ts b/framework/core/js/src/common/extenders/ThemeMode.ts
new file mode 100644
index 0000000000..64d760b7f0
--- /dev/null
+++ b/framework/core/js/src/common/extenders/ThemeMode.ts
@@ -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]));
+ }
+}
diff --git a/framework/core/js/src/common/extenders/index.ts b/framework/core/js/src/common/extenders/index.ts
index 8702e00d35..f776f6f711 100644
--- a/framework/core/js/src/common/extenders/index.ts
+++ b/framework/core/js/src/common/extenders/index.ts
@@ -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;
diff --git a/framework/core/js/src/forum/components/SettingsPage.tsx b/framework/core/js/src/forum/components/SettingsPage.tsx
index 15e18b95a8..bdf1069610 100644
--- a/framework/core/js/src/forum/components/SettingsPage.tsx
+++ b/framework/core/js/src/forum/components/SettingsPage.tsx
@@ -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 extends UserPage {
discloseOnlineLoading?: boolean;
+ colorSchemeLoading?: boolean;
oninit(vnode: Mithril.Vnode) {
super.oninit(vnode);
@@ -35,20 +39,35 @@ export default class SettingsPage {
+ 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();
- ['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,
,
@@ -122,4 +141,35 @@ export default class SettingsPage();
+
+ ThemeMode.colorSchemes.forEach((mode) => {
+ items.add(
+ mode.id,
+ {
+ this.colorSchemeLoading = true;
+
+ this.user!.savePreferences({ colorScheme: mode.id }).then(() => {
+ this.colorSchemeLoading = false;
+ app.setColorScheme(mode.id);
+ m.redraw();
+ });
+ }}
+ />,
+ 100
+ );
+ });
+
+ return items;
+ }
}
diff --git a/framework/core/less/admin/UsersListPage.less b/framework/core/less/admin/UsersListPage.less
index 54ee110926..3f92b72ab5 100644
--- a/framework/core/less/admin/UsersListPage.less
+++ b/framework/core/less/admin/UsersListPage.less
@@ -82,7 +82,7 @@
&--shaded {
background: var(--body-bg-shaded);
- & when (@config-dark-mode = true) {
+ [data-theme^=dark] & {
background: var(--body-bg-light);
}
}
diff --git a/framework/core/less/common/App.less b/framework/core/less/common/App.less
index 0162ef2ed7..c053c5c23c 100644
--- a/framework/core/less/common/App.less
+++ b/framework/core/less/common/App.less
@@ -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');
}
}
diff --git a/framework/core/less/common/Checkbox.less b/framework/core/less/common/Checkbox.less
index 79949e7fb3..7ebbfd44fd 100644
--- a/framework/core/less/common/Checkbox.less
+++ b/framework/core/less/common/Checkbox.less
@@ -42,6 +42,11 @@
}
}
+.Checkbox--switch.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
.Checkbox--switch .Checkbox-display {
width: 50px;
height: 28px;
diff --git a/framework/core/less/common/Form.less b/framework/core/less/common/Form.less
index 1278afc8e1..06802be484 100644
--- a/framework/core/less/common/Form.less
+++ b/framework/core/less/common/Form.less
@@ -66,6 +66,10 @@
}
}
+.FieldSet--min .FieldSet-items > * {
+ width: auto;
+}
+
.FieldSet--col .FieldSet-items, .Form-controls {
flex-wrap: wrap;
flex-direction: row;
diff --git a/framework/core/less/common/ThemeMode.less b/framework/core/less/common/ThemeMode.less
new file mode 100644
index 0000000000..4fccc5f2a6
--- /dev/null
+++ b/framework/core/less/common/ThemeMode.less
@@ -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);
+}
diff --git a/framework/core/less/common/common.less b/framework/core/less/common/common.less
index e2eb37bb83..78cd44e932 100644
--- a/framework/core/less/common/common.less
+++ b/framework/core/less/common/common.less
@@ -30,5 +30,6 @@
@import "Select";
@import "Table";
@import "TextEditor";
+@import "ThemeMode";
@import "Tooltip";
@import "ValidationError";
diff --git a/framework/core/less/common/mixins/light-contents.less b/framework/core/less/common/mixins/light-contents.less
index 8a726b812a..0eb3efa3c7 100644
--- a/framework/core/less/common/mixins/light-contents.less
+++ b/framework/core/less/common/mixins/light-contents.less
@@ -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') {
diff --git a/framework/core/less/common/root.less b/framework/core/less/common/root.less
index 3ddf7d58e1..03b6ba5a8a 100644
--- a/framework/core/less/common/root.less
+++ b/framework/core/less/common/root.less
@@ -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;
+
+ --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;
+
+ --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%);
- --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;
+ // ---------------------------------
+ // COMPONENT COLORS
+
+ --header-bg: @@header-bg;
+ --header-color: @@header-color;
+ --header-control-bg: @@header-control-bg;
+ --header-control-color: @@header-control-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: @muted-more-color;
+ --muted-more-color: darken(@muted-more-color-light, 20%);
- --shadow-color: @shadow-color;
+ --discussion-title-color: darken(@discussion-title-color-light, 20%);
- --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%);
+ .Button--color-vars(@control-color, @control-bg-light, 'button');
+ .Button--color-vars(@control-color, @body-bg-light, 'button-inverted');
+}
- --error-color: @error-color;
+// ---------------------------------
+// Dark colors
+
+[data-theme^=dark] {
+ .scheme(dark);
// ---------------------------------
// UTILITIES
- --text-on-dark: @text-on-dark;
- --text-on-light: @text-on-light;
+ --yiq-threshold: 108;
+}
- & when (@config-dark-mode = true) {
- --yiq-threshold: 108;
- }
- & when (@config-dark-mode = false) {
- --yiq-threshold: 150;
+// 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');
}
- // ---------------------------------
- // COMPONENT COLORS
+ --yiq-threshold: 80;
+}
+
+// ---------------------------------
+// Common light/dark colors
+
+:root {
- --header-bg: @header-bg;
- --header-color: @header-color;
- --header-control-bg: @header-control-bg;
- --header-control-color: @header-control-color;
+ --primary-color: @primary-color;
+ --secondary-color: @secondary-color;
- --overlay-bg: @overlay-bg;
- --code-bg: @code-bg;
- --code-color: @code-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');
+ --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();
+
.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');
- .light-contents-vars();
- .light-contents-vars(@header-color, @header-control-bg, @header-control-color, 'header-colored');
+}
+
+:root {
// ---------------------------------
// LAYOUT
diff --git a/framework/core/less/common/variables.less b/framework/core/less/common/variables.less
index 4995e850a4..04d1d45983 100644
--- a/framework/core/less/common/variables.less
+++ b/framework/core/less/common/variables.less
@@ -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;
-
- @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);
-
- @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;
-
- @overlay-bg: fade(@secondary-color, 90%);
-
- @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;
-
- @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);
-
- @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;
-
- @overlay-bg: fade(darken(@body-bg, 5%), 90%);
-
- @code-bg: darken(@body-bg, 3%);
- @code-color: #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.
+@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%);
+
+@link-color: saturate(@primary-color, 10%);
+
+@control-bg-light: hsl(@secondary-hue, min(50%, @secondary-sat), 93%);
+@control-bg-dark: hsl(@secondary-hue, min(20%, @secondary-sat), 13%);
+
+@control-color-light: @muted-color-light;
+@control-color-dark: @muted-color-dark;
+
+@control-danger-bg-light: #fdd;
+@control-danger-bg-dark: #411;
+@control-danger-color-light: #d66;
+@control-danger-color-dark: #a88;
+
+@text-color-light: #111;
+@text-color-dark: #ddd;
+
+@heading-color-light: @text-color-light;
+@heading-color-dark: @text-color-dark;
+
+@shadow-color-light: rgba(0, 0, 0, 0.35);
+@shadow-color-dark: rgba(0, 0, 0, 0.5);
+
+@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;
+
@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
diff --git a/framework/core/less/forum/HeaderList.less b/framework/core/less/forum/HeaderList.less
index 125135fa5b..54dc58b190 100644
--- a/framework/core/less/forum/HeaderList.less
+++ b/framework/core/less/forum/HeaderList.less
@@ -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,
diff --git a/framework/core/less/forum/PageStructure.less b/framework/core/less/forum/PageStructure.less
index 6b22dfe9fe..ec32af4a97 100644
--- a/framework/core/less/forum/PageStructure.less
+++ b/framework/core/less/forum/PageStructure.less
@@ -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;
- }
-}
diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml
index 7f540a16f0..9367cacd1c 100644
--- a/framework/core/locale/core.yml
+++ b/framework/core/locale/core.yml
@@ -56,6 +56,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
@@ -68,7 +69,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
@@ -79,6 +79,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:
@@ -606,6 +613,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
@@ -961,6 +975,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
@@ -972,6 +988,8 @@ core:
icon: Icon
icon_text: "Enter the name of any FontAwesome icon class, including the fas fa-
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
diff --git a/framework/core/migrations/2024_06_07_000000_change_to_theme_mode_setting_and_preference.php b/framework/core/migrations/2024_06_07_000000_change_to_theme_mode_setting_and_preference.php
new file mode 100644
index 0000000000..85b0721d45
--- /dev/null
+++ b/framework/core/migrations/2024_06_07_000000_change_to_theme_mode_setting_and_preference.php
@@ -0,0 +1,56 @@
+ 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();
+ }
+];
diff --git a/framework/core/src/Extend/Frontend.php b/framework/core/src/Extend/Frontend.php
index 331f63a024..ef05abe898 100644
--- a/framework/core/src/Extend/Frontend.php
+++ b/framework/core/src/Extend/Frontend.php
@@ -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 @@ public function title(string $driverClass): self
return $this;
}
+ /**
+ * Adds document root attributes.
+ *
+ * @example ['data-test' => 'value']
+ * @example ['data-test' => function (ServerRequestInterface $request) { return 'value'; }]
+ *
+ * @param array $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 @@ public function extend(Container $container, Extension $extension = null): void
$this->registerContent($container);
$this->registerPreloads($container);
$this->registerTitleDriver($container);
+ $this->registerDocumentAttributes($container);
}
private function registerAssets(Container $container, string $moduleName): void
@@ -330,4 +361,23 @@ private function registerTitleDriver(Container $container): void
});
}
}
+
+ 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);
+ }
+ );
+ }
}
diff --git a/framework/core/src/Frontend/Document.php b/framework/core/src/Frontend/Document.php
index 3b03602cc1..ed242495af 100644
--- a/framework/core/src/Frontend/Document.php
+++ b/framework/core/src/Frontend/Document.php
@@ -135,6 +135,15 @@ class Document implements Renderable
*/
public array $preloads = [];
+ /**
+ * Document extra attributes.
+ *
+ * @var array
+ */
+ public array $extraAttributes = [
+ 'class' => [],
+ ];
+
/**
* We need the versioner to get the revisions of split chunks.
*/
@@ -171,6 +180,8 @@ protected function makeView(): View
'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 @@ protected function makePreloads(): array
}, $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) {
diff --git a/framework/core/src/Frontend/FrontendServiceProvider.php b/framework/core/src/Frontend/FrontendServiceProvider.php
index 6df1d685b1..bc859d89e9 100644
--- a/framework/core/src/Frontend/FrontendServiceProvider.php
+++ b/framework/core/src/Frontend/FrontendServiceProvider.php
@@ -17,6 +17,7 @@
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 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 @@ public function register(): void
$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 @@ function (Container $container) {
'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';
- },
- ],
];
});
diff --git a/framework/core/src/Install/Steps/WriteSettings.php b/framework/core/src/Install/Steps/WriteSettings.php
index b62c535157..e4bb582e0a 100644
--- a/framework/core/src/Install/Steps/WriteSettings.php
+++ b/framework/core/src/Install/Steps/WriteSettings.php
@@ -62,7 +62,7 @@ private function getDefaults(): array
'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!',
diff --git a/framework/core/src/User/UserServiceProvider.php b/framework/core/src/User/UserServiceProvider.php
index 7334cd5c51..524871feaa 100644
--- a/framework/core/src/User/UserServiceProvider.php
+++ b/framework/core/src/User/UserServiceProvider.php
@@ -124,6 +124,7 @@ public function boot(Container $container, Dispatcher $events): void
User::registerPreference('discloseOnline', 'boolval', true);
User::registerPreference('indexProfile', 'boolval', true);
User::registerPreference('locale');
+ User::registerPreference('colorScheme', 'strval', 'auto');
User::registerVisibilityScoper(new ScopeUserVisibility(), 'view');
}
diff --git a/framework/core/views/frontend/app.blade.php b/framework/core/views/frontend/app.blade.php
index ff025f38d2..764d53c6c1 100644
--- a/framework/core/views/frontend/app.blade.php
+++ b/framework/core/views/frontend/app.blade.php
@@ -1,6 +1,5 @@
-
+
{{ $title }}