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:
Paul Nothaft
2026-05-07 11:42:25 +02:00
parent 183e3117b6
commit 0c80abd57b
4 changed files with 201 additions and 33 deletions
+9
View File
@@ -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) {