feat(theme): expand color settings to 8-token CI palette + alt button
This commit is contained in:
@@ -104,4 +104,59 @@ describe('publicSiteService', () => {
|
|||||||
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
||||||
expect(payload.branding.colors.primary).toBe('#5C8762');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,11 +87,19 @@ async function fetchBrandingContext() {
|
|||||||
supportEmail: null,
|
supportEmail: null,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
footerText: 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: {
|
colors: {
|
||||||
primary: '#16a34a',
|
primary: '#16a34a',
|
||||||
accent: '#0f766e',
|
accent: '#0f766e',
|
||||||
|
accentDark: '#16a34a',
|
||||||
background: '#f4fbf6',
|
background: '#f4fbf6',
|
||||||
text: '#0f172a'
|
surface: '#ffffff',
|
||||||
|
elevated: '#f5f5f5',
|
||||||
|
border: '#e5e5e5',
|
||||||
|
text: '#0f172a',
|
||||||
|
mutedText: '#737373'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,10 +125,17 @@ async function fetchBrandingContext() {
|
|||||||
try {
|
try {
|
||||||
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
||||||
if (themeConfig && typeof themeConfig === 'object') {
|
if (themeConfig && typeof themeConfig === 'object') {
|
||||||
|
// Legacy 4 colors
|
||||||
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
|
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
|
||||||
context.colors.accent = themeConfig.accentColor || context.colors.accent;
|
context.colors.accent = themeConfig.accentColor || context.colors.accent;
|
||||||
context.colors.background = themeConfig.backgroundColor || context.colors.background;
|
context.colors.background = themeConfig.backgroundColor || context.colors.background;
|
||||||
context.colors.text = themeConfig.textColor || context.colors.text;
|
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) {
|
} catch (error) {
|
||||||
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
|
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
|
||||||
|
|||||||
@@ -158,7 +158,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
}, [presetName]);
|
}, [presetName]);
|
||||||
|
|
||||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
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);
|
setLocalTheme(updated);
|
||||||
|
|
||||||
// When any change is made, mark it as custom
|
// When any change is made, mark it as custom
|
||||||
@@ -265,18 +271,24 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{/* Preview swatches: background, surface, accent-dark, accent
|
||||||
|
— gives a quick read of the preset's full palette. */}
|
||||||
<div
|
<div
|
||||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
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
|
<div
|
||||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
||||||
style={{ backgroundColor: theme.config.accentColor }}
|
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>
|
</div>
|
||||||
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
|
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
|
||||||
<div className="ml-auto text-neutral-400">
|
<div className="ml-auto text-neutral-400">
|
||||||
@@ -834,25 +846,27 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
handleChange('colorMode', mode);
|
handleChange('colorMode', mode);
|
||||||
// When switching to dark, auto-populate dark defaults if colors are still light
|
// When switching to dark, auto-populate dark defaults if colors are still light
|
||||||
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
|
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
|
||||||
const updated = {
|
const updated: ThemeConfig = {
|
||||||
...localTheme,
|
...localTheme,
|
||||||
colorMode: mode,
|
colorMode: mode,
|
||||||
backgroundColor: '#0f0f0f',
|
backgroundColor: '#0f0f0f',
|
||||||
textColor: '#e5e5e5',
|
|
||||||
surfaceColor: '#1a1a1a',
|
surfaceColor: '#1a1a1a',
|
||||||
|
elevatedColor: '#242424',
|
||||||
surfaceBorderColor: '#2e2e2e',
|
surfaceBorderColor: '#2e2e2e',
|
||||||
|
textColor: '#e5e5e5',
|
||||||
mutedTextColor: '#a3a3a3',
|
mutedTextColor: '#a3a3a3',
|
||||||
};
|
};
|
||||||
setLocalTheme(updated);
|
setLocalTheme(updated);
|
||||||
onChange({ ...updated, customCss });
|
onChange({ ...updated, customCss });
|
||||||
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
|
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
|
||||||
const updated = {
|
const updated: ThemeConfig = {
|
||||||
...localTheme,
|
...localTheme,
|
||||||
colorMode: mode,
|
colorMode: mode,
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: '#fafafa',
|
||||||
textColor: '#171717',
|
|
||||||
surfaceColor: '#ffffff',
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f5f5f5',
|
||||||
surfaceBorderColor: '#e5e5e5',
|
surfaceBorderColor: '#e5e5e5',
|
||||||
|
textColor: '#171717',
|
||||||
mutedTextColor: '#737373',
|
mutedTextColor: '#737373',
|
||||||
};
|
};
|
||||||
setLocalTheme(updated);
|
setLocalTheme(updated);
|
||||||
@@ -876,85 +890,119 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* 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>
|
||||||
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
{[
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
{ key: 'backgroundColor', label: t('branding.backgroundColor', 'Background'), help: t('branding.backgroundColorHelp', 'Page base'), fallback: '#fafafa' },
|
||||||
{t('branding.primaryColor')}
|
{ 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>
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={localTheme.primaryColor || '#5C8762'}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
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"
|
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={localTheme.primaryColor || '#5C8762'}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
||||||
placeholder="#5C8762"
|
placeholder={fallback}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
|
||||||
{t('branding.accentColor')}
|
{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>
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={localTheme.accentColor || '#22c55e'}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
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"
|
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={localTheme.accentColor || '#22c55e'}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
||||||
placeholder="#22c55e"
|
placeholder={fallback}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
|
||||||
{t('branding.backgroundColor')}
|
{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>
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={localTheme.backgroundColor || '#fafafa'}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
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"
|
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={localTheme.backgroundColor || '#fafafa'}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
||||||
placeholder="#fafafa"
|
placeholder={fallback}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* primaryColor is kept in sync with accentDarkColor inside
|
||||||
|
handleChange() — no dedicated picker. */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1179,10 +1227,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
|
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
|
||||||
</p>
|
</p>
|
||||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||||
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
|
{`--color-background: ${localTheme.backgroundColor || '#fafafa'};
|
||||||
--accent-color: ${localTheme.accentColor || '#22c55e'};
|
--color-surface: ${localTheme.surfaceColor || '#ffffff'};
|
||||||
--background-color: ${localTheme.backgroundColor || '#fafafa'};
|
--color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
|
||||||
--text-color: ${localTheme.textColor || '#171717'};
|
--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'};
|
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
|
||||||
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
|
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -93,32 +93,30 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
|||||||
|
|
||||||
{showDetails && (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
<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>
|
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
|
||||||
<div className="flex gap-1">
|
<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
|
<div
|
||||||
|
key={i}
|
||||||
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
style={{ backgroundColor: s.value }}
|
||||||
title={t('branding.primaryColor')}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -116,20 +116,20 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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 */}
|
{/* 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>
|
<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')}
|
{t('events.galleryTheme')}
|
||||||
</h2>
|
</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 })}
|
{t('events.customizingThemeFor', { event: eventName })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -139,7 +139,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
|
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
|
||||||
{/* Left side - Theme Customizer */}
|
{/* 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
|
<ThemeCustomizerEnhanced
|
||||||
value={theme}
|
value={theme}
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
@@ -155,11 +155,11 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Gallery Preview */}
|
{/* 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">
|
<div className="space-y-4">
|
||||||
{/* Grid Style Selector */}
|
{/* Grid Style Selector */}
|
||||||
<div>
|
<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')}
|
{t('branding.previewLayout')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
@@ -169,12 +169,12 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
|||||||
onClick={() => setPreviewLayout(layout)}
|
onClick={() => setPreviewLayout(layout)}
|
||||||
className={`relative p-3 rounded-lg border-2 transition-all ${
|
className={`relative p-3 rounded-lg border-2 transition-all ${
|
||||||
(previewLayout || theme.galleryLayout || 'grid') === layout
|
(previewLayout || theme.galleryLayout || 'grid') === layout
|
||||||
? 'border-primary-600 bg-primary-50'
|
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/40'
|
||||||
: 'border-neutral-200 hover:border-neutral-300 bg-white'
|
: '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="flex flex-col items-center gap-1">
|
||||||
<div className="text-neutral-700">
|
<div className="text-neutral-700 dark:text-neutral-200">
|
||||||
{layoutIcons[layout]}
|
{layoutIcons[layout]}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs capitalize">
|
<span className="text-xs capitalize">
|
||||||
|
|||||||
@@ -102,10 +102,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
|
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
|
||||||
const root = document.documentElement;
|
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) {
|
if (themeConfig.primaryColor) {
|
||||||
root.style.setProperty('--color-primary', 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-light', lightenColor(themeConfig.primaryColor, 20));
|
||||||
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
|
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
|
||||||
}
|
}
|
||||||
@@ -114,6 +115,14 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
root.style.setProperty('--color-accent', 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) {
|
if (themeConfig.backgroundColor) {
|
||||||
root.style.setProperty('--color-background', 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');
|
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) {
|
if (themeConfig.surfaceBorderColor) {
|
||||||
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
|
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
|
||||||
} else if (effectiveMode === 'dark') {
|
} else if (effectiveMode === 'dark') {
|
||||||
|
|||||||
+109
-18
@@ -19,29 +19,60 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
: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: #5C8762;
|
||||||
--color-primary-light: #7aa583;
|
--color-primary-light: #7aa583;
|
||||||
--color-primary-dark: #4a6f4f;
|
--color-primary-dark: #4a6f4f;
|
||||||
--color-accent: #22c55e;
|
|
||||||
--color-background: #fafafa;
|
|
||||||
--color-text: #171717;
|
|
||||||
--font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
|
--font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
|
||||||
--heading-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;
|
--border-radius: 0.5rem;
|
||||||
--font-size-base: 16px;
|
--font-size-base: 16px;
|
||||||
--shadow-default: 0 4px 6px rgba(0,0,0,0.1);
|
--shadow-default: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
|
||||||
/* Surface colors (for cards, inputs, etc.) */
|
/* Tailwind RGB values for primary color (legacy, used by primary-* utilities) */
|
||||||
--color-surface: #ffffff;
|
|
||||||
--color-surface-border: #e5e5e5;
|
|
||||||
--color-muted-text: #737373;
|
|
||||||
|
|
||||||
/* Tailwind RGB values for primary color */
|
|
||||||
--tw-color-primary: 92 135 98;
|
--tw-color-primary: 92 135 98;
|
||||||
--radius: 0.5rem;
|
--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);
|
font-family: var(--font-family);
|
||||||
}
|
}
|
||||||
@@ -100,33 +131,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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 {
|
.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;
|
@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);
|
border-radius: var(--border-radius);
|
||||||
|
--tw-ring-color: var(--color-accent);
|
||||||
|
--tw-ring-offset-color: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
@apply hover:opacity-90 focus-visible:ring-2;
|
@apply hover:opacity-90;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background-color: var(--color-primary-dark);
|
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 {
|
.btn-secondary {
|
||||||
background-color: var(--color-surface-border);
|
background-color: var(--color-surface-border);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@apply hover:opacity-80 focus-visible:ring-neutral-400;
|
@apply hover:opacity-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
border-color: var(--color-surface-border);
|
border-color: var(--color-surface-border);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--color-muted-text);
|
color: var(--color-muted-text);
|
||||||
@apply border hover:opacity-80 focus-visible:ring-neutral-400;
|
@apply border hover:opacity-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
@@ -141,7 +196,13 @@
|
|||||||
@apply h-11 px-8 text-lg;
|
@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 {
|
.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 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;
|
@apply bg-white border-neutral-300 text-neutral-900;
|
||||||
@@ -163,7 +224,10 @@
|
|||||||
color: var(--color-muted-text);
|
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 {
|
.card {
|
||||||
@apply rounded-xl border bg-white border-neutral-200;
|
@apply rounded-xl border bg-white border-neutral-200;
|
||||||
box-shadow: var(--shadow-default);
|
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;
|
@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 {
|
.bg-surface {
|
||||||
background-color: var(--color-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-surface {
|
||||||
border-color: var(--color-surface-border);
|
border-color: var(--color-surface-border);
|
||||||
}
|
}
|
||||||
@@ -208,6 +294,10 @@
|
|||||||
color: var(--color-muted-text);
|
color: var(--color-muted-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Image loading skeleton */
|
/* Image loading skeleton */
|
||||||
.skeleton {
|
.skeleton {
|
||||||
@apply animate-pulse rounded-lg bg-neutral-200;
|
@apply animate-pulse rounded-lg bg-neutral-200;
|
||||||
@@ -281,7 +371,8 @@
|
|||||||
@apply bg-red-900/40 text-red-300;
|
@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 {
|
.dark .btn-secondary {
|
||||||
@apply bg-neutral-700 border-neutral-600 text-neutral-100;
|
@apply bg-neutral-700 border-neutral-600 text-neutral-100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,13 +53,27 @@ export interface GalleryLayoutSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeConfig {
|
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;
|
primaryColor?: string;
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
|
accentDarkColor?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
textColor?: string;
|
|
||||||
surfaceColor?: string;
|
surfaceColor?: string;
|
||||||
|
elevatedColor?: string;
|
||||||
surfaceBorderColor?: string;
|
surfaceBorderColor?: string;
|
||||||
|
textColor?: string;
|
||||||
mutedTextColor?: string;
|
mutedTextColor?: string;
|
||||||
|
|
||||||
// Color Mode
|
// Color Mode
|
||||||
@@ -115,8 +129,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#5C8762',
|
primaryColor: '#5C8762',
|
||||||
accentColor: '#22c55e',
|
accentColor: '#22c55e',
|
||||||
|
accentDarkColor: '#5C8762',
|
||||||
backgroundColor: '#fafafa',
|
backgroundColor: '#fafafa',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f5f5f5',
|
||||||
|
surfaceBorderColor: '#e5e5e5',
|
||||||
textColor: '#171717',
|
textColor: '#171717',
|
||||||
|
mutedTextColor: '#737373',
|
||||||
borderRadius: 'md',
|
borderRadius: 'md',
|
||||||
galleryLayout: 'grid',
|
galleryLayout: 'grid',
|
||||||
gallerySettings: {
|
gallerySettings: {
|
||||||
@@ -136,8 +155,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#c9a961',
|
primaryColor: '#c9a961',
|
||||||
accentColor: '#e6ddd4',
|
accentColor: '#e6ddd4',
|
||||||
|
accentDarkColor: '#c9a961',
|
||||||
backgroundColor: '#fdfcfb',
|
backgroundColor: '#fdfcfb',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#faf6f0',
|
||||||
|
surfaceBorderColor: '#e8e0d4',
|
||||||
textColor: '#3f3f3f',
|
textColor: '#3f3f3f',
|
||||||
|
mutedTextColor: '#7a7a7a',
|
||||||
fontFamily: 'Playfair Display, serif',
|
fontFamily: 'Playfair Display, serif',
|
||||||
headingFontFamily: 'Playfair Display, serif',
|
headingFontFamily: 'Playfair Display, serif',
|
||||||
borderRadius: 'lg',
|
borderRadius: 'lg',
|
||||||
@@ -163,8 +187,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#3b82f6',
|
primaryColor: '#3b82f6',
|
||||||
accentColor: '#1e40af',
|
accentColor: '#1e40af',
|
||||||
|
accentDarkColor: '#3b82f6',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f8fafc',
|
||||||
|
surfaceBorderColor: '#e2e8f0',
|
||||||
textColor: '#0f172a',
|
textColor: '#0f172a',
|
||||||
|
mutedTextColor: '#64748b',
|
||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
borderRadius: 'sm',
|
borderRadius: 'sm',
|
||||||
galleryLayout: 'masonry',
|
galleryLayout: 'masonry',
|
||||||
@@ -189,8 +218,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#ec4899',
|
primaryColor: '#ec4899',
|
||||||
accentColor: '#fbbf24',
|
accentColor: '#fbbf24',
|
||||||
|
accentDarkColor: '#ec4899',
|
||||||
backgroundColor: '#fef3c7',
|
backgroundColor: '#fef3c7',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#fef9e3',
|
||||||
|
surfaceBorderColor: '#fde68a',
|
||||||
textColor: '#451a03',
|
textColor: '#451a03',
|
||||||
|
mutedTextColor: '#92400e',
|
||||||
fontFamily: 'Comic Neue, cursive',
|
fontFamily: 'Comic Neue, cursive',
|
||||||
borderRadius: 'lg',
|
borderRadius: 'lg',
|
||||||
galleryLayout: 'carousel',
|
galleryLayout: 'carousel',
|
||||||
@@ -214,8 +248,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#1f2937',
|
primaryColor: '#1f2937',
|
||||||
accentColor: '#059669',
|
accentColor: '#059669',
|
||||||
|
accentDarkColor: '#1f2937',
|
||||||
backgroundColor: '#f9fafb',
|
backgroundColor: '#f9fafb',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f3f4f6',
|
||||||
|
surfaceBorderColor: '#e5e7eb',
|
||||||
textColor: '#111827',
|
textColor: '#111827',
|
||||||
|
mutedTextColor: '#6b7280',
|
||||||
fontFamily: 'IBM Plex Sans, sans-serif',
|
fontFamily: 'IBM Plex Sans, sans-serif',
|
||||||
borderRadius: 'sm',
|
borderRadius: 'sm',
|
||||||
galleryLayout: 'timeline',
|
galleryLayout: 'timeline',
|
||||||
@@ -238,8 +277,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#7c3aed',
|
primaryColor: '#7c3aed',
|
||||||
accentColor: '#f59e0b',
|
accentColor: '#f59e0b',
|
||||||
|
accentDarkColor: '#7c3aed',
|
||||||
backgroundColor: '#faf5ff',
|
backgroundColor: '#faf5ff',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f3e8ff',
|
||||||
|
surfaceBorderColor: '#e9d5ff',
|
||||||
textColor: '#1e1b4b',
|
textColor: '#1e1b4b',
|
||||||
|
mutedTextColor: '#6b7280',
|
||||||
fontFamily: 'Montserrat, sans-serif',
|
fontFamily: 'Montserrat, sans-serif',
|
||||||
borderRadius: 'none',
|
borderRadius: 'none',
|
||||||
galleryLayout: 'mosaic',
|
galleryLayout: 'mosaic',
|
||||||
@@ -261,9 +305,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#5C8762',
|
primaryColor: '#5C8762',
|
||||||
accentColor: '#22c55e',
|
accentColor: '#22c55e',
|
||||||
|
accentDarkColor: '#5C8762',
|
||||||
backgroundColor: '#0f0f0f',
|
backgroundColor: '#0f0f0f',
|
||||||
textColor: '#e5e5e5',
|
textColor: '#e5e5e5',
|
||||||
surfaceColor: '#1a1a1a',
|
surfaceColor: '#1a1a1a',
|
||||||
|
elevatedColor: '#242424',
|
||||||
surfaceBorderColor: '#2e2e2e',
|
surfaceBorderColor: '#2e2e2e',
|
||||||
mutedTextColor: '#a3a3a3',
|
mutedTextColor: '#a3a3a3',
|
||||||
colorMode: 'dark',
|
colorMode: 'dark',
|
||||||
@@ -287,9 +333,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#c9a961',
|
primaryColor: '#c9a961',
|
||||||
accentColor: '#e6ddd4',
|
accentColor: '#e6ddd4',
|
||||||
|
accentDarkColor: '#c9a961',
|
||||||
backgroundColor: '#121212',
|
backgroundColor: '#121212',
|
||||||
textColor: '#f0ebe5',
|
textColor: '#f0ebe5',
|
||||||
surfaceColor: '#1e1e1e',
|
surfaceColor: '#1e1e1e',
|
||||||
|
elevatedColor: '#262626',
|
||||||
surfaceBorderColor: '#333333',
|
surfaceBorderColor: '#333333',
|
||||||
mutedTextColor: '#a3a3a3',
|
mutedTextColor: '#a3a3a3',
|
||||||
colorMode: 'dark',
|
colorMode: 'dark',
|
||||||
@@ -317,9 +365,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#3b82f6',
|
primaryColor: '#3b82f6',
|
||||||
accentColor: '#1e40af',
|
accentColor: '#1e40af',
|
||||||
|
accentDarkColor: '#3b82f6',
|
||||||
backgroundColor: '#0a0a0a',
|
backgroundColor: '#0a0a0a',
|
||||||
textColor: '#f5f5f5',
|
textColor: '#f5f5f5',
|
||||||
surfaceColor: '#171717',
|
surfaceColor: '#171717',
|
||||||
|
elevatedColor: '#1f1f1f',
|
||||||
surfaceBorderColor: '#262626',
|
surfaceBorderColor: '#262626',
|
||||||
mutedTextColor: '#a3a3a3',
|
mutedTextColor: '#a3a3a3',
|
||||||
colorMode: 'dark',
|
colorMode: 'dark',
|
||||||
@@ -345,9 +395,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#18181b',
|
primaryColor: '#18181b',
|
||||||
accentColor: '#ef4444',
|
accentColor: '#ef4444',
|
||||||
|
accentDarkColor: '#18181b',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
textColor: '#18181b',
|
textColor: '#18181b',
|
||||||
surfaceColor: '#ffffff',
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#fafafa',
|
||||||
surfaceBorderColor: '#f4f4f5',
|
surfaceBorderColor: '#f4f4f5',
|
||||||
mutedTextColor: '#71717a',
|
mutedTextColor: '#71717a',
|
||||||
colorMode: 'light',
|
colorMode: 'light',
|
||||||
@@ -372,9 +424,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
config: {
|
config: {
|
||||||
primaryColor: '#c9a961',
|
primaryColor: '#c9a961',
|
||||||
accentColor: '#c9a961',
|
accentColor: '#c9a961',
|
||||||
|
accentDarkColor: '#a88c4a',
|
||||||
backgroundColor: '#0d0d0d',
|
backgroundColor: '#0d0d0d',
|
||||||
textColor: '#f2f2f2',
|
textColor: '#f2f2f2',
|
||||||
surfaceColor: '#1a1a1a',
|
surfaceColor: '#1a1a1a',
|
||||||
|
elevatedColor: '#222222',
|
||||||
surfaceBorderColor: '#262626',
|
surfaceBorderColor: '#262626',
|
||||||
mutedTextColor: '#a3a3a3',
|
mutedTextColor: '#a3a3a3',
|
||||||
colorMode: 'dark',
|
colorMode: 'dark',
|
||||||
@@ -391,5 +445,36 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
|||||||
shadowStyle: 'subtle'
|
shadowStyle: 'subtle'
|
||||||
},
|
},
|
||||||
isPreset: true
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,77 @@
|
|||||||
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
|
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates legacy theme configurations that used 'hero' as a galleryLayout
|
* Fills in any missing 8-token CI palette fields on legacy themes that were
|
||||||
* to the new decoupled headerStyle + galleryLayout system.
|
* saved before the palette expanded from 4 → 8 explicit tokens.
|
||||||
*
|
*
|
||||||
* This ensures backward compatibility with existing events that have
|
* The visible look of an existing instance must not change just because the
|
||||||
* 'hero' set as their galleryLayout.
|
* 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 {
|
export function migrateThemeConfig(theme: ThemeConfig): ThemeConfig {
|
||||||
if (!theme) return theme;
|
if (!theme) return theme;
|
||||||
|
|
||||||
|
let migrated = theme;
|
||||||
|
|
||||||
// Check if this theme uses the legacy 'hero' layout
|
// Check if this theme uses the legacy 'hero' layout
|
||||||
if ((theme.galleryLayout as string) === 'hero') {
|
if ((migrated.galleryLayout as string) === 'hero') {
|
||||||
return {
|
migrated = {
|
||||||
...theme,
|
...migrated,
|
||||||
headerStyle: 'hero' as HeaderStyleType,
|
headerStyle: 'hero' as HeaderStyleType,
|
||||||
galleryLayout: 'grid' as GalleryLayoutType,
|
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 headerStyle is not set but galleryLayout is valid, default to 'standard'
|
||||||
if (!theme.headerStyle && theme.galleryLayout) {
|
if (!migrated.headerStyle && migrated.galleryLayout) {
|
||||||
return {
|
migrated = {
|
||||||
...theme,
|
...migrated,
|
||||||
headerStyle: 'standard' as HeaderStyleType,
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
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: {
|
primary: {
|
||||||
50: '#f0fdf4',
|
50: '#f0fdf4',
|
||||||
100: '#dcfce7',
|
100: '#dcfce7',
|
||||||
|
|||||||
Reference in New Issue
Block a user