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

@@ -0,0 +1,9 @@
---
# nuzlocke-tracker-1qzo
title: Fix WCAG AA color contrast violations
status: completed
type: bug
priority: high
created_at: 2026-02-20T19:19:32Z
updated_at: 2026-02-20T19:20:25Z
---

View File

@@ -44,12 +44,16 @@ for (const theme of themes) {
id: v.id,
impact: v.impact,
description: v.description,
nodes: v.nodes.length,
nodes: v.nodes.map((n) => ({
html: n.html,
target: n.target,
failureSummary: n.failureSummary,
})),
}))
expect(
violations,
`${name} (${theme}): ${violations.length} accessibility violations found:\n${JSON.stringify(violations, null, 2)}`,
`${name} (${theme}): ${violations.length} violation(s):\n${JSON.stringify(violations, null, 2)}`,
).toHaveLength(0)
})
}

View File

@@ -43,12 +43,14 @@ const statusOptions: {
{
value: 'caught',
label: 'Caught',
color: 'bg-green-900/40 text-green-300 border-green-700',
color:
'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800 border-green-700 light:border-green-300',
},
{
value: 'fainted',
label: 'Fainted',
color: 'bg-red-900/40 text-red-300 border-red-700',
color:
'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800 border-red-700 light:border-red-300',
},
{
value: 'missed',
@@ -299,7 +301,7 @@ export function EncounterModal({
setSelectedPokemon(pickRandomPokemon(routePokemon, dupedPokemonIds))
}
}}
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"
>
{selectedPokemon ? 'Re-roll' : 'Randomize'}
</button>
@@ -403,14 +405,14 @@ export function EncounterModal({
<EncounterMethodBadge method={rp.encounterMethod} />
)}
{!isDuped && displayRate !== null && displayRate !== undefined && (
<span className="text-[10px] text-purple-400 font-medium">
<span className="text-[10px] text-purple-400 light:text-purple-700 font-medium">
{displayRate}%
</span>
)}
{!isDuped &&
selectedCondition === null &&
conditions.length > 0 && (
<span className="text-[10px] text-purple-400">
<span className="text-[10px] text-purple-400 light:text-purple-700">
{conditions.join(', ')}
</span>
)}
@@ -518,7 +520,7 @@ export function EncounterModal({
onClick={() => setNickname(name)}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
nickname === name
? 'bg-accent-900/40 border-accent-600 text-accent-300'
? 'bg-accent-900/40 border-accent-600 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'border-border-default text-text-secondary hover:border-accent-600 hover:bg-accent-900/20'
}`}
>

View File

@@ -48,7 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
<div className="text-xs text-text-muted mt-0.5">{entry.routeName}</div>
<div className="text-[10px] text-purple-400 mt-0.5 font-medium">
<div className="text-[10px] text-purple-400 light:text-purple-700 mt-0.5 font-medium">
Leg {entry.legOrder} &mdash; {entry.gameName}
</div>

View File

@@ -48,18 +48,18 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
<div
className={`font-medium ${
leg.faintLevel !== null
? 'text-red-300'
? 'text-red-300 light:text-red-700'
: leg.wasTransferred
? 'text-blue-300'
? 'text-blue-300 light:text-blue-700'
: leg.enteredHof
? 'text-yellow-300'
: 'text-green-300'
? 'text-yellow-300 light:text-amber-700'
: 'text-green-300 light:text-green-700'
}`}
>
{label}
</div>
{leg.enteredHof && leg.faintLevel === null && (
<div className="text-yellow-300">Hall of Fame</div>
<div className="text-yellow-300 light:text-amber-700">Hall of Fame</div>
)}
</div>
<div className="w-2 h-2 bg-gray-900 dark:bg-gray-700 rotate-45 -mt-1" />
@@ -156,8 +156,8 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${
lineage.status === 'alive'
? 'bg-green-900/40 text-green-300'
: 'bg-red-900/40 text-red-300'
? 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-800'
: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-800'
}`}
>
{lineage.status === 'alive' ? 'Alive' : 'Dead'}

View File

@@ -9,7 +9,7 @@ interface ShinyBoxProps {
export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) {
return (
<div className="border-2 border-yellow-600 rounded-lg p-4">
<h3 className="text-sm font-semibold text-yellow-400 mb-3 flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-yellow-400 light:text-amber-700 mb-3 flex items-center gap-1.5">
<span>&#10022;</span>
Shiny Box
<span className="text-xs font-normal text-text-muted ml-1">

View File

@@ -110,7 +110,7 @@ export function ShinyEncounterModal({
</svg>
</button>
</div>
<p className="text-sm text-yellow-400 mt-1">
<p className="text-sm text-yellow-400 light:text-amber-700 mt-1">
Shiny catches bypass the one-per-route rule
</p>
</div>

View File

@@ -21,7 +21,9 @@ export function AdminLayout() {
to={item.to}
className={({ isActive }) =>
`block px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap ${
isActive ? 'bg-accent-900/40 text-accent-300' : 'hover:bg-surface-2'
isActive
? 'bg-accent-900/40 text-accent-300 light:bg-accent-100 light:text-accent-700'
: 'hover:bg-surface-2'
}`
}
>

View File

@@ -77,7 +77,7 @@ export function BulkImportModal({
)}
{result && (
<div className="p-3 bg-green-900/30 text-green-300 rounded-md text-sm">
<div className="p-3 bg-green-900/30 text-green-300 light:bg-green-100 light:text-green-800 rounded-md text-sm">
<p>
{createdLabel}: {result.created}, {updatedLabel}: {result.updated}
</p>

View File

@@ -46,8 +46,9 @@
/* Text on dark */
--color-text-primary: #e6edf3;
--color-text-secondary: #7d8590;
--color-text-tertiary: #484f58;
--color-text-secondary: #9198a1;
--color-text-tertiary: #8b949e;
--color-text-muted: #8b949e;
--color-text-link: #7eb0ce;
/* Borders */
@@ -90,7 +91,8 @@ html[data-theme='light'] {
/* Text */
--color-text-primary: #1f2328;
--color-text-secondary: #656d76;
--color-text-tertiary: #8b949e;
--color-text-tertiary: #596069;
--color-text-muted: #596069;
--color-text-link: #1a5068;
/* Borders */
@@ -103,8 +105,8 @@ html[data-theme='light'] {
--color-status-alive-bg: rgba(26, 127, 55, 0.1);
--color-status-dead: #cf222e;
--color-status-dead-bg: rgba(207, 34, 46, 0.1);
--color-status-active: #1a7f37;
--color-status-active-bg: rgba(26, 127, 55, 0.1);
--color-status-active: #116b2b;
--color-status-active-bg: rgba(17, 107, 43, 0.08);
--color-status-completed: #0969da;
--color-status-completed-bg: rgba(9, 105, 218, 0.1);
--color-status-failed: #cf222e;

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)