feat(branding): force color mode = standard look; hide overridden theme controls

When a force light/dark lock is active it now means "use the clean standard
look": applyForceColorMode also resets typography & style (fonts, size, corner
radius, shadow, background pattern) to defaults — on top of the surface/text
palette it already swapped — so those settings genuinely don't apply while the
lock is on. Accent brand colours and the structural cards (header/controls/
gallery layout/hero divider) are preserved. Override-only: the saved theme keeps
the admin's custom values, so turning the lock off restores them.

In the theme customizer, when a force mode is active, hide the now-dead controls
to avoid confusion — the per-theme Color Mode picker, the Surfaces + Text colour
pickers, and the whole Typography & Style card — and show an explanatory note.
The Force picker itself and the Accent pickers stay visible. i18n en/de.

This pairs with the admin dark-mode fix: admin surfaces follow the `.dark` class
which AdminDarkModeContext drives from the force lock, so force is respected
end-to-end.
This commit is contained in:
Luca
2026-06-16 15:08:14 +02:00
parent d3266a0d1c
commit 4749e222dc
5 changed files with 86 additions and 17 deletions
@@ -184,6 +184,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
slotBeforeCustomCss
}) => {
const { t } = useTranslation();
// A forced color mode locks the instance to the standard look — the per-theme
// Color Mode, surface/text palette, and Typography & Style are overridden at
// render (applyForceColorMode). Hide those now-dead controls to avoid
// configuring settings that don't apply; accent colours + the structural
// cards (header/controls/layout) stay editable.
const forcedColorActive = (forceColorMode ?? null) !== null;
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
@@ -915,6 +921,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Color Mode Selector */}
<div className="mb-6">
{forcedColorActive && (
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
{t('branding.forcedStandardLookNote', 'A forced color mode is active — the gallery uses the standard light/dark look. Color mode, surface & text colors, and Typography & Style are hidden here because they dont apply while the lock is on. Accent colors still apply. Set Force below to “No force” to customize them.')}
</div>
)}
{!forcedColorActive && (<>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.colorMode', 'Color Mode')}
</label>
@@ -969,6 +981,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
</p>
</>)}
{/*
* Force color mode (instance-wide). Lives next to the per-theme
@@ -1033,6 +1046,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
* cleanly side-by-side.
*/}
<div className="space-y-6">
{!forcedColorActive && (<>
{/* Surfaces */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
@@ -1128,6 +1142,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
))}
</div>
</div>
</>)}
{/* Accent */}
<div>
@@ -1181,7 +1196,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
</Card>
{/* Typography & Style */}
{/* Typography & Style — hidden while a forced color mode is active */}
{!forcedColorActive && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Type className="w-5 h-5" />
@@ -1312,6 +1328,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
</div>
</Card>
)}
{/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
+1
View File
@@ -1956,6 +1956,7 @@
"forceColorModeNone": "Kein Zwang (Benutzerauswahl)",
"forceColorModeDark": "Dunkelmodus erzwingen",
"forceColorModeLight": "Hellmodus erzwingen",
"forcedStandardLookNote": "Ein erzwungener Farbmodus ist aktiv — die Galerie verwendet das Standard-Hell-/Dunkel-Design. Farbmodus, Flächen- & Textfarben sowie Typografie & Stil werden hier ausgeblendet, da sie bei aktiver Sperre nicht greifen. Akzentfarben gelten weiterhin. Setze „Erzwingen“ unten auf „Keine Erzwingung“, um sie anzupassen.",
"colorGroupSurfaces": "Oberflächen",
"colorGroupSurfacesHelp": "Die neutralen Ebenen hinter Ihrem Inhalt. Hintergrund liegt am weitesten zurück; Oberfläche und Erhöht stapeln sich darüber.",
"backgroundColorHelp": "Die Seite selbst — Hintergrundfarbe jeder Galerie, Admin-Seite und CMS-Seite.",
+1
View File
@@ -1545,6 +1545,7 @@
"forceColorModeNone": "No force (user choice)",
"forceColorModeDark": "Force dark",
"forceColorModeLight": "Force light",
"forcedStandardLookNote": "A forced color mode is active — the gallery uses the standard light/dark look. Color mode, surface & text colors, and Typography & Style are hidden here because they dont apply while the lock is on. Accent colors still apply. Set Force below to “No force” to customize them.",
"colorGroupSurfaces": "Surfaces",
"colorGroupSurfacesHelp": "The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.",
"backgroundColorHelp": "The page itself — body background of every gallery, admin page and CMS page.",
@@ -122,15 +122,43 @@ describe('applyForceColorMode', () => {
expect(applyForceColorMode(lightTheme, undefined)).toEqual(lightTheme);
});
it('only pins colorMode when the theme already matches the forced mode', () => {
it('keeps the custom surface palette when the theme already matches the forced mode', () => {
const result = applyForceColorMode(customDark, 'dark');
expect(result.colorMode).toBe('dark');
// Custom surfaces preserved.
// Custom surfaces preserved (mode already matches → no palette swap).
expect(result.backgroundColor).toBe('#0D0D0D');
expect(result.surfaceColor).toBe('#111414');
expect(result.accentColor).toBe('#017C7C');
});
it('resets typography & style to the standard look while preserving accent', () => {
const themed: ThemeConfig = {
...customDark,
fontFamily: 'Playfair Display, serif',
headingFontFamily: 'Playfair Display, serif',
fontSize: 'large',
borderRadius: 'lg',
shadowStyle: 'dramatic',
};
// Force-light differs from the theme's dark mode → full standard look.
const result = applyForceColorMode(themed, 'light');
expect(result.colorMode).toBe('light');
// Typography/style fall back to the standard look.
expect(result.fontFamily).toBeUndefined();
expect(result.headingFontFamily).toBeUndefined();
expect(result.fontSize).toBe('normal');
expect(result.borderRadius).toBe('md');
expect(result.shadowStyle).toBe('subtle');
// Accent brand colours survive.
expect(result.accentColor).toBe('#017C7C');
expect(result.accentDarkColor).toBe('#014E4E');
// Same standard reset even when the theme already matches the forced mode.
const matching = applyForceColorMode(themed, 'dark');
expect(matching.fontFamily).toBeUndefined();
expect(matching.borderRadius).toBe('md');
expect(matching.accentColor).toBe('#017C7C');
});
it('swaps surface tokens when forcing a light theme to dark', () => {
const result = applyForceColorMode(lightTheme, 'dark');
expect(result.colorMode).toBe('dark');
+36 -14
View File
@@ -25,20 +25,40 @@ const LIGHT_SURFACE_DEFAULTS = {
mutedTextColor: '#737373',
};
/**
* Standard typography + style applied when an instance-wide force color mode
* lock is active. A force lock means "use the clean standard look" the
* per-theme colour mode, surface/text palette, AND typography/style are
* overridden so the customizer's now-hidden colour/typography/style controls
* genuinely don't do anything while the lock is on (limits confusion). Accent
* brand colours and the structural choices that live in their own cards
* (header style, controls style, gallery layout, hero divider) are preserved.
*
* Override-only: the SAVED theme keeps the admin's custom values turning the
* lock off makes them apply again. `undefined` lets applyTheme() fall back to
* its built-in default (e.g. the system/Inter font) rather than a stale value.
*/
const FORCED_STANDARD_TYPOGRAPHY_STYLE: Partial<ThemeConfig> = {
fontFamily: undefined,
headingFontFamily: undefined,
fontSize: 'normal',
borderRadius: 'md',
shadowStyle: 'subtle',
backgroundPattern: undefined,
};
/**
* Apply an instance-wide force color mode lock to a theme config.
*
* If the theme already matches the locked mode (or no lock is set), only
* the colorMode flag is pinned. If the theme is locked to a mode it
* doesn't natively support (e.g. an admin set Force Dark but is opening
* a light gallery preset), the surface/text tokens are replaced with the
* matching mode's defaults — the user's accent/accentDark colours are
* preserved so brand identity survives the flip.
* No lock returned unchanged. With a lock, pin colorMode, swap the
* surface/text palette to the locked mode's defaults (only when the theme
* doesn't already match the mode), and reset typography/style to the standard
* look. The user's accent/accentDark colours are always preserved so brand
* identity survives the flip.
*
* Centralised here so GlobalThemeProvider, GalleryPage and GalleryView
* stay in sync (#397 follow-up: galleries did not visibly flip when
* Force Dark/Light was toggled because only colorMode was overridden,
* leaving the original light/dark surface colours in place).
* Centralised here so every consumer stays in sync (#397 follow-up: galleries
* did not visibly flip when Force Dark/Light was toggled because only
* colorMode was overridden, leaving the original surface colours in place).
*/
export function applyForceColorMode(
theme: ThemeConfig,
@@ -53,14 +73,16 @@ export function applyForceColorMode(
: 'light')
: (theme.colorMode || 'light');
if (themeMode === forced) {
return { ...theme, colorMode: forced };
}
// Swap the surface palette only when the theme doesn't natively match the
// locked mode; the standard typography/style reset applies either way.
const surfaces = themeMode === forced
? {}
: (forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS);
const surfaces = forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS;
return {
...theme,
...surfaces,
...FORCED_STANDARD_TYPOGRAPHY_STYLE,
colorMode: forced,
};
}