Merge pull request #405 from the-luap/fix/gallery-download-cta-followup

fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
This commit is contained in:
Paul Nothaft
2026-05-07 12:35:46 +02:00
committed by GitHub
4 changed files with 201 additions and 33 deletions
@@ -56,6 +56,38 @@ interface GalleryLayoutProps {
children: React.ReactNode; 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 }) => (
<button
type="button"
onClick={onClick}
disabled={isDownloading}
aria-label={label}
className="gallery-btn gallery-btn-download inline-flex items-center gap-2 px-3 sm:px-4 h-9 rounded-lg text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-accent)',
color: 'var(--color-accent-fg, #ffffff)',
}}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
</button>
);
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
event, event,
brandingSettings, brandingSettings,
@@ -259,22 +291,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* {/*
* "Download" CTA — accent-coloured button immediately left of * "Download" CTA — accent-coloured button immediately left of
* Logout, always visible when the gallery allows downloads. * Logout, always visible when the gallery allows downloads.
* Uses var(--color-accent) inline so the same colour token * Markup lives in HeaderDownloadButton above; reused in the
* shared with the 8-token palette (PR #400) resolves to the * minimal and hero headers below.
* admin's chosen accent regardless of which PR merges first.
*/} */}
{showHeaderDownload && onHeaderDownload && ( {showHeaderDownload && onHeaderDownload && (
<button <HeaderDownloadButton
type="button"
onClick={onHeaderDownload} onClick={onHeaderDownload}
disabled={isDownloading} isDownloading={isDownloading}
aria-label={t('gallery.download', 'Download')} label={t('gallery.download', 'Download')}
className="gallery-btn gallery-btn-download inline-flex items-center gap-2 px-3 sm:px-4 h-9 rounded-lg text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" />
style={{ backgroundColor: 'var(--color-accent)', color: '#ffffff' }}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('gallery.download', 'Download')}</span>
</button>
)} )}
{/* Logout button */} {/* Logout button */}
@@ -344,17 +369,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
style. Intentionally NOT shown in the no-header variant style. Intentionally NOT shown in the no-header variant
where the gallery is fully chromeless by design. */} where the gallery is fully chromeless by design. */}
{showHeaderDownload && onHeaderDownload && ( {showHeaderDownload && onHeaderDownload && (
<button <HeaderDownloadButton
type="button"
onClick={onHeaderDownload} onClick={onHeaderDownload}
disabled={isDownloading} isDownloading={isDownloading}
aria-label={t('gallery.download', 'Download')} label={t('gallery.download', 'Download')}
className="gallery-btn gallery-btn-download inline-flex items-center gap-2 px-3 sm:px-4 h-9 rounded-lg text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" />
style={{ backgroundColor: 'var(--color-accent)', color: '#ffffff' }}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('gallery.download', 'Download')}</span>
</button>
)} )}
{showLogout && onLogout && ( {showLogout && onLogout && (
<Button <Button
@@ -441,17 +460,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
Intentionally NOT shown in the no-header variant where Intentionally NOT shown in the no-header variant where
the gallery is fully chromeless by design. */} the gallery is fully chromeless by design. */}
{showHeaderDownload && onHeaderDownload && ( {showHeaderDownload && onHeaderDownload && (
<button <HeaderDownloadButton
type="button"
onClick={onHeaderDownload} onClick={onHeaderDownload}
disabled={isDownloading} isDownloading={isDownloading}
aria-label={t('gallery.download', 'Download')} label={t('gallery.download', 'Download')}
className="gallery-btn gallery-btn-download inline-flex items-center gap-2 px-3 sm:px-4 h-9 rounded-lg text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" />
style={{ backgroundColor: 'var(--color-accent)', color: '#ffffff' }}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('gallery.download', 'Download')}</span>
</button>
)} )}
{/* Logout button */} {/* Logout button */}
+9
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types'; import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service'; import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service';
import { applyForceColorMode } from '../utils/themeMigration'; import { applyForceColorMode } from '../utils/themeMigration';
import { getReadableForeground } from '../utils/contrast';
import { usePublicSettings } from '../hooks/usePublicSettings'; import { usePublicSettings } from '../hooks/usePublicSettings';
// Self-hosted font loader. Resolves the available-fonts list once (cached for // Self-hosted font loader. Resolves the available-fonts list once (cached for
@@ -136,6 +137,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
if (themeConfig.accentColor) { if (themeConfig.accentColor) {
root.style.setProperty('--color-accent', 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 // Accent-dark: filled CTA background. Falls back to primaryColor for
@@ -144,6 +150,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
const accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor; const accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor;
if (accentDark) { if (accentDark) {
root.style.setProperty('--color-accent-dark', 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) { if (themeConfig.backgroundColor) {
@@ -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);
});
});
+78
View File
@@ -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;
}