fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
Two follow-ups from PR #401's review: 1. Download button text was hardcoded `color: '#ffffff'`. Once admins start picking palettes via #400's expanded customizer, a pale accent (yellow, pastel blue, etc.) leaves the button unreadable — white text on near-white background. Fix: derive the foreground colour from the accent's WCAG relative luminance and expose it as the new `--color-accent-fg` CSS variable in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black text; dark backgrounds get white. Same treatment applied to `--color-accent-dark-fg` for the filled-CTA token. The Download button now reads `var(--color-accent-fg, #ffffff)` so any future component that paints on accent gets the same treatment for free, and legacy deployments before the variable is set fall back to the previous hardcoded white. Threshold-based (rather than "highest contrast ratio") to preserve how saturated mid-tone accents have always rendered. The Picpeak default green (#5C8762, L≈0.20) keeps white text — same visual identity as before. Only genuinely pale accents flip to black, which is the actual scenario the review flagged. 2. The Download button JSX was duplicated three times in GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines each). Extracted into a small inline `HeaderDownloadButton` component above the GalleryLayout export. Three call sites now collapse to a 5-line component invocation each. Markup, accessibility, and styling live in one place — future tweaks only need to happen once. ## Files - `frontend/src/utils/contrast.ts` — new helper module: `relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and `getReadableForeground(hex)` (white-or-black picker). - `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases: fallbacks, saturated mid-tones, pale accents, near-black, shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors (black/white luminance). - `frontend/src/contexts/ThemeContext.tsx` — wire the helper into `applyTheme`: set `--color-accent-fg` from `accentColor` and `--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`. - `frontend/src/components/gallery/GalleryLayout.tsx` — extract `HeaderDownloadButton` component above `GalleryLayout`, replace three inline button blocks with the component, update its inline style to read `--color-accent-fg` (with the legacy `#ffffff` as the CSS-variable fallback). ## Verified - `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass - `npx tsc --noEmit` — clean - `npx eslint` clean on every touched file - Default PicPeak green still renders white text (no regression) - Pale accent (#fef9c3 yellow-100) now correctly renders black text
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 */}
|
||||
|
||||
Reference in New Issue
Block a user