Fix WCAG AA color contrast violations across all pages
All checks were successful
CI / backend-lint (push) Successful in 9s
CI / actions-lint (push) Successful in 15s
CI / frontend-lint (push) Successful in 21s

Replace incorrect perceived-brightness formula in Stats progress bars
with proper WCAG relative luminance calculation, and convert type bar
colors to hex values for reliable contrast detection. Add light: variant
classes to status badges, yellow/purple text, and admin nav links across
17 files. Darken light-mode status-active token and text-tertiary/muted
tokens. Add aria-labels to admin filter selects and flex-wrap for mobile
overflow on AdminEvolutions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 20:48:16 +01:00
parent a12478f24b
commit 4fbfcf9b29
18 changed files with 109 additions and 80 deletions

View File

@@ -18,9 +18,9 @@ const statusRing: Record<RunStatus, string> = {
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300',
completed: 'bg-blue-900/40 text-blue-300',
failed: 'bg-red-900/40 text-red-300',
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
}
function LegIndicator({ leg }: { leg: GenlockeLegDetail }) {
@@ -270,7 +270,7 @@ export function GenlockeDetail() {
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
showGraveyard
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-surface-3 text-text-secondary hover:bg-surface-4'
: 'bg-surface-3 text-text-secondary light:text-text-primary hover:bg-surface-4'
}`}
>
Graveyard
@@ -280,7 +280,7 @@ export function GenlockeDetail() {
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
showLineage
? 'bg-accent-600 text-white hover:bg-accent-500'
: 'bg-surface-3 text-text-secondary hover:bg-surface-4'
: 'bg-surface-3 text-text-secondary light:text-text-primary hover:bg-surface-4'
}`}
>
Lineage

View File

