Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/compare/CompareClient.a11y-keyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import CompareClient from './CompareClient';

vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));

vi.mock('@/hooks/useRecentSearches', () => ({
useRecentSearches: () => ({
searches: ['octocat vs dinesh9997'],
addSearch: vi.fn(),
removeSearch: vi.fn(),
clearSearches: vi.fn(),
}),
}));

describe('CompareClient Keyboard Navigation & ARIA Accessibility', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders input fields with accessible ARIA labels', () => {
render(<CompareClient />);

const user1Input = screen.getByLabelText(/enter first github username to compare/i);
const user2Input = screen.getByLabelText(/enter second github username to compare/i);

expect(user1Input).toBeInTheDocument();
expect(user2Input).toBeInTheDocument();
});

it('renders submit compare button with proper aria-label', () => {
render(<CompareClient />);

const compareBtn = screen.getByRole('button', { name: /compare two github profiles/i });

expect(compareBtn).toBeInTheDocument();
});

it('includes focus-visible class names on interactive controls', () => {
render(<CompareClient />);

const user1Input = screen.getByLabelText(/enter first github username to compare/i);
const compareBtn = screen.getByRole('button', { name: /compare two github profiles/i });

expect(user1Input.className).toContain('focus-visible:ring-2');
expect(compareBtn.className).toContain('focus-visible:ring-2');
});

it('renders recent comparison history tags with accessible remove buttons', () => {
render(<CompareClient />);

const historyBtn = screen.getByRole('button', { name: 'octocat vs dinesh9997' });
const removeBtn = screen.getByRole('button', {
name: /remove octocat vs dinesh9997 from recent comparisions/i,
});

expect(historyBtn).toBeInTheDocument();
expect(removeBtn).toBeInTheDocument();
});
});
25 changes: 14 additions & 11 deletions app/compare/CompareClient.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';

