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:
@@ -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 }) => (
|
||||
<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> = ({
|
||||
event,
|
||||
brandingSettings,
|
||||
@@ -259,22 +291,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{/*
|
||||
* "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 && (
|
||||
<button
|
||||
type="button"
|
||||
<HeaderDownloadButton
|
||||
onClick={onHeaderDownload}
|
||||
disabled={isDownloading}
|
||||
aria-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>
|
||||
isDownloading={isDownloading}
|
||||
label={t('gallery.download', 'Download')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
@@ -344,17 +369,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
style. Intentionally NOT shown in the no-header variant
|
||||
where the gallery is fully chromeless by design. */}
|
||||
{showHeaderDownload && onHeaderDownload && (
|
||||
<button
|
||||
type="button"
|
||||
<HeaderDownloadButton
|
||||
onClick={onHeaderDownload}
|
||||
disabled={isDownloading}
|
||||
aria-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>
|
||||
isDownloading={isDownloading}
|
||||
label={t('gallery.download', 'Download')}
|
||||
/>
|
||||
)}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
@@ -441,17 +460,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
Intentionally NOT shown in the no-header variant where
|
||||
the gallery is fully chromeless by design. */}
|
||||
{showHeaderDownload && onHeaderDownload && (
|
||||
<button
|
||||
type="button"
|
||||
<HeaderDownloadButton
|
||||
onClick={onHeaderDownload}
|
||||
disabled={isDownloading}
|
||||
aria-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>
|
||||
isDownloading={isDownloading}
|
||||
label={t('gallery.download', 'Download')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
|
||||
@@ -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<ThemeProviderProps> = ({
|
||||
|
||||
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<ThemeProviderProps> = ({
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user