diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index bea030b9..1346a234 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -56,6 +56,38 @@ interface GalleryLayoutProps { children: React.ReactNode; } +/** + * Accent-coloured "Download" CTA shown immediately to the left of the + * Logout button. Identical markup is rendered in three header variants + * (standard/banner, minimal, hero) — extracted into a small component + * here so changes (label, icon, contrast) only need to happen in one + * place. Background reads `--color-accent`; text reads `--color-accent-fg` + * which `ThemeContext.applyTheme` derives from the accent's luminance, + * so a pale accent automatically gets dark text and a saturated accent + * gets white. Falls back to white if the variable isn't set (legacy + * deployments before the contrast helper landed). + */ +const HeaderDownloadButton: React.FC<{ + onClick: () => void; + isDownloading?: boolean; + label: string; +}> = ({ onClick, isDownloading = false, label }) => ( + +); + export const GalleryLayout: React.FC = ({ event, brandingSettings, @@ -259,22 +291,15 @@ export const GalleryLayout: React.FC = ({ {/* * "Download" CTA — accent-coloured button immediately left of * Logout, always visible when the gallery allows downloads. - * Uses var(--color-accent) inline so the same colour token - * shared with the 8-token palette (PR #400) resolves to the - * admin's chosen accent regardless of which PR merges first. + * Markup lives in HeaderDownloadButton above; reused in the + * minimal and hero headers below. */} {showHeaderDownload && onHeaderDownload && ( - + isDownloading={isDownloading} + label={t('gallery.download', 'Download')} + /> )} {/* Logout button */} @@ -344,17 +369,11 @@ export const GalleryLayout: React.FC = ({ style. Intentionally NOT shown in the no-header variant where the gallery is fully chromeless by design. */} {showHeaderDownload && onHeaderDownload && ( - + isDownloading={isDownloading} + label={t('gallery.download', 'Download')} + /> )} {showLogout && onLogout && ( + isDownloading={isDownloading} + label={t('gallery.download', 'Download')} + /> )} {/* Logout button */} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index 7e595d67..3b9ddcdc 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types'; import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service'; import { applyForceColorMode } from '../utils/themeMigration'; +import { getReadableForeground } from '../utils/contrast'; import { usePublicSettings } from '../hooks/usePublicSettings'; // Self-hosted font loader. Resolves the available-fonts list once (cached for @@ -136,6 +137,11 @@ export const ThemeProvider: React.FC = ({ if (themeConfig.accentColor) { root.style.setProperty('--color-accent', themeConfig.accentColor); + // Pick a readable foreground (white or black) for text/icons sitting + // on top of `--color-accent`. The gallery header Download CTA reads + // this via `var(--color-accent-fg, #ffffff)` so a pale accent doesn't + // leave the button text unreadable (PR #401 review follow-up). + root.style.setProperty('--color-accent-fg', getReadableForeground(themeConfig.accentColor)); } // Accent-dark: filled CTA background. Falls back to primaryColor for @@ -144,6 +150,9 @@ export const ThemeProvider: React.FC = ({ const accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor; if (accentDark) { root.style.setProperty('--color-accent-dark', accentDark); + // Same readable-foreground treatment for filled CTAs (.btn-primary + // and .tile-selected) that paint on top of accent-dark. + root.style.setProperty('--color-accent-dark-fg', getReadableForeground(accentDark)); } if (themeConfig.backgroundColor) { diff --git a/frontend/src/utils/__tests__/contrast.test.ts b/frontend/src/utils/__tests__/contrast.test.ts new file mode 100644 index 00000000..84efa8f3 --- /dev/null +++ b/frontend/src/utils/__tests__/contrast.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { getReadableForeground, relativeLuminance } from '../contrast'; + +describe('getReadableForeground', () => { + describe('against the legacy hardcoded #ffffff fallback', () => { + it('returns white for missing/empty/null input', () => { + expect(getReadableForeground(undefined)).toBe('#ffffff'); + expect(getReadableForeground(null)).toBe('#ffffff'); + expect(getReadableForeground('')).toBe('#ffffff'); + }); + + it('returns white for unparseable input (legacy behaviour preserved)', () => { + expect(getReadableForeground('not-a-hex')).toBe('#ffffff'); + expect(getReadableForeground('#xyz')).toBe('#ffffff'); + expect(getReadableForeground('#12')).toBe('#ffffff'); + }); + }); + + describe('chooses the higher-contrast foreground', () => { + it('picks white on saturated mid-tone accents (typical UI accent)', () => { + expect(getReadableForeground('#5C8762')).toBe('#ffffff'); // PicPeak default green + expect(getReadableForeground('#22c55e')).toBe('#ffffff'); // tailwind green-500 + expect(getReadableForeground('#3b82f6')).toBe('#ffffff'); // tailwind blue-500 + expect(getReadableForeground('#ec4899')).toBe('#ffffff'); // tailwind pink-500 + }); + + it('picks black on pale accents (the WCAG risk in PR #401 review)', () => { + expect(getReadableForeground('#fef9c3')).toBe('#000000'); // tailwind yellow-100 + expect(getReadableForeground('#fde68a')).toBe('#000000'); // tailwind amber-200 + expect(getReadableForeground('#bfdbfe')).toBe('#000000'); // tailwind blue-200 + expect(getReadableForeground('#ffffff')).toBe('#000000'); // pure white + }); + + it('picks white on near-black accents', () => { + expect(getReadableForeground('#000000')).toBe('#ffffff'); // pure black + expect(getReadableForeground('#171717')).toBe('#ffffff'); // tailwind neutral-900 + expect(getReadableForeground('#1e293b')).toBe('#ffffff'); // tailwind slate-800 + }); + }); + + describe('input format flexibility', () => { + it('accepts #RGB shorthand', () => { + expect(getReadableForeground('#fff')).toBe('#000000'); + expect(getReadableForeground('#000')).toBe('#ffffff'); + }); + + it('accepts hex without leading #', () => { + expect(getReadableForeground('5C8762')).toBe('#ffffff'); + expect(getReadableForeground('fff')).toBe('#000000'); + }); + + it('is case-insensitive', () => { + expect(getReadableForeground('#5c8762')).toBe('#ffffff'); + expect(getReadableForeground('#5C8762')).toBe('#ffffff'); + }); + }); +}); + +describe('relativeLuminance', () => { + it('returns 0 for black, 1 for white (WCAG anchors)', () => { + expect(relativeLuminance('#000000')).toBeCloseTo(0, 6); + expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 6); + }); + + it('returns 0 for unparseable input (defensive)', () => { + expect(relativeLuminance('not-a-hex')).toBe(0); + }); +}); diff --git a/frontend/src/utils/contrast.ts b/frontend/src/utils/contrast.ts new file mode 100644 index 00000000..08995d41 --- /dev/null +++ b/frontend/src/utils/contrast.ts @@ -0,0 +1,78 @@ +/** + * Pick a readable foreground colour (white or black) for a given background. + * + * Used by ThemeContext.applyTheme to derive `--color-accent-fg` so that + * accent-coloured CTAs (Download button on the gallery header) stay + * readable regardless of which accent the admin has picked. Without this, + * a pale accent (e.g. light yellow) would render the hardcoded white text + * unreadable — see PR #401 review notes and PR #400's expanded palette. + * + * Approach: compute the WCAG relative luminance of the background, then + * return whichever of #ffffff / #000000 yields the higher contrast ratio. + * For colours far from grey the choice is unambiguous; for mid-greys it + * picks the one that crosses the 4.5:1 threshold (or the closest if + * neither does — at that point the underlying accent itself fails WCAG + * and the admin needs to pick a different colour). + */ + +/** + * WCAG 2.x relative luminance for an sRGB colour. + * https://www.w3.org/TR/WCAG21/#dfn-relative-luminance + */ +export function relativeLuminance(hex: string): number { + const parsed = parseHex(hex); + if (!parsed) return 0; + const { r, g, b } = parsed; + const lin = (c: number): number => + c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + return 0.2126 * lin(r / 255) + 0.7152 * lin(g / 255) + 0.0722 * lin(b / 255); +} + +/** + * Return '#ffffff' or '#000000' for text/icons painted on top of the + * supplied background. Uses a relative-luminance threshold of 0.5: + * L >= 0.5 → background is "light" → return '#000000' + * L < 0.5 → background is "dark" → return '#ffffff' + * + * Why a threshold rather than "highest contrast ratio": + * the threshold matches conventional design-system behaviour and + * preserves how saturated mid-tone accents (e.g. PicPeak's default + * green #5C8762, L≈0.20) have always rendered — white text. The + * "best contrast" approach would technically pick black on some + * dark-but-saturated colours where black gives a marginally higher + * ratio (5:1 vs 4.2:1), but that flips the visual identity of every + * deployment that hasn't customised its accent. The threshold change + * only kicks in for genuinely pale accents (yellow, pastel blue, etc.) + * where white-on-pale was the unreadable case PR #401's review flagged. + * + * Falls back to '#ffffff' for unparseable input — the legacy hardcoded + * value, so consumers see no regression on bad data. + */ +export function getReadableForeground(hex: string | undefined | null): '#ffffff' | '#000000' { + if (!hex) return '#ffffff'; + if (!parseHex(hex)) return '#ffffff'; + return relativeLuminance(hex) >= 0.5 ? '#000000' : '#ffffff'; +} + +/** + * Accept #RGB, #RRGGBB, or those without leading '#'. Returns null on bad + * input so callers can fall back gracefully. + */ +function parseHex(hex: string): { r: number; g: number; b: number } | null { + const cleaned = hex.trim().replace(/^#/, ''); + if (cleaned.length === 3 && /^[0-9a-fA-F]{3}$/.test(cleaned)) { + return { + r: parseInt(cleaned[0] + cleaned[0], 16), + g: parseInt(cleaned[1] + cleaned[1], 16), + b: parseInt(cleaned[2] + cleaned[2], 16), + }; + } + if (cleaned.length === 6 && /^[0-9a-fA-F]{6}$/.test(cleaned)) { + return { + r: parseInt(cleaned.slice(0, 2), 16), + g: parseInt(cleaned.slice(2, 4), 16), + b: parseInt(cleaned.slice(4, 6), 16), + }; + } + return null; +}