import { copyToClipboard } from '@/utils/clipboard';
import Image from 'next/image';
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import TopRivalriesTicker from '@/components/TopRivalriesTicker';
Expand Down Expand Up @@ -1308,7 +1307,7 @@ export default function CompareClient() {
setUser1Error('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleCompare(user1, user2)}
className="w-full pl-10 pr-4 py-3 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.1)] bg-white dark:bg-[#0a0a0a] text-gray-900 dark:text-white text-sm placeholder:text-[#A1A1AA] focus:outline-none focus:border-emerald-500/50 transition-colors"
className="w-full pl-10 pr-4 py-3 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.1)] bg-white dark:bg-[#0a0a0a] text-gray-900 dark:text-white text-sm placeholder:text-[#A1A1AA] focus:outline-none focus:border-emerald-500/50 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors"
/>

{user1Error && (
Expand Down Expand Up @@ -1337,7 +1336,7 @@ export default function CompareClient() {
setUser2Error('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleCompare(user1, user2)}
className="w-full pl-10 pr-4 py-3 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.1)] bg-white dark:bg-[#0a0a0a] text-gray-900 dark:text-white text-sm placeholder:text-[#A1A1AA] focus:outline-none focus:border-emerald-500/50 transition-colors"
className="w-full pl-10 pr-4 py-3 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.1)] bg-white dark:bg-[#0a0a0a] text-gray-900 dark:text-white text-sm placeholder:text-[#A1A1AA] focus:outline-none focus:border-emerald-500/50 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors"
/>

{user2Error && (
Expand All @@ -1352,7 +1351,7 @@ export default function CompareClient() {
onClick={() => handleCompare(user1, user2)}
disabled={loading}
aria-label="Compare two GitHub profiles"
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-black dark:bg-white text-white dark:text-black text-sm font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-black dark:bg-white text-white dark:text-black text-sm font-semibold hover:bg-zinc-800 dark:hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Swords size={16} />}
{loading ? 'Comparing...' : 'Compare'}
Expand All @@ -1370,7 +1369,7 @@ export default function CompareClient() {

<button
onClick={clearSearches}
className="text-xs text-red-500 hover:text-red-600 transition-colors"
className="text-xs text-red-500 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:outline-none rounded px-1 transition-colors"
>
Clear All
</button>
Expand All @@ -1394,14 +1393,14 @@ export default function CompareClient() {
handleCompare(left, right);
}
}}
className="text-xs text-gray-700 dark:text-gray-300 hover:text-emerald-500 transition-colors"
className="text-xs text-gray-700 dark:text-gray-300 hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none rounded px-1 transition-colors"
>
{search}
</button>

<button
onClick={() => removeSearch(search)}
className="text-xs text-red-400 hover:text-red-600"
className="text-xs text-red-400 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:outline-none rounded px-0.5"
aria-label={`Remove ${search} from recent comparisions`}
>
Comment on lines 1401 to 1405
×
Expand Down Expand Up @@ -1646,7 +1645,8 @@ export default function CompareClient() {
whileTap={{ scale: 0.95 }}
onClick={handleShareBattle}
title="Copy battle link"
className="flex items-center gap-2 px-5 py-4 rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-md border border-white/20 text-white font-bold shadow-[0_0_20px_rgba(255,255,255,0.1)] transition-colors"
aria-label="Copy battle link to clipboard"
className="flex items-center gap-2 px-5 py-4 rounded-full bg-white/10 hover:bg-white/20 backdrop-blur-md border border-white/20 text-white font-bold shadow-[0_0_20px_rgba(255,255,255,0.1)] focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors"
>
{copied ? (
<>
Expand Down Expand Up @@ -1693,7 +1693,8 @@ export default function CompareClient() {
whileTap={{ scale: 0.95 }}
onClick={handleTwitterShare}
title="Share on X (Twitter)"
className="flex items-center gap-2 px-5 py-4 rounded-full bg-black hover:bg-zinc-800 backdrop-blur-md border border-white/20 text-white font-bold shadow-[0_0_20px_rgba(0,0,0,0.4)] transition-colors"
aria-label="Share comparison battle on X"
className="flex items-center gap-2 px-5 py-4 rounded-full bg-black hover:bg-zinc-800 backdrop-blur-md border border-white/20 text-white font-bold shadow-[0_0_20px_rgba(0,0,0,0.4)] focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors"
>
{/* X logo */}
<svg
Expand All @@ -1714,7 +1715,8 @@ export default function CompareClient() {
whileTap={{ scale: 0.95 }}
onClick={handleLinkedInShare}
title="Share on LinkedIn"
className="flex items-center gap-2 px-5 py-4 rounded-full bg-[#0A66C2] hover:bg-[#0958a8] backdrop-blur-md border border-[#0A66C2]/50 text-white font-bold shadow-[0_0_20px_rgba(10,102,194,0.4)] transition-colors"
aria-label="Share comparison battle on LinkedIn"
className="flex items-center gap-2 px-5 py-4 rounded-full bg-[#0A66C2] hover:bg-[#0958a8] backdrop-blur-md border border-[#0A66C2]/50 text-white font-bold shadow-[0_0_20px_rgba(10,102,194,0.4)] focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors"
>
{/* LinkedIn logo */}
<svg
Expand All @@ -1735,7 +1737,8 @@ export default function CompareClient() {
whileTap={{ scale: 0.95 }}
onClick={handleDownloadCard}
disabled={isExporting}
className="flex items-center gap-3 px-6 py-4 rounded-full bg-emerald-500 hover:bg-emerald-400 text-black font-bold shadow-[0_0_30px_rgba(16,185,129,0.5)] transition-colors overflow-hidden relative group"
aria-label="Export comparison wrapped card image"
className="flex items-center gap-3 px-6 py-4 rounded-full bg-emerald-500 hover:bg-emerald-400 text-black font-bold shadow-[0_0_30px_rgba(16,185,129,0.5)] focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:outline-none transition-colors overflow-hidden relative group"
>
<motion.div
animate={isExporting ? { rotate: 360 } : {}}
Expand Down
50 changes: 26 additions & 24 deletions components/InteractiveViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default function InteractiveViewer({
// Stable particle list — generated once on mount, never re-shuffled.
const particles = useMemo((): ParallaxParticle[] => buildParticles(), []);

// ── Parallax math ─────────────────────────────────────────────────────────
// ── Parallax math ─────────────────────────────────────────────────────────
// Offset from center: at mousePos.x = 0.5, offset = 0 (no shift).
// At the left edge (0), offset = -STRENGTH/2; at right (1), offset = +STRENGTH/2.
const hoverParallaxX = (mousePos.x - 0.5) * PARALLAX_STRENGTH;
Expand Down Expand Up @@ -399,29 +399,31 @@ export default function InteractiveViewer({
Each particle shifts by (parallaxX * depth, parallaxY * depth) px relative
to its base position, so "closer" particles (higher depth) shift more —
creating the impression of a multi-layered isometric space. */}
{particles.map((particle): ReactElement => (
<div
key={particle.id}
style={{
position: 'absolute',
left: `${particle.x}%`,
top: `${particle.y}%`,
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.isCircle ? '50%' : '2px',
boxShadow: `0 0 ${particle.size * 2}px ${particle.color}55`,
opacity: isHovering ? particle.opacity * 1.8 : particle.opacity,
// Particles shift in the SAME direction as the cursor offset to create
// a realistic parallax: near objects (depth ~0.6) move more than far ones.
transform: `translate(${parallaxX * particle.depth}px, ${parallaxY * particle.depth}px)`,
// Smooth lerp toward the new position; opacity fades independently
transition: `transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease`,
pointerEvents: 'none',
willChange: 'transform',
}}
/>
))}
{particles.map(
(particle): ReactElement => (
<div
key={particle.id}
style={{
position: 'absolute',
left: `${particle.x}%`,
top: `${particle.y}%`,
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.isCircle ? '50%' : '2px',
boxShadow: `0 0 ${particle.size * 2}px ${particle.color}55`,
opacity: isHovering ? particle.opacity * 1.8 : particle.opacity,
// Particles shift in the SAME direction as the cursor offset to create
// a realistic parallax: near objects (depth ~0.6) move more than far ones.
transform: `translate(${parallaxX * particle.depth}px, ${parallaxY * particle.depth}px)`,
// Smooth lerp toward the new position; opacity fades independently
transition: `transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease`,
pointerEvents: 'none',
willChange: 'transform',
}}
/>
)
)}
</div>

{/* ── Card content ──────────────────────────────────────────────────────
Expand Down
13 changes: 8 additions & 5 deletions components/burnout/BurnoutRiskTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ interface BurnoutRiskTableProps {
}

type SortColumn =
'username' | 'commitShare' | 'highIntensityWeeks' | 'restWeeks' | 'burnoutScore' | 'totalCommits';
| 'username'
| 'commitShare'
| 'highIntensityWeeks'
| 'restWeeks'
| 'burnoutScore'
| 'totalCommits';
type SortDirection = 'asc' | 'desc';

// Custom Pure SVG Sparkline for visual performance
Expand Down Expand Up @@ -162,9 +167,7 @@ export default function BurnoutRiskTable({ contributors }: BurnoutRiskTableProps

const renderSortIcon = (column: SortColumn) => {
if (sortColumn !== column) {
return (
<ArrowUpDown size={12} className="opacity-40 hover:opacity-100 transition-opacity ml-1" />
);
return <ArrowUpDown size={12} className="opacity-40 hover:opacity-100 transition-opacity ml-1" />;
}
return sortDirection === 'asc' ? (
<ArrowUp size={12} className="text-indigo-500 ml-1" />
Expand Down Expand Up @@ -206,7 +209,7 @@ export default function BurnoutRiskTable({ contributors }: BurnoutRiskTableProps
placeholder="Search contributor..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-7 py-1.5 rounded-xl border border-black/10 dark:border-white/10 bg-white/80 dark:bg-[#121212]/80 text-xs text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500"
className="w-full pl-8 pr-7 py-1.5 rounded-xl border border-black/10 dark:border-white/10 bg-white/80 dark:bg-[#121212]/80 text-xs text-gray-900 dark:text-white placeholder-gray-400"
/>
<Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400"
Expand Down
11 changes: 11 additions & 0 deletions components/dashboard/RadarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export default function RadarChart({ languagesA, languagesB, labelA, labelB }: R
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3 }}
role="region"
aria-label="Language Dominance Radar Comparison"
className="p-6 rounded-xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] flex flex-col items-center justify-between min-h-[360px]"
>
<div className="w-full mb-4 flex justify-between items-center">
Expand Down Expand Up @@ -100,8 +102,17 @@ export default function RadarChart({ languagesA, languagesB, labelA, labelB }: R
width="320"
height="300"
viewBox="0 0 320 300"
role="img"
aria-labelledby="radar-chart-title radar-chart-desc"
className="w-full h-full overflow-visible"
>
<title id="radar-chart-title">
Language Dominance Radar Chart comparing {labelA} and {labelB}
</title>
<desc id="radar-chart-desc">
Radar chart visualizing language distribution percentages for top languages across
both profiles.
</desc>
<defs>
<filter id="glow-cyan" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
Expand Down
Loading
Loading