From 5b410ed9f87daad8e96345a86897f2a9e9419802 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 5 May 2026 16:48:47 +0200 Subject: [PATCH] fix(branding): selected-state accent colors, force-mode actually flips galleries, compact color picker layout --- .../src/components/GlobalThemeProvider.tsx | 13 +- .../components/admin/EmailTemplateEditor.tsx | 2 +- .../src/components/admin/PhotoFilterPanel.tsx | 4 +- .../admin/ThemeCustomizerEnhanced.tsx | 216 ++++++++++-------- .../src/components/gallery/GalleryView.tsx | 10 +- frontend/src/pages/GalleryPage.tsx | 10 +- frontend/src/pages/admin/BrandingPage.tsx | 4 +- .../src/pages/admin/WebhookDeliveriesPage.tsx | 2 +- .../utils/__tests__/themeMigration.test.ts | 70 +++++- frontend/src/utils/themeMigration.ts | 65 ++++++ 10 files changed, 275 insertions(+), 121 deletions(-) diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx index a5ab63d5..ef84968f 100644 --- a/frontend/src/components/GlobalThemeProvider.tsx +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef } from 'react'; import { useTheme } from '../contexts/ThemeContext'; import { usePublicSettings } from '../hooks/usePublicSettings'; +import { applyForceColorMode } from '../utils/themeMigration'; interface GlobalThemeProviderProps { children: React.ReactNode; @@ -18,14 +19,10 @@ export const GlobalThemeProvider: React.FC = ({ childr if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { themeAppliedRef.current = true; - // 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); + // Honor instance-wide force color mode: when set, applyForceColorMode + // also swaps the surface/text tokens so the page actually flips + // visually (not just the colorMode flag — see #397 follow-up). + setTheme(applyForceColorMode(settingsData.theme_config, settingsData.branding_force_color_mode)); } }, [settingsData, setTheme]); diff --git a/frontend/src/components/admin/EmailTemplateEditor.tsx b/frontend/src/components/admin/EmailTemplateEditor.tsx index 56c3686c..fb2a2089 100644 --- a/frontend/src/components/admin/EmailTemplateEditor.tsx +++ b/frontend/src/components/admin/EmailTemplateEditor.tsx @@ -360,7 +360,7 @@ export const EmailTemplateEditor: React.FC = ({ /> ))} @@ -698,7 +737,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( onClick={() => handleChange('headerStyle', style)} className={`relative p-4 rounded-lg border-2 transition-all ${ (localTheme.headerStyle || 'standard') === style - ? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' + ? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600' }`} > @@ -714,7 +753,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( {(localTheme.headerStyle || 'standard') === style && ( - + )} ))} @@ -737,7 +776,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( onClick={() => handleChange('heroDividerStyle', divider)} className={`relative p-3 rounded-lg border-2 transition-all ${ (localTheme.heroDividerStyle || 'wave') === divider - ? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' + ? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600' }`} > @@ -751,7 +790,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( {(localTheme.heroDividerStyle || 'wave') === divider && ( - + )} ))} @@ -777,7 +816,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( onClick={() => handleChange('controlsStyle', 'classic')} className={`relative p-4 rounded-lg border-2 transition-all ${ (localTheme.controlsStyle || 'classic') === 'classic' - ? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' + ? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600' }`} > @@ -793,7 +832,7 @@ export const ThemeCustomizerEnhanced: React.FC = ( {(localTheme.controlsStyle || 'classic') === 'classic' && ( - + )} @@ -951,18 +990,29 @@ export const ThemeCustomizerEnhanced: React.FC = ( * coverage only (per user language profile); other locales will * show the fallback until reviewed by a native speaker. */} + {/* + * 8-token CI palette pickers, grouped by role. Each picker label + * carries an Info icon whose `title` attribute renders the + * descriptive help text on hover (or long-press on touch). Keeping + * the help out of the static layout means every picker row is the + * same height so the four Surfaces and the two Accent rows align + * cleanly side-by-side. + */}
{/* Surfaces */}
-

+

{t('branding.colorGroupSurfaces', 'Surfaces')} + + +

-

- {t( - 'branding.colorGroupSurfacesHelp', - 'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.' - )} -

{[ { @@ -990,41 +1040,32 @@ export const ThemeCustomizerEnhanced: React.FC = ( fallback: '#e5e5e5', }, ].map(({ key, label, help, fallback }) => ( -
- -

{help}

-
- )[key] || fallback} - onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} - className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" - /> - )[key] || fallback} - onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} - placeholder={fallback} - className="flex-1" - /> -
-
+ )[key] || fallback} + fallback={fallback} + onChange={(v) => handleChange(key as keyof ThemeConfig, v)} + /> ))}
{/* Text */}
-

+

{t('branding.colorGroupText', 'Text')} + + +

-

- {t( - 'branding.colorGroupTextHelp', - 'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.' - )} -

{[ { @@ -1040,41 +1081,32 @@ export const ThemeCustomizerEnhanced: React.FC = ( fallback: '#737373', }, ].map(({ key, label, help, fallback }) => ( -
- -

{help}

-
- )[key] || fallback} - onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} - className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" - /> - )[key] || fallback} - onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} - placeholder={fallback} - className="flex-1" - /> -
-
+ )[key] || fallback} + fallback={fallback} + onChange={(v) => handleChange(key as keyof ThemeConfig, v)} + /> ))}
{/* Accent */}
-

+

{t('branding.colorGroupAccent', 'Accent')} + + +

-

- {t( - 'branding.colorGroupAccentHelp', - 'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.' - )} -

{[ { @@ -1096,26 +1128,14 @@ export const ThemeCustomizerEnhanced: React.FC = ( fallback: '#5C8762', }, ].map(({ key, label, help, fallback }) => ( -
- -

{help}

-
- )[key] || fallback} - onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} - className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" - /> - )[key] || fallback} - onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} - placeholder={fallback} - className="flex-1" - /> -
-
+ )[key] || fallback} + fallback={fallback} + onChange={(v) => handleChange(key as keyof ThemeConfig, v)} + /> ))}
{/* primaryColor is kept in sync with accentDarkColor inside @@ -1273,14 +1293,14 @@ export const ThemeCustomizerEnhanced: React.FC = ( onClick={() => onCssTemplateChange(null)} className={`relative p-4 rounded-lg border-2 transition-all text-left ${ !cssTemplateId - ? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' + ? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600' }`} >
{t('branding.noTemplate', 'No Template')} {!cssTemplateId && ( - + )}
@@ -1295,14 +1315,14 @@ export const ThemeCustomizerEnhanced: React.FC = ( onClick={() => onCssTemplateChange(template.id)} className={`relative p-4 rounded-lg border-2 transition-all text-left ${ cssTemplateId === template.id - ? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' + ? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600' }`} >
{template.name} {cssTemplateId === template.id && ( - + )}
@@ -1326,7 +1346,7 @@ export const ThemeCustomizerEnhanced: React.FC = (