Fix WCAG AA color contrast violations across all pages
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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} —{' '}
|
||||
{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"
|
||||
>
|
||||
✦ 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 ✓
|
||||
</span>
|
||||
) : isActive ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user