feat(branding): force color mode (dark or light) site-wide
This commit is contained in:
@@ -219,9 +219,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
|||||||
logo_display_header,
|
logo_display_header,
|
||||||
logo_display_hero,
|
logo_display_hero,
|
||||||
logo_display_mode,
|
logo_display_mode,
|
||||||
hide_powered_by
|
hide_powered_by,
|
||||||
|
force_color_mode
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
|
||||||
|
const normalizedForceColorMode = force_color_mode === 'dark'
|
||||||
|
? 'dark'
|
||||||
|
: force_color_mode === 'light'
|
||||||
|
? 'light'
|
||||||
|
: null;
|
||||||
|
|
||||||
// Get current watermark settings hash for change detection
|
// Get current watermark settings hash for change detection
|
||||||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||||||
|
|
||||||
@@ -243,7 +251,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
|||||||
logo_display_header,
|
logo_display_header,
|
||||||
logo_display_hero,
|
logo_display_hero,
|
||||||
logo_display_mode,
|
logo_display_mode,
|
||||||
hide_powered_by
|
hide_powered_by,
|
||||||
|
force_color_mode: normalizedForceColorMode
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle favicon deletion if empty string or null is provided
|
// Handle favicon deletion if empty string or null is provided
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ router.get('/', async (req, res) => {
|
|||||||
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
|
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
|
||||||
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
|
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
|
||||||
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
|
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
|
||||||
|
// Force a specific color mode site-wide. When set, the user toggle
|
||||||
|
// is hidden and the value overrides per-theme/system preference.
|
||||||
|
// Allowed values: 'dark' | 'light' | null (null = no force).
|
||||||
|
branding_force_color_mode: settingsObject.branding_force_color_mode === 'dark'
|
||||||
|
? 'dark'
|
||||||
|
: settingsObject.branding_force_color_mode === 'light'
|
||||||
|
? 'light'
|
||||||
|
: null,
|
||||||
theme_config: settingsObject.theme_config || null,
|
theme_config: settingsObject.theme_config || null,
|
||||||
default_language: settingsObject.general_default_language || 'en',
|
default_language: settingsObject.general_default_language || 'en',
|
||||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ childr
|
|||||||
|
|
||||||
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
||||||
themeAppliedRef.current = true;
|
themeAppliedRef.current = true;
|
||||||
setTheme(settingsData.theme_config);
|
// Honor instance-wide force color mode: when set, override the
|
||||||
|
// theme's own colorMode so legacy themes can't render light against
|
||||||
|
// a force-dark instance (or vice-versa).
|
||||||
|
const forced = settingsData.branding_force_color_mode;
|
||||||
|
const themeWithForce = forced
|
||||||
|
? { ...settingsData.theme_config, colorMode: forced }
|
||||||
|
: settingsData.theme_config;
|
||||||
|
setTheme(themeWithForce);
|
||||||
}
|
}
|
||||||
}, [settingsData, setTheme]);
|
}, [settingsData, setTheme]);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface AdminHeaderProps {
|
|||||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAdminAuth();
|
const { user, logout } = useAdminAuth();
|
||||||
const { isDark, toggle: toggleDarkMode } = useAdminDarkMode();
|
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const { formatTimeAgo } = useLocalizedTimeAgo();
|
const { formatTimeAgo } = useLocalizedTimeAgo();
|
||||||
@@ -116,14 +116,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
|
||||||
{/* Dark Mode Toggle */}
|
{/* Dark Mode Toggle — hidden entirely when an admin has locked
|
||||||
<button
|
the instance to a specific mode via Branding > Force color mode. */}
|
||||||
onClick={toggleDarkMode}
|
{!forcedMode && (
|
||||||
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
<button
|
||||||
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
onClick={toggleDarkMode}
|
||||||
>
|
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||||
</button>
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div className="relative" ref={notificationRef}>
|
<div className="relative" ref={notificationRef}>
|
||||||
|
|||||||
@@ -341,6 +341,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
themeToApply = settingsData.theme_config;
|
themeToApply = settingsData.theme_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Honor instance-wide force color mode (Branding > Force color mode).
|
||||||
|
// The branding-level lock wins over per-event themes so a force-dark
|
||||||
|
// instance never accidentally renders a light gallery (and vice-versa).
|
||||||
|
if (themeToApply && settingsData.branding_force_color_mode) {
|
||||||
|
themeToApply = { ...themeToApply, colorMode: settingsData.branding_force_color_mode };
|
||||||
|
}
|
||||||
|
|
||||||
// Apply theme with a small delay to ensure it overrides any global theme
|
// Apply theme with a small delay to ensure it overrides any global theme
|
||||||
if (themeToApply) {
|
if (themeToApply) {
|
||||||
// Use setTimeout to ensure this runs after any global theme application
|
// Use setTimeout to ensure this runs after any global theme application
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
|
|
||||||
type DarkModePreference = 'light' | 'dark' | 'system';
|
type DarkModePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
interface AdminDarkModeContextType {
|
interface AdminDarkModeContextType {
|
||||||
preference: DarkModePreference;
|
preference: DarkModePreference;
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
|
/**
|
||||||
|
* When the admin has set `branding_force_color_mode`, the toggle is locked
|
||||||
|
* to that value. Consumers (AdminHeader) hide their toggle when this is
|
||||||
|
* truthy — UI parity with the user's "disable lightmode option page wide"
|
||||||
|
* request from discussion #397.
|
||||||
|
*/
|
||||||
|
forcedMode: 'dark' | 'light' | null;
|
||||||
setPreference: (pref: DarkModePreference) => void;
|
setPreference: (pref: DarkModePreference) => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
@@ -24,12 +32,25 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoginPage = location.pathname === '/admin/login';
|
const isLoginPage = location.pathname === '/admin/login';
|
||||||
|
|
||||||
|
// Instance-wide force mode (read from branding settings). When set, this
|
||||||
|
// wins over user preference and system preference. Refetches every 30s so
|
||||||
|
// toggling it in the Branding tab propagates to other open tabs without
|
||||||
|
// needing a full reload.
|
||||||
|
const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 });
|
||||||
|
const forcedMode: 'dark' | 'light' | null = publicSettings?.branding_force_color_mode === 'dark'
|
||||||
|
? 'dark'
|
||||||
|
: publicSettings?.branding_force_color_mode === 'light'
|
||||||
|
? 'light'
|
||||||
|
: null;
|
||||||
|
|
||||||
const [preference, setPreferenceState] = useState<DarkModePreference>(() => {
|
const [preference, setPreferenceState] = useState<DarkModePreference>(() => {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
|
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
|
||||||
return 'light';
|
return 'light';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The effective dark state: if a force mode is set, that wins; otherwise
|
||||||
|
// we resolve from the user's preference (light / dark / system).
|
||||||
const [isDark, setIsDark] = useState(() => resolveIsDark(preference));
|
const [isDark, setIsDark] = useState(() => resolveIsDark(preference));
|
||||||
|
|
||||||
const applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
|
const applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
|
||||||
@@ -42,26 +63,37 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setPreference = useCallback((pref: DarkModePreference) => {
|
const setPreference = useCallback((pref: DarkModePreference) => {
|
||||||
|
// If an admin has locked the instance to a specific mode, the user
|
||||||
|
// toggle is a no-op — silently ignore so we don't desync the UI.
|
||||||
|
if (forcedMode) return;
|
||||||
setPreferenceState(pref);
|
setPreferenceState(pref);
|
||||||
localStorage.setItem(STORAGE_KEY, pref);
|
localStorage.setItem(STORAGE_KEY, pref);
|
||||||
const dark = resolveIsDark(pref);
|
const dark = resolveIsDark(pref);
|
||||||
setIsDark(dark);
|
setIsDark(dark);
|
||||||
// Don't apply dark on login page
|
// Don't apply dark on login page
|
||||||
applyDarkClass(dark, isLoginPage);
|
applyDarkClass(dark, isLoginPage);
|
||||||
}, [applyDarkClass, isLoginPage]);
|
}, [applyDarkClass, isLoginPage, forcedMode]);
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
|
if (forcedMode) return;
|
||||||
setPreference(isDark ? 'light' : 'dark');
|
setPreference(isDark ? 'light' : 'dark');
|
||||||
}, [isDark, setPreference]);
|
}, [isDark, setPreference, forcedMode]);
|
||||||
|
|
||||||
// Apply on mount and when route changes - skip dark mode on login page
|
// Apply on mount and when route or force mode changes. The force-mode
|
||||||
|
// branch wins, then per-route login override, then user preference.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (forcedMode) {
|
||||||
|
const dark = forcedMode === 'dark';
|
||||||
|
setIsDark(dark);
|
||||||
|
applyDarkClass(dark, isLoginPage && forcedMode === 'light');
|
||||||
|
return;
|
||||||
|
}
|
||||||
applyDarkClass(isDark, isLoginPage);
|
applyDarkClass(isDark, isLoginPage);
|
||||||
}, [applyDarkClass, isDark, isLoginPage]);
|
}, [applyDarkClass, isDark, isLoginPage, forcedMode]);
|
||||||
|
|
||||||
// Listen for system changes when preference is 'system'
|
// Listen for system changes when preference is 'system' (and no force mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preference !== 'system') return;
|
if (forcedMode || preference !== 'system') return;
|
||||||
|
|
||||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
@@ -70,7 +102,7 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
};
|
};
|
||||||
mql.addEventListener('change', handler);
|
mql.addEventListener('change', handler);
|
||||||
return () => mql.removeEventListener('change', handler);
|
return () => mql.removeEventListener('change', handler);
|
||||||
}, [preference, applyDarkClass]);
|
}, [preference, applyDarkClass, forcedMode]);
|
||||||
|
|
||||||
// Strip dark class when unmounting (navigating away from admin)
|
// Strip dark class when unmounting (navigating away from admin)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,7 +111,10 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(() => ({ preference, isDark, setPreference, toggle }), [preference, isDark, setPreference, toggle]);
|
const value = useMemo(
|
||||||
|
() => ({ preference, isDark, forcedMode, setPreference, toggle }),
|
||||||
|
[preference, isDark, forcedMode, setPreference, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminDarkModeContext.Provider value={value}>
|
<AdminDarkModeContext.Provider value={value}>
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Honor instance-wide force color mode (Branding > Force color mode).
|
||||||
|
// When set, override per-event/per-theme colorMode so no gallery can
|
||||||
|
// render light against a force-dark instance.
|
||||||
|
if (themeToApply && settingsData.branding_force_color_mode) {
|
||||||
|
themeToApply = { ...themeToApply, colorMode: settingsData.branding_force_color_mode };
|
||||||
|
}
|
||||||
|
|
||||||
// Apply theme
|
// Apply theme
|
||||||
if (themeToApply) {
|
if (themeToApply) {
|
||||||
setTheme(themeToApply);
|
setTheme(themeToApply);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
logo_display_hero: true,
|
logo_display_hero: true,
|
||||||
logo_display_mode: 'logo_and_text',
|
logo_display_mode: 'logo_and_text',
|
||||||
hide_powered_by: false,
|
hide_powered_by: false,
|
||||||
|
force_color_mode: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||||
@@ -564,6 +565,47 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Force color mode: instance-wide lock for dark or light theme.
|
||||||
|
* When set, the user-facing dark/light toggle is hidden in the
|
||||||
|
* admin header and any per-event/per-theme colorMode is overridden.
|
||||||
|
* Three states: none (user choice), dark, light. Mutually exclusive.
|
||||||
|
*/}
|
||||||
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||||
|
{t('branding.forceColorMode', 'Force color mode')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-3">
|
||||||
|
{t(
|
||||||
|
'branding.forceColorModeHelp',
|
||||||
|
'Lock the entire admin and public site to dark or light. The user-facing toggle is hidden when active.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([
|
||||||
|
{ value: null, label: t('branding.forceColorModeNone', 'No force (user choice)') },
|
||||||
|
{ value: 'dark', label: t('branding.forceColorModeDark', 'Force dark') },
|
||||||
|
{ value: 'light', label: t('branding.forceColorModeLight', 'Force light') },
|
||||||
|
] as const).map(({ value, label }) => {
|
||||||
|
const active = (brandingSettings.force_color_mode ?? null) === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={String(value)}
|
||||||
|
onClick={() => handleBrandingChange('force_color_mode', value)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
|
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export interface PublicSettings {
|
|||||||
branding_logo_display_header?: boolean;
|
branding_logo_display_header?: boolean;
|
||||||
branding_logo_display_hero?: boolean;
|
branding_logo_display_hero?: boolean;
|
||||||
branding_hide_powered_by?: boolean;
|
branding_hide_powered_by?: boolean;
|
||||||
|
/**
|
||||||
|
* Force the entire site into a specific color mode (instance-wide).
|
||||||
|
* 'dark' or 'light' override user/system preference; null = no force.
|
||||||
|
* AdminDarkModeContext + ThemeContext both honor this.
|
||||||
|
*/
|
||||||
|
branding_force_color_mode?: 'dark' | 'light' | null;
|
||||||
theme_config: any;
|
theme_config: any;
|
||||||
default_language: string;
|
default_language: string;
|
||||||
enable_analytics: boolean;
|
enable_analytics: boolean;
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export interface BrandingSettings {
|
|||||||
logo_display_hero?: boolean;
|
logo_display_hero?: boolean;
|
||||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||||
hide_powered_by?: boolean;
|
hide_powered_by?: boolean;
|
||||||
|
/**
|
||||||
|
* Force the entire admin and public site into a specific color mode.
|
||||||
|
* When set, the user-facing dark/light toggle is hidden and any per-theme
|
||||||
|
* `colorMode` override is ignored. `null` means no force (default behavior).
|
||||||
|
*/
|
||||||
|
force_color_mode?: 'dark' | 'light' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeSettings {
|
export interface ThemeSettings {
|
||||||
@@ -288,7 +294,12 @@ export const settingsService = {
|
|||||||
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
|
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
|
||||||
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
|
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
|
||||||
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text',
|
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text',
|
||||||
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false)
|
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false),
|
||||||
|
force_color_mode: rawSettings.branding_force_color_mode === 'dark'
|
||||||
|
? 'dark'
|
||||||
|
: rawSettings.branding_force_color_mode === 'light'
|
||||||
|
? 'light'
|
||||||
|
: null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -215,3 +215,58 @@ test.describe('Gallery Theme Color Mode', () => {
|
|||||||
await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Force color mode (instance-wide lock)', () => {
|
||||||
|
// The force color mode setting is exposed in Branding > Force color mode.
|
||||||
|
// When set, the user-facing dark/light toggle in the admin header should
|
||||||
|
// disappear entirely. We verify both presence of the controls and that
|
||||||
|
// toggling them hides the header chip.
|
||||||
|
test('force-mode controls are present in Branding settings', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Force color mode validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
await page.goto('/admin/branding');
|
||||||
|
await expect(page.getByRole('heading', { name: /Force color mode|Farbmodus erzwingen/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// The three states must be selectable as buttons.
|
||||||
|
await expect(page.getByRole('button', { name: /No force|Kein/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /Force light|Hell erzwingen/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting force-dark hides the header dark mode toggle on next reload', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Force color mode validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Confirm the toggle is initially visible (no force mode set).
|
||||||
|
let toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
|
||||||
|
// Set force-dark via Branding page.
|
||||||
|
await page.goto('/admin/branding');
|
||||||
|
await page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i }).click();
|
||||||
|
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
|
||||||
|
|
||||||
|
// Reload so the public-settings refetch picks up the new value.
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||||
|
await expect(toggle).toHaveCount(0);
|
||||||
|
|
||||||
|
// The .dark class should be applied to <html>.
|
||||||
|
const html = page.locator('html');
|
||||||
|
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
|
||||||
|
|
||||||
|
// Restore: clear the force mode so subsequent test runs aren't affected.
|
||||||
|
await page.goto('/admin/branding');
|
||||||
|
await page.getByRole('button', { name: /No force|Kein/i }).click();
|
||||||
|
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user