fix(branding): force lock = light/dark only; Branding stays the full preset, galleries hide color+mode

Reworks the previous force-mode UX per the intended model:
- Branding page IS the global preset — keep presets, colors, fonts and style
  fully visible. The only change under a force lock: hide the redundant
  per-theme Color Mode picker (light/dark is the Force control), with a hint.
- Force only locks light/dark again: reverted applyForceColorMode to swap the
  surface palette only — it no longer resets typography/style, so the branding
  fonts/style always apply.
- Per-event GALLERY theme editors now receive the global force value and, when
  a lock is active, hide the colour pickers AND the light/dark picker (a gallery
  can't override the site-wide lock). Presets/fonts/layout stay. Force off →
  everything returns.

Wiring: CreateEventPage + EventDetailsPage pass
forceColorMode={publicSettings?.branding_force_color_mode} (value only, no Force
control). Branding keeps both the value and the onForceColorModeChange handler,
which is how the component tells the two contexts apart. i18n en/de.
This commit is contained in:
Luca
2026-06-16 15:54:59 +02:00
parent 1ac653ad1b
commit a7c19135bb
7 changed files with 37 additions and 84 deletions
@@ -184,12 +184,15 @@ 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.
// A force lock (instance-wide light/dark) overrides the per-theme color
// mode. On the Branding page (where the Force control lives —
// onForceColorModeChange is provided) we hide only the now-redundant
// per-theme Color Mode picker. In per-event gallery editors (no Force
// control) we ALSO hide the colour pickers, since a gallery can't override
// the site-wide lock. Presets, fonts and style always stay.
const forcedColorActive = (forceColorMode ?? null) !== null;
const isBrandingContext = !!onForceColorModeChange;
const hideGalleryColors = forcedColorActive && !isBrandingContext;
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
@@ -311,7 +314,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
return (
<div className="space-y-6">
{!forcedColorActive && (<>
{/* Preset Themes */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
@@ -896,7 +898,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
)}
</Card>
)}
</>)}
{/* Color Customization */}
<Card className="p-6">
@@ -924,8 +925,10 @@ 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, so the theme customization is hidden (it doesnt apply while the lock is on). Your accent brand colors still apply. Set Force to “No force” to customize the gallery theme again.')}
<div className="mb-3 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">
{isBrandingContext
? t('branding.forcedModeBrandingHint', 'Light/dark is locked site-wide by the Force control below — the per-theme mode picker is hidden because it would have no effect.')
: t('branding.forcedModeGalleryNote', 'A site-wide color lock is active, so this gallery follows the locked light/dark mode. Color and light/dark options are hidden here and cant be overridden per gallery.')}
</div>
)}
{!forcedColorActive && (<>
@@ -1047,8 +1050,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
* same height so the four Surfaces and the two Accent rows align
* cleanly side-by-side.
*/}
{!hideGalleryColors && (
<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">
@@ -1194,12 +1197,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* primaryColor is kept in sync with accentDarkColor inside
handleChange() — no dedicated picker. */}
</div>
</>)}
</div>
)}
</Card>
{/* Typography & Style — hidden while a forced color mode is active */}
{!forcedColorActive && (
{/* Typography & Style */}
<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" />
@@ -1330,9 +1332,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
</div>
</Card>
)}
{!forcedColorActive && (<>
{/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
<Card className="p-6">
@@ -1509,7 +1509,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{t('branding.customCSSHelp')}
</p>
</Card>
</>)}
{/* Actions */}
{!hideActions && (
+2 -1
View File
@@ -1956,7 +1956,8 @@
"forceColorModeNone": "Kein Zwang (Benutzerauswahl)",
"forceColorModeDark": "Dunkelmodus erzwingen",
"forceColorModeLight": "Hellmodus erzwingen",
"forcedStandardLookNote": "Ein erzwungener Farbmodus ist aktiv — die Galerie verwendet das Standard-Hell-/Dunkel-Design, daher ist die Theme-Anpassung ausgeblendet (sie greift bei aktiver Sperre nicht). Deine Akzent-Markenfarben gelten weiterhin. Setze „Erzwingen“ auf „Keine Erzwingung“, um das Galerie-Theme wieder anzupassen.",
"forcedModeBrandingHint": "Hell/Dunkel wird site-weit über die Erzwingen-Steuerung unten festgelegt — die themenspezifische Modusauswahl ist ausgeblendet, da sie keine Wirkung hätte.",
"forcedModeGalleryNote": "Eine site-weite Farbsperre ist aktiv, daher folgt diese Galerie dem gesperrten Hell-/Dunkel-Modus. Farb- und Hell/Dunkel-Optionen sind hier ausgeblendet und können nicht pro Galerie überschrieben werden.",
"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.",
+2 -1
View File
@@ -1545,7 +1545,8 @@
"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, so the theme customization is hidden (it doesnt apply while the lock is on). Your accent brand colors still apply. Set Force to “No force” to customize the gallery theme again.",
"forcedModeBrandingHint": "Light/dark is locked site-wide by the Force control below — the per-theme mode picker is hidden because it would have no effect.",
"forcedModeGalleryNote": "A site-wide color lock is active, so this gallery follows the locked light/dark mode. Color and light/dark options are hidden here and cant be overridden per gallery.",
"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.",
@@ -694,6 +694,7 @@ export const CreateEventPage: React.FC = () => {
onChange={handleThemeChange}
presetName={formData.theme_preset}
onPresetChange={handlePresetChange}
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
showGalleryLayouts={true}
hideActions={true}
onSyncFromBranding={() => {
@@ -2265,6 +2265,7 @@ export const EventDetailsPage: React.FC = () => {
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.themeAndStyle')}</h2>
<ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
onChange={(theme) => {
setCurrentTheme(theme);
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
@@ -122,43 +122,15 @@ describe('applyForceColorMode', () => {
expect(applyForceColorMode(lightTheme, undefined)).toEqual(lightTheme);
});
it('keeps the custom surface palette when the theme already matches the forced mode', () => {
it('only pins colorMode when the theme already matches the forced mode', () => {
const result = applyForceColorMode(customDark, 'dark');
expect(result.colorMode).toBe('dark');
// Custom surfaces preserved (mode already matches → no palette swap).
// Custom surfaces preserved.
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');
+14 -36
View File
@@ -25,40 +25,20 @@ 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.
*
* 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.
* 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.
*
* 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).
* 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).
*/
export function applyForceColorMode(
theme: ThemeConfig,
@@ -73,16 +53,14 @@ export function applyForceColorMode(
: 'light')
: (theme.colorMode || 'light');
// 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);
if (themeMode === forced) {
return { ...theme, colorMode: forced };
}
const surfaces = forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS;
return {
...theme,
...surfaces,
...FORCED_STANDARD_TYPOGRAPHY_STYLE,
colorMode: forced,
};
}