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 }) => (
+
+
+ {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 && (
-
-
- {t('gallery.download', 'Download')}
-
+ 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 && (
-
-
- {t('gallery.download', 'Download')}
-
+ isDownloading={isDownloading}
+ label={t('gallery.download', 'Download')}
+ />
)}
{showLogout && onLogout && (
= ({
Intentionally NOT shown in the no-header variant where
the gallery is fully chromeless by design. */}
{showHeaderDownload && onHeaderDownload && (
-
-
- {t('gallery.download', 'Download')}
-
+ 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;
+}