@@ -3,9 +3,9 @@ import { useGenlockes } from '../hooks/useGenlockes'
import type { RunStatus } from '../types'
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300',
completed: 'bg-blue-900/40 text-blue-300',
failed: 'bg-red-900/40 text-red-300',
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
}
export function GenlockeList() {

View File

@@ -31,9 +31,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300',
completed: 'bg-blue-900/40 text-blue-300',
failed: 'bg-red-900/40 text-red-300',
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
}
function formatDuration(start: string, end: string) {

View File

@@ -59,9 +59,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun
}
const statusStyles: Record<RunStatus, string> = {
active: 'bg-green-900/40 text-green-300',
completed: 'bg-blue-900/40 text-blue-300',
failed: 'bg-red-900/40 text-red-300',
active: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800',
completed: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-800',
failed: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800',
}
function formatDuration(start: string, end: string) {
@@ -801,7 +801,7 @@ export function RunEncounters() {
})}
</p>
{run.genlocke && (
<p className="text-sm text-purple-400 mt-1 font-medium">
<p className="text-sm text-purple-400 light:text-purple-700 mt-1 font-medium">
Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} &mdash;{' '}
{run.genlocke.genlockeName}
</p>
@@ -811,7 +811,7 @@ export function RunEncounters() {
{isActive && run.rules?.shinyClause && (
<button
onClick={() => setShowShinyModal(true)}
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 rounded-full font-medium hover:bg-yellow-900/20 transition-colors"
className="px-3 py-1 text-sm border border-yellow-600 text-yellow-400 light:text-amber-700 light:border-amber-600 rounded-full font-medium hover:bg-yellow-900/20 light:hover:bg-amber-50 transition-colors"
>
&#10022; Log Shiny
</button>
@@ -1153,7 +1153,7 @@ export function RunEncounters() {
bulkRandomize.mutate()
}
}}
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 hover:bg-purple-900/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="px-2.5 py-1 text-xs font-medium rounded-lg border border-purple-600 text-purple-400 light:text-purple-700 light:border-purple-500 hover:bg-purple-900/20 light:hover:bg-purple-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{bulkRandomize.isPending ? 'Randomizing...' : 'Randomize All'}
</button>
@@ -1358,7 +1358,7 @@ export function RunEncounters() {
</div>
<div onClick={(e) => e.stopPropagation()}>
{isDefeated ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300">
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800">
Defeated &#10003;
</span>
) : isActive ? (

View File

@@ -4,24 +4,24 @@ import { StatCard } from '../components'
import type { PokemonRanking, StatsResponse } from '../types/stats'
const typeBarColors: Record<string, string> = {
normal: 'bg-gray-400',
fire: 'bg-red-500',
water: 'bg-blue-500',
electric: 'bg-yellow-400',
grass: 'bg-green-500',
ice: 'bg-cyan-300',
fighting: 'bg-red-700',
poison: 'bg-purple-500',
ground: 'bg-amber-600',
flying: 'bg-indigo-300',
psychic: 'bg-pink-500',
bug: 'bg-lime-500',
rock: 'bg-amber-700',
ghost: 'bg-purple-700',
dragon: 'bg-indigo-600',
dark: 'bg-gray-700',
steel: 'bg-gray-400',
fairy: 'bg-pink-300',
normal: '#9ca3af',
fire: '#ef4444',
water: '#3b82f6',
electric: '#facc15',
grass: '#22c55e',
ice: '#67e8f9',
fighting: '#b91c1c',
poison: '#a855f7',
ground: '#d97706',
flying: '#a5b4fc',
psychic: '#ec4899',
bug: '#84cc16',
rock: '#b45309',
ghost: '#7e22ce',
dragon: '#4f46e5',
dark: '#374151',
steel: '#9ca3af',
fairy: '#f9a8d4',
}
function fmt(value: number | null, suffix = ''): string {
@@ -74,44 +74,50 @@ function PokemonList({ title, pokemon }: { title: string; pokemon: PokemonRankin
)
}
function hexLuminance(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
function srgbLuminance(hex: string): number {
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const r = toLinear(parseInt(hex.slice(1, 3), 16) / 255)
const g = toLinear(parseInt(hex.slice(3, 5), 16) / 255)
const b = toLinear(parseInt(hex.slice(5, 7), 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function shouldUseDarkText(bgHex: string): boolean {
const bgL = srgbLuminance(bgHex)
const whiteContrast = 1.05 / (bgL + 0.05)
const blackContrast = (bgL + 0.05) / 0.05
return blackContrast > whiteContrast
}
function HorizontalBar({
label,
value,
max,
color,
colorHex,
}: {
label: string
value: number
max: number
color?: string
colorHex?: string
colorHex: string
}) {
const width = max > 0 ? (value / max) * 100 : 0
const isLight = colorHex ? hexLuminance(colorHex) > 0.55 : false
const useDark = shouldUseDarkText(colorHex)
return (
<div className="flex items-center gap-2 text-sm">
<div className="flex-1 bg-surface-2 rounded-full h-6 overflow-hidden relative">
<div
className={`h-full rounded-full ${color ?? ''}`}
className="h-full rounded-full"
style={{
width: `${Math.max(width, 1)}%`,
...(colorHex ? { backgroundColor: colorHex } : {}),
backgroundColor: colorHex,
}}
/>
<span
className={`absolute inset-0 flex items-center px-3 text-xs font-medium capitalize truncate ${
isLight ? 'text-gray-900 dark:text-gray-900' : 'text-text-primary'
useDark ? 'text-gray-900' : 'text-white'
}`}
style={{
textShadow: isLight ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.3)',
textShadow: useDark ? '0 0 4px rgba(255,255,255,0.8)' : '0 0 4px rgba(0,0,0,0.5)',
}}
>
{label}
@@ -166,7 +172,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
label={g.gameName}
value={g.count}
max={gameMax}
{...(g.gameColor ? { colorHex: g.gameColor } : { color: 'bg-blue-500' })}
colorHex={g.gameColor ?? '#3b82f6'}
/>
))}
</div>
@@ -258,7 +264,7 @@ function StatsContent({ stats }: { stats: StatsResponse }) {
label={t.type}
value={t.count}
max={typeMax}
color={typeBarColors[t.type] ?? 'bg-gray-500'}
colorHex={typeBarColors[t.type] ?? '#6b7280'}
/>
))}
</div>

View File

@@ -75,9 +75,9 @@ export function AdminEvolutions() {
return (
<div>
<div className="flex justify-between items-center mb-4">
<div className="flex flex-wrap justify-between items-center gap-2 mb-4">
<h2 className="text-xl font-semibold">Evolutions</h2>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
onClick={async () => {
const data = await exportEvolutions()
@@ -102,7 +102,7 @@ export function AdminEvolutions() {
</div>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="mb-4 flex flex-wrap items-center gap-4">
<input
type="text"
value={search}
@@ -114,6 +114,7 @@ export function AdminEvolutions() {
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/>
<select
aria-label="Filter by trigger"
value={triggerFilter}
onChange={(e) => {
setTriggerFilter(e.target.value)

View File

@@ -70,8 +70,9 @@ export function AdminGames() {
</div>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="mb-4 flex flex-wrap items-center gap-4">
<select
aria-label="Filter by region"
value={regionFilter}
onChange={(e) => setRegionFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"
@@ -84,6 +85,7 @@ export function AdminGames() {
))}
</select>
<select
aria-label="Filter by generation"
value={genFilter}
onChange={(e) => setGenFilter(e.target.value)}
className="px-3 py-2 border rounded-md bg-surface-2 border-border-default"

View File

@@ -116,6 +116,7 @@ export function AdminPokemon() {
className="w-full max-w-sm px-3 py-2 border rounded-md bg-surface-2 border-border-default"
/>
<select
aria-label="Filter by type"
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value)