fix(branding): selected-state accent colors, force-mode actually flips galleries, compact color picker layout

This commit is contained in:
Luca
2026-05-05 16:48:47 +02:00
parent 67d7d8d3fa
commit 5b410ed9f8
10 changed files with 275 additions and 121 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { migrateThemeConfig } from '../themeMigration';
import { migrateThemeConfig, applyForceColorMode } from '../themeMigration';
import type { ThemeConfig } from '../../types/theme.types';
describe('migrateThemeConfig — 8-token palette fill', () => {
@@ -89,3 +89,71 @@ describe('migrateThemeConfig — 8-token palette fill', () => {
expect(migrated.accentDarkColor).toBe('#5C8762');
});
});
describe('applyForceColorMode', () => {
const lightTheme: ThemeConfig = {
primaryColor: '#5C8762',
accentColor: '#22c55e',
accentDarkColor: '#5C8762',
backgroundColor: '#fafafa',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373',
colorMode: 'light',
};
const lbmDark: ThemeConfig = {
primaryColor: '#014E4E',
accentColor: '#017C7C',
accentDarkColor: '#014E4E',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#4A6060',
colorMode: 'dark',
};
it('returns the theme unchanged when no force mode is set', () => {
expect(applyForceColorMode(lightTheme, null)).toEqual(lightTheme);
expect(applyForceColorMode(lightTheme, undefined)).toEqual(lightTheme);
});
it('only pins colorMode when the theme already matches the forced mode', () => {
const result = applyForceColorMode(lbmDark, 'dark');
expect(result.colorMode).toBe('dark');
// LBM surfaces preserved.
expect(result.backgroundColor).toBe('#0D0D0D');
expect(result.surfaceColor).toBe('#111414');
expect(result.accentColor).toBe('#017C7C');
});
it('swaps surface tokens when forcing a light theme to dark', () => {
const result = applyForceColorMode(lightTheme, 'dark');
expect(result.colorMode).toBe('dark');
// Surfaces flipped to dark defaults.
expect(result.backgroundColor).toBe('#0f0f0f');
expect(result.surfaceColor).toBe('#1a1a1a');
expect(result.elevatedColor).toBe('#242424');
expect(result.surfaceBorderColor).toBe('#2e2e2e');
expect(result.textColor).toBe('#e5e5e5');
expect(result.mutedTextColor).toBe('#a3a3a3');
// Brand identity preserved.
expect(result.accentColor).toBe('#22c55e');
expect(result.accentDarkColor).toBe('#5C8762');
});
it('swaps surface tokens when forcing a dark theme to light', () => {
const result = applyForceColorMode(lbmDark, 'light');
expect(result.colorMode).toBe('light');
expect(result.backgroundColor).toBe('#fafafa');
expect(result.surfaceColor).toBe('#ffffff');
expect(result.textColor).toBe('#171717');
// LBM accent colours survive the flip.
expect(result.accentColor).toBe('#017C7C');
expect(result.accentDarkColor).toBe('#014E4E');
});
});
+65
View File
@@ -1,5 +1,70 @@
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
/**
* Surface defaults for the two color modes — the same values applyTheme()
* falls back to when a theme has no explicit surface/elevated/border/text
* tokens. Exposed here so the force-color-mode helper can swap them
* wholesale when an admin locks the instance to a mode that the active
* theme doesn't natively support.
*/
const DARK_SURFACE_DEFAULTS = {
backgroundColor: '#0f0f0f',
surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e',
textColor: '#e5e5e5',
mutedTextColor: '#a3a3a3',
};
const LIGHT_SURFACE_DEFAULTS = {
backgroundColor: '#fafafa',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373',
};
/**
* 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.
*
* 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,
forced: 'dark' | 'light' | null | undefined
): ThemeConfig {
if (!forced) return theme;
const themeMode = theme.colorMode === 'auto'
? (typeof window !== 'undefined'
&& window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light')
: (theme.colorMode || 'light');
if (themeMode === forced) {
return { ...theme, colorMode: forced };
}
const surfaces = forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS;
return {
...theme,
...surfaces,
colorMode: forced,
};
}
/**
* Fills in any missing 8-token CI palette fields on legacy themes that were
* saved before the palette expanded from 4 → 8 explicit tokens.