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
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|