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:
@@ -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 don’t 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 && (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 don’t 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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user