diff --git a/backend/src/__tests__/publicSiteService.test.js b/backend/src/__tests__/publicSiteService.test.js index 18da9424..80daeef7 100644 --- a/backend/src/__tests__/publicSiteService.test.js +++ b/backend/src/__tests__/publicSiteService.test.js @@ -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'); + }); }); diff --git a/backend/src/services/publicSiteService.js b/backend/src/services/publicSiteService.js index d51332f0..75cfa92e 100644 --- a/backend/src/services/publicSiteService.js +++ b/backend/src/services/publicSiteService.js @@ -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 }); diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index f1041b14..60e3ad3e 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -158,7 +158,13 @@ export const ThemeCustomizerEnhanced: React.FC = ( }, [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 = (
+ {/* Preview swatches: background, surface, accent-dark, accent + — gives a quick read of the preset's full palette. */}
+
+
-
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
@@ -834,25 +846,27 @@ export const ThemeCustomizerEnhanced: React.FC = ( 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 = (

-
+ {/* + * 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. + */} +
+ {/* Surfaces */}
- -
- handleChange('primaryColor', e.target.value)} - className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" - /> - handleChange('primaryColor', e.target.value)} - placeholder="#5C8762" - className="flex-1" - /> +

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

+
+ {[ + { 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 }) => ( +
+ +

{help}

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

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

+
+ {[ + { 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 }) => ( +
+ +

{help}

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

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

+
+ {[ + { 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 }) => ( +
+ +

{help}

+
+ )[key] || fallback} + onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} + className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" + /> + )[key] || fallback} + onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)} + placeholder={fallback} + className="flex-1" + /> +
+
+ ))}
+ {/* primaryColor is kept in sync with accentDarkColor inside + handleChange() — no dedicated picker. */}
@@ -1179,10 +1227,14 @@ export const ThemeCustomizerEnhanced: React.FC = ( {t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}

-{`--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'};`} diff --git a/frontend/src/components/admin/ThemeDisplay.tsx b/frontend/src/components/admin/ThemeDisplay.tsx index e9aaccf1..6e46cc96 100644 --- a/frontend/src/components/admin/ThemeDisplay.tsx +++ b/frontend/src/components/admin/ThemeDisplay.tsx @@ -93,32 +93,30 @@ export const ThemeDisplay: React.FC = ({ {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. */}
{t('branding.colors')}:
- {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) => (
- )} - {themeConfig.accentColor && ( -
- )} - {themeConfig.backgroundColor && ( -
- )} + ))}
diff --git a/frontend/src/components/admin/ThemeEditorModal.tsx b/frontend/src/components/admin/ThemeEditorModal.tsx index 3c015a40..812cefb5 100644 --- a/frontend/src/components/admin/ThemeEditorModal.tsx +++ b/frontend/src/components/admin/ThemeEditorModal.tsx @@ -116,20 +116,20 @@ export const ThemeEditorModal: React.FC = ({ return (
-
+
{/* Header */} -
+
-

+

{t('events.galleryTheme')}

-

+

{t('events.customizingThemeFor', { event: eventName })}

@@ -139,7 +139,7 @@ export const ThemeEditorModal: React.FC = ({
{/* Left side - Theme Customizer */} -
+
= ({
{/* Right side - Gallery Preview */} -
+
{/* Grid Style Selector */}
-

+

{t('branding.previewLayout')}

@@ -169,12 +169,12 @@ export const ThemeEditorModal: React.FC = ({ 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' }`} >
-
+
{layoutIcons[layout]}
diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index 95f3b5af..a7010eee 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -102,17 +102,26 @@ export const ThemeProvider: React.FC = ({ 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 = ({ 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') { diff --git a/frontend/src/index.css b/frontend/src/index.css index 2bcd9189..3b4664bc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 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; } diff --git a/frontend/src/types/theme.types.ts b/frontend/src/types/theme.types.ts index a809ddfb..0a817665 100644 --- a/frontend/src/types/theme.types.ts +++ b/frontend/src/types/theme.types.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 } }; \ No newline at end of file diff --git a/frontend/src/utils/__tests__/themeMigration.test.ts b/frontend/src/utils/__tests__/themeMigration.test.ts new file mode 100644 index 00000000..6517b009 --- /dev/null +++ b/frontend/src/utils/__tests__/themeMigration.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/themeMigration.ts b/frontend/src/utils/themeMigration.ts index 8fcda1ac..0ad31ec4 100644 --- a/frontend/src/utils/themeMigration.ts +++ b/frontend/src/utils/themeMigration.ts @@ -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); } /** diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index a3d70ede..75dae6a1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -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',