feat(theme): expand color settings to 8-token CI palette + alt button

This commit is contained in:
Luca
2026-05-05 16:04:43 +02:00
parent ee7de6f6a1
commit 114aab5777
11 changed files with 612 additions and 150 deletions
@@ -104,4 +104,59 @@ describe('publicSiteService', () => {
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
expect(payload.branding.colors.primary).toBe('#5C8762');
});
it('exposes the 8-token CI palette through branding.colors', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
// LBM CI palette (charcoal + teal).
primaryColor: '#014E4E',
accentColor: '#017C7C',
accentDarkColor: '#014E4E',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#4A6060'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
// Legacy 4 colors still mapped.
expect(payload.branding.colors.primary).toBe('#014E4E');
expect(payload.branding.colors.accent).toBe('#017C7C');
expect(payload.branding.colors.background).toBe('#0D0D0D');
expect(payload.branding.colors.text).toBe('#EBEBEB');
// 8-token CI palette additions.
expect(payload.branding.colors.accentDark).toBe('#014E4E');
expect(payload.branding.colors.surface).toBe('#111414');
expect(payload.branding.colors.elevated).toBe('#182222');
expect(payload.branding.colors.border).toBe('#1E2E2E');
expect(payload.branding.colors.mutedText).toBe('#4A6060');
});
it('falls back accentDark to legacy primaryColor when the new key is absent', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717'
// accentDarkColor intentionally omitted to simulate a legacy theme.
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.branding.colors.accentDark).toBe('#5C8762');
});
});
+16 -1
View File
@@ -87,11 +87,19 @@ async function fetchBrandingContext() {
supportEmail: null,
logoUrl: null,
footerText: null,
// 8-token CI palette mirrored from frontend ThemeConfig.
// primary/accent are kept as legacy aliases (primary == accent-dark);
// new tokens are surface, elevated, border, mutedText, accentDark.
colors: {
primary: '#16a34a',
accent: '#0f766e',
accentDark: '#16a34a',
background: '#f4fbf6',
text: '#0f172a'
surface: '#ffffff',
elevated: '#f5f5f5',
border: '#e5e5e5',
text: '#0f172a',
mutedText: '#737373'
}
};
@@ -117,10 +125,17 @@ async function fetchBrandingContext() {
try {
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
if (themeConfig && typeof themeConfig === 'object') {
// Legacy 4 colors
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
context.colors.accent = themeConfig.accentColor || context.colors.accent;
context.colors.background = themeConfig.backgroundColor || context.colors.background;
context.colors.text = themeConfig.textColor || context.colors.text;
// 8-token CI palette additions
context.colors.accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor || context.colors.accentDark;
context.colors.surface = themeConfig.surfaceColor || context.colors.surface;
context.colors.elevated = themeConfig.elevatedColor || context.colors.elevated;
context.colors.border = themeConfig.surfaceBorderColor || context.colors.border;
context.colors.mutedText = themeConfig.mutedTextColor || context.colors.mutedText;
}
} catch (error) {
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
@@ -158,7 +158,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue };
const updated: ThemeConfig = { ...localTheme, [key]: newValue };
// Legacy alias: keep primaryColor in lockstep with accentDarkColor so
// any consumer that still reads --color-primary or themeConfig.primaryColor
// doesn't drift after the 8-token migration.
if (key === 'accentDarkColor') {
updated.primaryColor = newValue;
}
setLocalTheme(updated);
// When any change is made, mark it as custom
@@ -265,18 +271,24 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
<div className="flex items-center gap-2 mt-3">
<div className="flex gap-1">
{/* Preview swatches: background, surface, accent-dark, accent
— gives a quick read of the preset's full palette. */}
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.primaryColor }}
style={{ backgroundColor: theme.config.backgroundColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.surfaceColor || theme.config.backgroundColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.accentDarkColor || theme.config.primaryColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.accentColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div>
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
<div className="ml-auto text-neutral-400">
@@ -834,25 +846,27 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
handleChange('colorMode', mode);
// When switching to dark, auto-populate dark defaults if colors are still light
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
const updated = {
const updated: ThemeConfig = {
...localTheme,
colorMode: mode,
backgroundColor: '#0f0f0f',
textColor: '#e5e5e5',
surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e',
textColor: '#e5e5e5',
mutedTextColor: '#a3a3a3',
};
setLocalTheme(updated);
onChange({ ...updated, customCss });
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
const updated = {
const updated: ThemeConfig = {
...localTheme,
colorMode: mode,
backgroundColor: '#fafafa',
textColor: '#171717',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373',
};
setLocalTheme(updated);
@@ -876,85 +890,119 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/*
* 8-token CI palette pickers, grouped by role.
* Each token writes directly to the same field name on ThemeConfig
* (kebab → camel mapping happens via handleChange's first arg).
* Translation keys fall back to inline strings — German/English
* coverage only (per user language profile); other locales will
* show the fallback until reviewed by a native speaker.
*/}
<div className="space-y-6">
{/* Surfaces */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.primaryColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.primaryColor || '#5C8762'}
onChange={(e) => handleChange('primaryColor', e.target.value)}
placeholder="#5C8762"
className="flex-1"
/>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
{t('branding.colorGroupSurfaces', 'Surfaces')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ key: 'backgroundColor', label: t('branding.backgroundColor', 'Background'), help: t('branding.backgroundColorHelp', 'Page base'), fallback: '#fafafa' },
{ key: 'surfaceColor', label: t('branding.surfaceColor', 'Surface'), help: t('branding.surfaceColorHelp', 'Cards, navigation'), fallback: '#ffffff' },
{ key: 'elevatedColor', label: t('branding.elevatedColor', 'Elevated'), help: t('branding.elevatedColorHelp', 'Raised panels, placeholders'), fallback: '#f5f5f5' },
{ key: 'surfaceBorderColor', label: t('branding.borderColor', 'Border'), help: t('branding.borderColorHelp', 'Dividers, grid lines'), fallback: '#e5e5e5' },
].map(({ key, label, help, fallback }) => (
<div key={key}>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
<div className="flex gap-2">
<input
type="color"
value={(localTheme as Record<string, string | undefined>)[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"
/>
<Input
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
placeholder={fallback}
className="flex-1"
/>
</div>
</div>
))}
</div>
</div>
{/* Text */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.accentColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.accentColor || '#22c55e'}
onChange={(e) => handleChange('accentColor', e.target.value)}
placeholder="#22c55e"
className="flex-1"
/>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
{t('branding.colorGroupText', 'Text')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ key: 'textColor', label: t('branding.textColor', 'Primary text'), help: t('branding.textColorHelp', 'Headlines, body'), fallback: '#171717' },
{ key: 'mutedTextColor', label: t('branding.mutedTextColor', 'Secondary text'), help: t('branding.mutedTextColorHelp', 'Captions, labels, meta'), fallback: '#737373' },
].map(({ key, label, help, fallback }) => (
<div key={key}>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
<div className="flex gap-2">
<input
type="color"
value={(localTheme as Record<string, string | undefined>)[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"
/>
<Input
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
placeholder={fallback}
className="flex-1"
/>
</div>
</div>
))}
</div>
</div>
{/* Accent */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.backgroundColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.backgroundColor || '#fafafa'}
onChange={(e) => handleChange('backgroundColor', e.target.value)}
placeholder="#fafafa"
className="flex-1"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.textColor')}
</label>
<div className="flex gap-2">
<input
type="color"
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={localTheme.textColor || '#171717'}
onChange={(e) => handleChange('textColor', e.target.value)}
placeholder="#171717"
className="flex-1"
/>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
{t('branding.colorGroupAccent', 'Accent')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ key: 'accentColor', label: t('branding.accentColor', 'Accent'), help: t('branding.accentColorHelp', 'Links, focus rings, hover'), fallback: '#22c55e' },
{ key: 'accentDarkColor', label: t('branding.accentDarkColor', 'Accent (filled)'), help: t('branding.accentDarkColorHelp', 'Primary CTA fill'), fallback: '#5C8762' },
].map(({ key, label, help, fallback }) => (
<div key={key}>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
<div className="flex gap-2">
<input
type="color"
value={(localTheme as Record<string, string | undefined>)[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"
/>
<Input
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
placeholder={fallback}
className="flex-1"
/>
</div>
</div>
))}
</div>
{/* primaryColor is kept in sync with accentDarkColor inside
handleChange() — no dedicated picker. */}
</div>
</div>
</Card>
@@ -1179,10 +1227,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
--accent-color: ${localTheme.accentColor || '#22c55e'};
--background-color: ${localTheme.backgroundColor || '#fafafa'};
--text-color: ${localTheme.textColor || '#171717'};
{`--color-background: ${localTheme.backgroundColor || '#fafafa'};
--color-surface: ${localTheme.surfaceColor || '#ffffff'};
--color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
--color-surface-border: ${localTheme.surfaceBorderColor || '#e5e5e5'};
--color-text: ${localTheme.textColor || '#171717'};
--color-muted-text: ${localTheme.mutedTextColor || '#737373'};
--color-accent: ${localTheme.accentColor || '#22c55e'};
--color-accent-dark: ${localTheme.accentDarkColor || localTheme.primaryColor || '#5C8762'};
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
</code>
+17 -19
View File
@@ -93,32 +93,30 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
{showDetails && (
<>
{/* Color Palette */}
{/* Color Palette — show all 8 tokens of the active theme.
Each swatch only renders if its token is set so legacy themes
(pre-8-token migration) still render their original 4 swatches. */}
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
<div className="flex gap-1">
{themeConfig.primaryColor && (
{[
{ value: themeConfig.backgroundColor, title: t('branding.backgroundColor', 'Background') },
{ value: themeConfig.surfaceColor, title: t('branding.surfaceColor', 'Surface') },
{ value: themeConfig.elevatedColor, title: t('branding.elevatedColor', 'Elevated') },
{ value: themeConfig.surfaceBorderColor, title: t('branding.borderColor', 'Border') },
{ value: themeConfig.textColor, title: t('branding.textColor', 'Text') },
{ value: themeConfig.mutedTextColor, title: t('branding.mutedTextColor', 'Muted text') },
{ value: themeConfig.accentColor, title: t('branding.accentColor', 'Accent') },
{ value: themeConfig.accentDarkColor || themeConfig.primaryColor, title: t('branding.accentDarkColor', 'Accent (filled)') },
].filter((s) => !!s.value).map((s, i) => (
<div
key={i}
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.primaryColor }}
title={t('branding.primaryColor')}
style={{ backgroundColor: s.value }}
title={s.title}
/>
)}
{themeConfig.accentColor && (
<div
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.accentColor }}
title={t('branding.accentColor')}
/>
)}
{themeConfig.backgroundColor && (
<div
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.backgroundColor }}
title={t('branding.backgroundColor')}
/>
)}
))}
</div>
</div>
@@ -116,20 +116,20 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-neutral-900">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.galleryTheme')}
</h2>
<p className="text-sm text-neutral-600 mt-1">
<p className="text-sm text-neutral-600 dark:text-neutral-300 mt-1">
{t('events.customizingThemeFor', { event: eventName })}
</p>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors"
className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
@@ -139,7 +139,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left side - Theme Customizer */}
<div className="p-6 overflow-y-auto border-r border-neutral-200">
<div className="p-6 overflow-y-auto border-r border-neutral-200 dark:border-neutral-700">
<ThemeCustomizerEnhanced
value={theme}
onChange={handleThemeChange}
@@ -155,11 +155,11 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
</div>
{/* Right side - Gallery Preview */}
<div className="p-6 bg-neutral-50 overflow-y-auto">
<div className="p-6 bg-neutral-50 dark:bg-neutral-800 overflow-y-auto">
<div className="space-y-4">
{/* Grid Style Selector */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-3">
{t('branding.previewLayout')}
</h3>
<div className="grid grid-cols-3 gap-2">
@@ -169,12 +169,12 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onClick={() => setPreviewLayout(layout)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(previewLayout || theme.galleryLayout || 'grid') === layout
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300 bg-white'
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/40'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 bg-white dark:bg-neutral-900'
}`}
>
<div className="flex flex-col items-center gap-1">
<div className="text-neutral-700">
<div className="text-neutral-700 dark:text-neutral-200">
{layoutIcons[layout]}
</div>
<span className="text-xs capitalize">
+22 -3
View File
@@ -102,17 +102,26 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
const root = document.documentElement;
// Apply CSS variables
// Apply CSS variables — 8-token CI palette.
// Legacy --color-primary / --color-primary-light / --color-primary-dark
// are kept for any consumer still reading them; they mirror accent-dark.
if (themeConfig.primaryColor) {
root.style.setProperty('--color-primary', themeConfig.primaryColor);
// Generate primary color shades
root.style.setProperty('--color-primary-light', lightenColor(themeConfig.primaryColor, 20));
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
}
if (themeConfig.accentColor) {
root.style.setProperty('--color-accent', themeConfig.accentColor);
}
// Accent-dark: filled CTA background. Falls back to primaryColor for
// legacy themes that pre-date the explicit token (matches the previous
// implicit behavior where .btn-primary used --color-primary).
const accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor;
if (accentDark) {
root.style.setProperty('--color-accent-dark', accentDark);
}
if (themeConfig.backgroundColor) {
root.style.setProperty('--color-background', themeConfig.backgroundColor);
@@ -194,6 +203,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
root.style.setProperty('--color-surface', '#ffffff');
}
// Elevated: raised panels, image placeholders. Falls back to a slight
// shift from surface so the layering still reads on legacy themes.
if (themeConfig.elevatedColor) {
root.style.setProperty('--color-elevated', themeConfig.elevatedColor);
} else if (effectiveMode === 'dark') {
root.style.setProperty('--color-elevated', '#242424');
} else {
root.style.setProperty('--color-elevated', '#f5f5f5');
}
if (themeConfig.surfaceBorderColor) {
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
} else if (effectiveMode === 'dark') {
+110 -19
View File
@@ -19,29 +19,60 @@
@layer base {
:root {
/* Theme CSS Variables */
/*
* Theme CSS Variables — 8-token CI palette.
* Token names are kept aligned with frontend/src/types/theme.types.ts
* (ThemeConfig). ThemeContext.applyTheme() writes these from the active
* theme/branding settings; values here are the "Classic Grid" defaults.
*/
--color-background: #fafafa; /* page base */
--color-surface: #ffffff; /* cards, nav, alternating sections */
--color-elevated: #f5f5f5; /* raised panels */
--color-surface-border: #e5e5e5; /* dividers, borders (a.k.a. border token) */
--color-text: #171717; /* primary text */
--color-muted-text: #737373; /* secondary text */
--color-accent: #22c55e; /* links, focus rings, hover */
--color-accent-dark: #5C8762; /* primary CTA fill */
/* Legacy aliases — kept for any consumer that still reads --color-primary.
* Both resolve to the accent-dark token (the previous "primary" CTA color). */
--color-primary: #5C8762;
--color-primary-light: #7aa583;
--color-primary-dark: #4a6f4f;
--color-accent: #22c55e;
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
--heading-font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
--border-radius: 0.5rem;
--font-size-base: 16px;
--shadow-default: 0 4px 6px rgba(0,0,0,0.1);
/* Surface colors (for cards, inputs, etc.) */
--color-surface: #ffffff;
--color-surface-border: #e5e5e5;
--color-muted-text: #737373;
/* Tailwind RGB values for primary color */
/* Tailwind RGB values for primary color (legacy, used by primary-* utilities) */
--tw-color-primary: 92 135 98;
--radius: 0.5rem;
}
/*
* Admin dark mode unification.
* AdminDarkModeContext toggles `.dark` on <html> without going through
* applyTheme(). Previously this only flipped the components that had
* explicit `.dark .x` overrides; everything else (cards/inputs/buttons
* that now read CSS variables) stayed light. We re-declare the 8-token
* defaults under `.dark` so the same variables resolve to a dark palette
* whenever the class is present. Gallery applyTheme() still wins because
* it writes inline `--color-*` styles on the html element, which beat the
* .dark stylesheet rule in the cascade.
*/
.dark {
--color-background: #0a0a0a;
--color-surface: #171717;
--color-elevated: #1f1f1f;
--color-surface-border: #262626;
--color-text: #f5f5f5;
--color-muted-text: #a3a3a3;
--color-accent: #22c55e;
--color-accent-dark: #5C8762;
}
* {
font-family: var(--font-family);
}
@@ -100,33 +131,57 @@
}
@layer components {
/* Button styles */
/*
* Button styles.
*
* Migration note: .btn-primary / .btn-secondary / .btn-outline keep the
* exact same visual contract they had before the 8-token migration —
* --color-primary still drives .btn-primary's background, white text on
* primary, and the legacy --color-primary-dark hover. This guarantees
* existing themes render identically after upgrade. The new .btn-alt
* implements the LBM "Alternative" button (outlined, inverts on hover)
* and the focus ring switches to var(--color-accent) so it reads on
* every surface (light, dark, branded).
*/
.btn {
@apply inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
border-radius: var(--border-radius);
--tw-ring-color: var(--color-accent);
--tw-ring-offset-color: var(--color-background);
}
.btn-primary {
background-color: var(--color-primary);
color: white;
@apply hover:opacity-90 focus-visible:ring-2;
@apply hover:opacity-90;
}
.btn-primary:hover {
background-color: var(--color-primary-dark);
}
.btn-alt {
background-color: transparent;
color: var(--color-text);
border: 2px solid var(--color-text);
}
.btn-alt:hover {
background-color: var(--color-text);
color: var(--color-background);
}
.btn-secondary {
background-color: var(--color-surface-border);
color: var(--color-text);
@apply hover:opacity-80 focus-visible:ring-neutral-400;
@apply hover:opacity-80;
}
.btn-outline {
border-color: var(--color-surface-border);
background-color: transparent;
color: var(--color-muted-text);
@apply border hover:opacity-80 focus-visible:ring-neutral-400;
@apply border hover:opacity-80;
}
.btn-sm {
@@ -141,7 +196,13 @@
@apply h-11 px-8 text-lg;
}
/* Input styles - Admin inputs use explicit Tailwind colors */
/*
* Input styles - Admin inputs keep explicit Tailwind colors so the visual
* contract is byte-for-byte identical to pre-migration (border-neutral-300
* vs the lighter neutral-200/surface-border). Dark mode is handled by the
* .dark .input override below. Gallery inputs use .input-themed which
* binds to the 8-token palette via CSS variables.
*/
.input {
@apply flex h-10 w-full rounded-lg border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
@apply bg-white border-neutral-300 text-neutral-900;
@@ -163,7 +224,10 @@
color: var(--color-muted-text);
}
/* Card styles - Admin cards use explicit Tailwind colors, gallery cards use CSS variables */
/* Card styles - Admin cards keep their explicit Tailwind colors for
* migration safety (visible border lightness change otherwise); the
* .dark .card override below handles admin dark mode. Gallery uses
* .card-themed which binds to the 8-token palette. */
.card {
@apply rounded-xl border bg-white border-neutral-200;
box-shadow: var(--shadow-default);
@@ -191,11 +255,33 @@
@apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl w-full;
}
/* Surface utility classes for gallery dark mode */
/*
* Theme-token utility classes — exposed so any component (admin or gallery)
* can opt into the 8-token palette without an inline style. The Tailwind
* config also exposes these as full color aliases (bg-surface, text-theme,
* etc.) but these single-purpose classes are kept for back-compat with
* existing call sites.
*/
.bg-background {
background-color: var(--color-background);
}
.bg-surface {
background-color: var(--color-surface);
}
.bg-elevated {
background-color: var(--color-elevated);
}
.bg-accent {
background-color: var(--color-accent);
}
.bg-accent-dark {
background-color: var(--color-accent-dark);
}
.border-surface {
border-color: var(--color-surface-border);
}
@@ -208,6 +294,10 @@
color: var(--color-muted-text);
}
.text-accent {
color: var(--color-accent);
}
/* Image loading skeleton */
.skeleton {
@apply animate-pulse rounded-lg bg-neutral-200;
@@ -281,7 +371,8 @@
@apply bg-red-900/40 text-red-300;
}
/* Secondary and outline buttons for admin dark mode */
/* Secondary and outline buttons for admin dark mode — restore explicit
* neutral overrides so existing admin pages render exactly as before. */
.dark .btn-secondary {
@apply bg-neutral-700 border-neutral-600 text-neutral-100;
}
+87 -2
View File
@@ -53,13 +53,27 @@ export interface GalleryLayoutSettings {
}
export interface ThemeConfig {
// Colors
// Colors — 8-token CI palette.
// Naming kept for backward-compat with existing settings rows; semantics:
// backgroundColor → page base
// surfaceColor → cards / nav / alternating sections
// elevatedColor → raised panels / image placeholders
// surfaceBorderColor→ dividers, borders, grid lines (a.k.a. "border" token)
// textColor → primary text (Text 1°)
// mutedTextColor → secondary text (Text 2°)
// accentColor → links, icons, focus rings, hover
// accentDarkColor → primary CTA fill / filled states
//
// primaryColor is retained as a legacy alias and migrated to accentDarkColor
// by frontend/src/utils/themeMigration.ts. Do not surface it in new UI.
primaryColor?: string;
accentColor?: string;
accentDarkColor?: string;
backgroundColor?: string;
textColor?: string;
surfaceColor?: string;
elevatedColor?: string;
surfaceBorderColor?: string;
textColor?: string;
mutedTextColor?: string;
// Color Mode
@@ -115,8 +129,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
accentDarkColor: '#5C8762',
backgroundColor: '#fafafa',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373',
borderRadius: 'md',
galleryLayout: 'grid',
gallerySettings: {
@@ -136,8 +155,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#c9a961',
accentColor: '#e6ddd4',
accentDarkColor: '#c9a961',
backgroundColor: '#fdfcfb',
surfaceColor: '#ffffff',
elevatedColor: '#faf6f0',
surfaceBorderColor: '#e8e0d4',
textColor: '#3f3f3f',
mutedTextColor: '#7a7a7a',
fontFamily: 'Playfair Display, serif',
headingFontFamily: 'Playfair Display, serif',
borderRadius: 'lg',
@@ -163,8 +187,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#3b82f6',
accentColor: '#1e40af',
accentDarkColor: '#3b82f6',
backgroundColor: '#ffffff',
surfaceColor: '#ffffff',
elevatedColor: '#f8fafc',
surfaceBorderColor: '#e2e8f0',
textColor: '#0f172a',
mutedTextColor: '#64748b',
fontFamily: 'Inter, sans-serif',
borderRadius: 'sm',
galleryLayout: 'masonry',
@@ -189,8 +218,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#ec4899',
accentColor: '#fbbf24',
accentDarkColor: '#ec4899',
backgroundColor: '#fef3c7',
surfaceColor: '#ffffff',
elevatedColor: '#fef9e3',
surfaceBorderColor: '#fde68a',
textColor: '#451a03',
mutedTextColor: '#92400e',
fontFamily: 'Comic Neue, cursive',
borderRadius: 'lg',
galleryLayout: 'carousel',
@@ -214,8 +248,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#1f2937',
accentColor: '#059669',
accentDarkColor: '#1f2937',
backgroundColor: '#f9fafb',
surfaceColor: '#ffffff',
elevatedColor: '#f3f4f6',
surfaceBorderColor: '#e5e7eb',
textColor: '#111827',
mutedTextColor: '#6b7280',
fontFamily: 'IBM Plex Sans, sans-serif',
borderRadius: 'sm',
galleryLayout: 'timeline',
@@ -238,8 +277,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#7c3aed',
accentColor: '#f59e0b',
accentDarkColor: '#7c3aed',
backgroundColor: '#faf5ff',
surfaceColor: '#ffffff',
elevatedColor: '#f3e8ff',
surfaceBorderColor: '#e9d5ff',
textColor: '#1e1b4b',
mutedTextColor: '#6b7280',
fontFamily: 'Montserrat, sans-serif',
borderRadius: 'none',
galleryLayout: 'mosaic',
@@ -261,9 +305,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
accentDarkColor: '#5C8762',
backgroundColor: '#0f0f0f',
textColor: '#e5e5e5',
surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e',
mutedTextColor: '#a3a3a3',
colorMode: 'dark',
@@ -287,9 +333,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#c9a961',
accentColor: '#e6ddd4',
accentDarkColor: '#c9a961',
backgroundColor: '#121212',
textColor: '#f0ebe5',
surfaceColor: '#1e1e1e',
elevatedColor: '#262626',
surfaceBorderColor: '#333333',
mutedTextColor: '#a3a3a3',
colorMode: 'dark',
@@ -317,9 +365,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#3b82f6',
accentColor: '#1e40af',
accentDarkColor: '#3b82f6',
backgroundColor: '#0a0a0a',
textColor: '#f5f5f5',
surfaceColor: '#171717',
elevatedColor: '#1f1f1f',
surfaceBorderColor: '#262626',
mutedTextColor: '#a3a3a3',
colorMode: 'dark',
@@ -345,9 +395,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#18181b',
accentColor: '#ef4444',
accentDarkColor: '#18181b',
backgroundColor: '#ffffff',
textColor: '#18181b',
surfaceColor: '#ffffff',
elevatedColor: '#fafafa',
surfaceBorderColor: '#f4f4f5',
mutedTextColor: '#71717a',
colorMode: 'light',
@@ -372,9 +424,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: {
primaryColor: '#c9a961',
accentColor: '#c9a961',
accentDarkColor: '#a88c4a',
backgroundColor: '#0d0d0d',
textColor: '#f2f2f2',
surfaceColor: '#1a1a1a',
elevatedColor: '#222222',
surfaceBorderColor: '#262626',
mutedTextColor: '#a3a3a3',
colorMode: 'dark',
@@ -391,5 +445,36 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
shadowStyle: 'subtle'
},
isPreset: true
},
lbmDark: {
name: 'LBM Dark',
description: 'Charcoal base with pure teal — Luca Bresch Media CI palette',
config: {
// Mapped from LBM_Brand_Identity.docx core system
primaryColor: '#014E4E', // legacy alias of accentDarkColor
accentColor: '#017C7C', // links, focus rings, hover
accentDarkColor: '#014E4E', // primary CTA fill
backgroundColor: '#0D0D0D', // page base
surfaceColor: '#111414', // cards, nav
elevatedColor: '#182222', // raised panels
surfaceBorderColor: '#1E2E2E',// dividers, borders
textColor: '#EBEBEB', // Text 1°
mutedTextColor: '#4A6060', // Text 2°
colorMode: 'dark',
fontFamily: 'Jost, sans-serif',
headingFontFamily: 'Jost, sans-serif',
borderRadius: 'md',
galleryLayout: 'grid',
gallerySettings: {
spacing: 'normal',
photoAnimation: 'fade',
gridColumns: { mobile: 2, tablet: 3, desktop: 4 }
},
headerStyle: 'standard',
footerStyle: 'minimal',
shadowStyle: 'subtle'
},
isPreset: true
}
};
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { migrateThemeConfig } from '../themeMigration';
import type { ThemeConfig } from '../../types/theme.types';
describe('migrateThemeConfig — 8-token palette fill', () => {
it('derives light surface defaults for a legacy 4-color light theme', () => {
const legacy: ThemeConfig = {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
colorMode: 'light',
galleryLayout: 'grid',
};
const migrated = migrateThemeConfig(legacy);
expect(migrated.surfaceColor).toBe('#ffffff');
expect(migrated.elevatedColor).toBe('#f5f5f5');
expect(migrated.surfaceBorderColor).toBe('#e5e5e5');
expect(migrated.mutedTextColor).toBe('#737373');
// Legacy primaryColor was used as the CTA fill — preserved as accentDark.
expect(migrated.accentDarkColor).toBe('#5C8762');
// Existing fields untouched.
expect(migrated.primaryColor).toBe('#5C8762');
expect(migrated.backgroundColor).toBe('#fafafa');
expect(migrated.textColor).toBe('#171717');
});
it('derives dark surface defaults for a legacy 4-color dark theme', () => {
const legacy: ThemeConfig = {
primaryColor: '#3b82f6',
accentColor: '#1e40af',
backgroundColor: '#0a0a0a',
textColor: '#f5f5f5',
colorMode: 'dark',
galleryLayout: 'grid',
};
const migrated = migrateThemeConfig(legacy);
expect(migrated.surfaceColor).toBe('#1a1a1a');
expect(migrated.elevatedColor).toBe('#242424');
expect(migrated.surfaceBorderColor).toBe('#2e2e2e');
expect(migrated.mutedTextColor).toBe('#a3a3a3');
expect(migrated.accentDarkColor).toBe('#3b82f6');
});
it('does not overwrite explicit 8-token values', () => {
const fullPalette: ThemeConfig = {
primaryColor: '#014E4E',
accentColor: '#017C7C',
accentDarkColor: '#014E4E',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#4A6060',
colorMode: 'dark',
galleryLayout: 'grid',
};
const migrated = migrateThemeConfig(fullPalette);
expect(migrated.surfaceColor).toBe('#111414');
expect(migrated.elevatedColor).toBe('#182222');
expect(migrated.surfaceBorderColor).toBe('#1E2E2E');
expect(migrated.mutedTextColor).toBe('#4A6060');
expect(migrated.accentDarkColor).toBe('#014E4E');
});
it('still migrates the legacy "hero" galleryLayout while filling palette', () => {
const legacy = {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
galleryLayout: 'hero',
} as unknown as ThemeConfig;
const migrated = migrateThemeConfig(legacy);
expect(migrated.galleryLayout).toBe('grid');
expect(migrated.headerStyle).toBe('hero');
expect(migrated.heroDividerStyle).toBe('wave');
// Palette still filled.
expect(migrated.surfaceColor).toBe('#ffffff');
expect(migrated.accentDarkColor).toBe('#5C8762');
});
});
+55 -12
View File
@@ -1,34 +1,77 @@
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
/**
* Migrates legacy theme configurations that used 'hero' as a galleryLayout
* to the new decoupled headerStyle + galleryLayout system.
* Fills in any missing 8-token CI palette fields on legacy themes that were
* saved before the palette expanded from 4 → 8 explicit tokens.
*
* This ensures backward compatibility with existing events that have
* 'hero' set as their galleryLayout.
* The visible look of an existing instance must not change just because the
* type system grew (per project memory: migrations preserve visual state).
* For each missing token we fall back to the value the renderer was already
* deriving implicitly:
* - accentDarkColor ← primaryColor (legacy primary was used as CTA fill)
* - elevatedColor ← surfaceColor (or a slight shift for light themes)
* - surfaceColor ← '#ffffff' / '#1a1a1a' depending on colorMode
* - surfaceBorderColor← '#e5e5e5' / '#2e2e2e'
* - mutedTextColor ← '#737373' / '#a3a3a3'
*/
function fillMissingPaletteTokens(theme: ThemeConfig): ThemeConfig {
const isDark = theme.colorMode === 'dark';
const filled: ThemeConfig = { ...theme };
if (!filled.surfaceColor) {
filled.surfaceColor = isDark ? '#1a1a1a' : '#ffffff';
}
if (!filled.elevatedColor) {
// For dark themes raise slightly above surface; for light, drop slightly below.
filled.elevatedColor = isDark ? '#242424' : '#f5f5f5';
}
if (!filled.surfaceBorderColor) {
filled.surfaceBorderColor = isDark ? '#2e2e2e' : '#e5e5e5';
}
if (!filled.mutedTextColor) {
filled.mutedTextColor = isDark ? '#a3a3a3' : '#737373';
}
if (!filled.accentDarkColor) {
// Legacy themes used primaryColor as the CTA fill — preserve that.
filled.accentDarkColor = filled.primaryColor;
}
return filled;
}
/**
* Migrates legacy theme configurations:
* - 'hero' galleryLayout → decoupled headerStyle + galleryLayout
* - missing 8-token CI palette fields → derived from legacy 4-color set
*
* This ensures backward compatibility with existing events.
*/
export function migrateThemeConfig(theme: ThemeConfig): ThemeConfig {
if (!theme) return theme;
let migrated = theme;
// Check if this theme uses the legacy 'hero' layout
if ((theme.galleryLayout as string) === 'hero') {
return {
...theme,
if ((migrated.galleryLayout as string) === 'hero') {
migrated = {
...migrated,
headerStyle: 'hero' as HeaderStyleType,
galleryLayout: 'grid' as GalleryLayoutType,
heroDividerStyle: (theme.heroDividerStyle || 'wave') as HeroDividerStyle,
heroDividerStyle: (migrated.heroDividerStyle || 'wave') as HeroDividerStyle,
};
}
// If headerStyle is not set but galleryLayout is valid, default to 'standard'
if (!theme.headerStyle && theme.galleryLayout) {
return {
...theme,
if (!migrated.headerStyle && migrated.galleryLayout) {
migrated = {
...migrated,
headerStyle: 'standard' as HeaderStyleType,
};
}
return theme;
// Fill any missing 8-token palette fields so the renderer never has to
// fall back to hard-coded defaults that diverge from the original look.
return fillMissingPaletteTokens(migrated);
}
/**
+13
View File
@@ -8,6 +8,19 @@ export default {
theme: {
extend: {
colors: {
// 8-token CI palette aliases — these read CSS variables that are set
// either by ThemeContext.applyTheme (gallery + branding) or by the
// :root.dark { } block in index.css (admin dark mode). Use these in
// place of bg-white / text-neutral-900 / border-neutral-200 so that
// every component flips with dark/light mode automatically.
background: 'var(--color-background)',
surface: 'var(--color-surface)',
elevated: 'var(--color-elevated)',
'border-token': 'var(--color-surface-border)',
'text-primary': 'var(--color-text)',
'text-secondary': 'var(--color-muted-text)',
accent: 'var(--color-accent)',
'accent-dark': 'var(--color-accent-dark)',
primary: {
50: '#f0fdf4',
100: '#dcfce7',