diff --git a/.beans/nuzlocke-tracker-1qzo--fix-wcag-aa-color-contrast-violations.md b/.beans/nuzlocke-tracker-1qzo--fix-wcag-aa-color-contrast-violations.md new file mode 100644 index 0000000..0e46fd9 --- /dev/null +++ b/.beans/nuzlocke-tracker-1qzo--fix-wcag-aa-color-contrast-violations.md @@ -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 +--- diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 889489c..1f922ac 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -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) }) } diff --git a/frontend/src/components/EncounterModal.tsx b/frontend/src/components/EncounterModal.tsx index f65ba02..7b17f07 100644 --- a/frontend/src/components/EncounterModal.tsx +++ b/frontend/src/components/EncounterModal.tsx @@ -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'} @@ -403,14 +405,14 @@ export function EncounterModal({ )} {!isDuped && displayRate !== null && displayRate !== undefined && ( - + {displayRate}% )} {!isDuped && selectedCondition === null && conditions.length > 0 && ( - + {conditions.join(', ')} )} @@ -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' }`} > diff --git a/frontend/src/components/GenlockeGraveyard.tsx b/frontend/src/components/GenlockeGraveyard.tsx index e3133b7..a30b125 100644 --- a/frontend/src/components/GenlockeGraveyard.tsx +++ b/frontend/src/components/GenlockeGraveyard.tsx @@ -48,7 +48,7 @@ function GraveyardCard({ entry }: { entry: GraveyardEntry }) {
{entry.routeName}
-
+
Leg {entry.legOrder} — {entry.gameName}
diff --git a/frontend/src/components/GenlockeLineage.tsx b/frontend/src/components/GenlockeLineage.tsx index e2807ad..00c513d 100644 --- a/frontend/src/components/GenlockeLineage.tsx +++ b/frontend/src/components/GenlockeLineage.tsx @@ -48,18 +48,18 @@ function LegDot({ leg }: { leg: LineageLegEntry }) {
{label}
{leg.enteredHof && leg.faintLevel === null && ( -
Hall of Fame
+
Hall of Fame
)}
@@ -156,8 +156,8 @@ function LineageCard({ lineage, allLegOrders }: { lineage: LineageEntry; allLegO {lineage.status === 'alive' ? 'Alive' : 'Dead'} diff --git a/frontend/src/components/ShinyBox.tsx b/frontend/src/components/ShinyBox.tsx index 0c3dc5a..10adcc4 100644 --- a/frontend/src/components/ShinyBox.tsx +++ b/frontend/src/components/ShinyBox.tsx @@ -9,7 +9,7 @@ interface ShinyBoxProps { export function ShinyBox({ encounters, onEncounterClick }: ShinyBoxProps) { return (
-

+

Shiny Box diff --git a/frontend/src/components/ShinyEncounterModal.tsx b/frontend/src/components/ShinyEncounterModal.tsx index 97e95b0..82ef0f9 100644 --- a/frontend/src/components/ShinyEncounterModal.tsx +++ b/frontend/src/components/ShinyEncounterModal.tsx @@ -110,7 +110,7 @@ export function ShinyEncounterModal({

-

+

Shiny catches bypass the one-per-route rule

diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index ceeb7f1..7af6513 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -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' }` } > diff --git a/frontend/src/components/admin/BulkImportModal.tsx b/frontend/src/components/admin/BulkImportModal.tsx index 2336613..8a7d07f 100644 --- a/frontend/src/components/admin/BulkImportModal.tsx +++ b/frontend/src/components/admin/BulkImportModal.tsx @@ -77,7 +77,7 @@ export function BulkImportModal({ )} {result && ( -
+

{createdLabel}: {result.created}, {updatedLabel}: {result.updated}

diff --git a/frontend/src/index.css b/frontend/src/index.css index 436f3af..871984f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/pages/GenlockeDetail.tsx b/frontend/src/pages/GenlockeDetail.tsx index 22c71ac..1f1372a 100644 --- a/frontend/src/pages/GenlockeDetail.tsx +++ b/frontend/src/pages/GenlockeDetail.tsx @@ -18,9 +18,9 @@ const statusRing: Record = { } const statusStyles: Record = { - 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 diff --git a/frontend/src/pages/GenlockeList.tsx b/frontend/src/pages/GenlockeList.tsx index 9cda515..109b1da 100644 --- a/frontend/src/pages/GenlockeList.tsx +++ b/frontend/src/pages/GenlockeList.tsx @@ -3,9 +3,9 @@ import { useGenlockes } from '../hooks/useGenlockes' import type { RunStatus } from '../types' const statusStyles: Record = { - 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() { diff --git a/frontend/src/pages/RunDashboard.tsx b/frontend/src/pages/RunDashboard.tsx index 30195ef..8ee30ec 100644 --- a/frontend/src/pages/RunDashboard.tsx +++ b/frontend/src/pages/RunDashboard.tsx @@ -31,9 +31,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun } const statusStyles: Record = { - 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) { diff --git a/frontend/src/pages/RunEncounters.tsx b/frontend/src/pages/RunEncounters.tsx index 87113fb..ff9577d 100644 --- a/frontend/src/pages/RunEncounters.tsx +++ b/frontend/src/pages/RunEncounters.tsx @@ -59,9 +59,9 @@ function sortEncounters(encounters: EncounterDetail[], key: TeamSortKey): Encoun } const statusStyles: Record = { - 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() { })}

{run.genlocke && ( -

+

Leg {run.genlocke.legOrder} of {run.genlocke.totalLegs} —{' '} {run.genlocke.genlockeName}

@@ -811,7 +811,7 @@ export function RunEncounters() { {isActive && run.rules?.shinyClause && ( @@ -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'} @@ -1358,7 +1358,7 @@ export function RunEncounters() {
e.stopPropagation()}> {isDefeated ? ( - + Defeated ✓ ) : isActive ? ( diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index 16062da..05ca24c 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -4,24 +4,24 @@ import { StatCard } from '../components' import type { PokemonRanking, StatsResponse } from '../types/stats' const typeBarColors: Record = { - 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 (
{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'} /> ))}
@@ -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'} /> ))}
diff --git a/frontend/src/pages/admin/AdminEvolutions.tsx b/frontend/src/pages/admin/AdminEvolutions.tsx index 902280d..75fa106 100644 --- a/frontend/src/pages/admin/AdminEvolutions.tsx +++ b/frontend/src/pages/admin/AdminEvolutions.tsx @@ -75,9 +75,9 @@ export function AdminEvolutions() { return (
-
+

Evolutions

-
+
-
+
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() { ))} { setTypeFilter(e.target.value)