From 4749e222dc41695a2494a3b740952745bb854d4e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:08:14 +0200 Subject: [PATCH] feat(branding): force color mode = standard look; hide overridden theme controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../admin/ThemeCustomizerEnhanced.tsx | 19 ++++++- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + .../utils/__tests__/themeMigration.test.ts | 32 +++++++++++- frontend/src/utils/themeMigration.ts | 50 +++++++++++++------ 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index bba72794..eb5c1fc5 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -184,6 +184,12 @@ export const ThemeCustomizerEnhanced: React.FC = ( 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(value); const [selectedPreset, setSelectedPreset] = useState(presetName); const [customCss, setCustomCss] = useState(value.customCss || ''); @@ -915,6 +921,12 @@ export const ThemeCustomizerEnhanced: React.FC = ( {/* Color Mode Selector */}
+ {forcedColorActive && ( +
+ {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.')} +
+ )} + {!forcedColorActive && (<> @@ -969,6 +981,7 @@ export const ThemeCustomizerEnhanced: React.FC = (

{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}

+ )} {/* * Force color mode (instance-wide). Lives next to the per-theme @@ -1033,6 +1046,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( * cleanly side-by-side. */}
+ {!forcedColorActive && (<> {/* Surfaces */}

@@ -1128,6 +1142,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( ))}

+ )} {/* Accent */}
@@ -1181,7 +1196,8 @@ export const ThemeCustomizerEnhanced: React.FC = (
- {/* Typography & Style */} + {/* Typography & Style — hidden while a forced color mode is active */} + {!forcedColorActive && (

@@ -1312,6 +1328,7 @@ export const ThemeCustomizerEnhanced: React.FC = (

+ )} {/* CSS Template Selector - only show if templates are provided */} {cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 49b65a75..59edff14 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5a8cfdee..a0707c4d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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.", diff --git a/frontend/src/utils/__tests__/themeMigration.test.ts b/frontend/src/utils/__tests__/themeMigration.test.ts index f98d3e4f..a9a6ed51 100644 --- a/frontend/src/utils/__tests__/themeMigration.test.ts +++ b/frontend/src/utils/__tests__/themeMigration.test.ts @@ -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'); diff --git a/frontend/src/utils/themeMigration.ts b/frontend/src/utils/themeMigration.ts index 37f9b424..77e569ba 100644 --- a/frontend/src/utils/themeMigration.ts +++ b/frontend/src/utils/themeMigration.ts @@ -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 = { + 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, }; }