Skip to content

Commit 44bc93f

Browse files
Merge branch 'main' into test/contributors-accessibility
2 parents 577c2b2 + 27dd627 commit 44bc93f

75 files changed

Lines changed: 5298 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('DashboardPage', () => {
9393
currentStreak: 5,
9494
peakStreak: 15,
9595
totalContributions: 500,
96+
codingHabit: 'Night Owl',
9697
},
9798
languages: [{ name: 'TypeScript', percentage: 100, color: '#3178c6' }],
9899
activity: [],

app/compare/CompareClient.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
Calendar,
1717
Trophy,
1818
Loader2,
19+
Moon,
20+
Sun,
21+
Coffee,
1922
} from 'lucide-react';
2023

2124
/* ── types ────────────────────────────────────────────────────────────── */
@@ -41,6 +44,7 @@ interface UserStats {
4144
currentStreak: number;
4245
peakStreak: number;
4346
totalContributions: number;
47+
codingHabit?: string;
4448
}
4549

4650
interface LanguageData {
@@ -389,6 +393,111 @@ function CompareSkeleton() {
389393
);
390394
}
391395

396+
/* ── helper: coding habit showdown ────────────────────────────────────── */
397+
398+
function CodingHabitCard({
399+
username,
400+
habit,
401+
side,
402+
}: {
403+
username: string;
404+
habit?: string;
405+
side: 'left' | 'right';
406+
}) {
407+
const isNight = habit === 'Night Owl';
408+
const isEarly = habit === 'Early Bird';
409+
410+
const icon = isNight ? (
411+
<Moon size={24} className="text-purple-400" />
412+
) : isEarly ? (
413+
<Sun size={24} className="text-amber-400" />
414+
) : (
415+
<Coffee size={24} className="text-teal-400" />
416+
);
417+
const bgClass = isNight
418+
? 'bg-gradient-to-br from-indigo-950 to-purple-950 border-purple-500/30'
419+
: isEarly
420+
? 'bg-gradient-to-br from-amber-900/40 to-orange-900/40 border-orange-500/30'
421+
: 'bg-gradient-to-br from-teal-900/40 to-emerald-900/40 border-teal-500/30';
422+
423+
const glowClass = isNight
424+
? 'shadow-[0_0_15px_rgba(168,85,247,0.15)]'
425+
: isEarly
426+
? 'shadow-[0_0_15px_rgba(245,158,11,0.15)]'
427+
: 'shadow-[0_0_15px_rgba(20,184,166,0.15)]';
428+
429+
return (
430+
<motion.div
431+
initial={{ opacity: 0, x: side === 'left' ? -20 : 20 }}
432+
whileInView={{ opacity: 1, x: 0 }}
433+
viewport={{ once: true }}
434+
whileHover={{ scale: 1.02 }}
435+
className={`relative overflow-hidden p-6 rounded-2xl border ${bgClass} ${glowClass} transition-all duration-300 flex flex-col items-center justify-center text-center h-full min-h-[140px]`}
436+
>
437+
<motion.div
438+
animate={{ y: [0, -5, 0] }}
439+
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
440+
className="mb-3 z-10"
441+
>
442+
{icon}
443+
</motion.div>
444+
<h4 className="text-xs font-bold uppercase tracking-widest text-white/70 mb-1 z-10">
445+
@{username}
446+
</h4>
447+
<h3 className="text-xl font-black tracking-tight text-white z-10">{habit || 'Unknown'}</h3>
448+
449+
{/* Decorative background elements */}
450+
{isNight && (
451+
<motion.div
452+
animate={{ opacity: [0.3, 0.7, 0.3] }}
453+
transition={{ duration: 3, repeat: Infinity }}
454+
className="absolute top-4 right-6 text-purple-300/20 text-xs"
455+
>
456+
457+
</motion.div>
458+
)}
459+
{isEarly && (
460+
<motion.div
461+
animate={{ rotate: 360 }}
462+
transition={{ duration: 20, repeat: Infinity, ease: 'linear' }}
463+
className="absolute -bottom-6 -right-6 text-amber-500/10"
464+
>
465+
<Sun size={80} />
466+
</motion.div>
467+
)}
468+
</motion.div>
469+
);
470+
}
471+
472+
function CodingHabitShowdown({ user1, user2 }: { user1: CompareUserData; user2: CompareUserData }) {
473+
return (
474+
<div>
475+
<h2 className="text-xs text-[#A1A1AA] uppercase tracking-widest font-medium mb-4">
476+
Coding Habits
477+
</h2>
478+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 relative">
479+
<CodingHabitCard
480+
username={user1.profile.username}
481+
habit={user1.stats.codingHabit}
482+
side="left"
483+
/>
484+
485+
<div className="hidden md:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
486+
<div className="w-8 h-8 rounded-full bg-white dark:bg-[#0a0a0a] border-2 border-black/10 dark:border-[rgba(255,255,255,0.08)] flex items-center justify-center shadow-xl">
487+
<span className="text-[10px] font-bold text-[#A1A1AA] tracking-wider">VS</span>
488+
</div>
489+
</div>
490+
491+
<CodingHabitCard
492+
username={user2.profile.username}
493+
habit={user2.stats.codingHabit}
494+
side="right"
495+
/>
496+
</div>
497+
</div>
498+
);
499+
}
500+
392501
/* ── main component ───────────────────────────────────────────────────── */
393502

394503
export default function CompareClient() {
@@ -662,6 +771,9 @@ export default function CompareClient() {
662771
</div>
663772
</div>
664773

774+
{/* Coding Habits Showdown */}
775+
<CodingHabitShowdown user1={d1} user2={d2} />
776+
665777
{/* Language Comparison */}
666778
<LanguageComparison
667779
langsA={d1.languages}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import CopyRepoButton from './CopyRepoButton';
5+
6+
vi.mock('lucide-react', () => ({
7+
Copy: () => <svg aria-hidden="true" data-testid="copy-icon" />,
8+
}));
9+
10+
Object.assign(navigator, {
11+
clipboard: {
12+
writeText: vi.fn().mockResolvedValue(undefined),
13+
},
14+
});
15+
16+
describe('CopyRepoButton accessibility behavior', () => {
17+
it('renders as an accessible button with visible label', () => {
18+
render(<CopyRepoButton />);
19+
20+
expect(screen.getByRole('button', { name: /copy url/i })).toBeDefined();
21+
});
22+
23+
it('keeps decorative copy icon hidden from assistive technology', () => {
24+
render(<CopyRepoButton />);
25+
26+
expect(screen.getByTestId('copy-icon').getAttribute('aria-hidden')).toBe('true');
27+
});
28+
29+
it('is keyboard focusable through the native button element', () => {
30+
render(<CopyRepoButton />);
31+
32+
const button = screen.getByRole('button', { name: /copy url/i });
33+
button.focus();
34+
35+
expect(document.activeElement).toBe(button);
36+
});
37+
38+
it('updates accessible button name after successful copy action', async () => {
39+
render(<CopyRepoButton />);
40+
41+
const button = screen.getByRole('button', { name: /copy url/i });
42+
fireEvent.click(button);
43+
44+
expect(await screen.findByRole('button', { name: /copied/i })).toBeDefined();
45+
});
46+
47+
it('preserves semantic button role after interaction', async () => {
48+
render(<CopyRepoButton />);
49+
50+
fireEvent.click(screen.getByRole('button', { name: /copy url/i }));
51+
52+
expect(await screen.findByRole('button', { name: /copied/i })).toBeDefined();
53+
expect(screen.getAllByRole('button')).toHaveLength(1);
54+
});
55+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { describe, expect, it, vi, beforeEach } from 'vitest';
3+
4+
import CopyRepoButton from './CopyRepoButton';
5+
6+
vi.mock('lucide-react', () => ({
7+
Copy: () => <svg data-testid="copy-icon" />,
8+
}));
9+
10+
describe('CopyRepoButton error resilience', () => {
11+
beforeEach(() => {
12+
vi.restoreAllMocks();
13+
});
14+
15+
it('renders successfully during initial mount', () => {
16+
render(<CopyRepoButton />);
17+
18+
expect(screen.getByRole('button', { name: /copy url/i })).toBeDefined();
19+
});
20+
21+
it('does not crash when clipboard write rejects', async () => {
22+
Object.assign(navigator, {
23+
clipboard: {
24+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
25+
},
26+
});
27+
28+
render(<CopyRepoButton />);
29+
30+
fireEvent.click(screen.getByRole('button'));
31+
32+
await waitFor(() => {
33+
expect(screen.getByRole('button')).toBeDefined();
34+
});
35+
});
36+
37+
it('keeps original label visible after clipboard failure', async () => {
38+
Object.assign(navigator, {
39+
clipboard: {
40+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
41+
},
42+
});
43+
44+
render(<CopyRepoButton />);
45+
46+
fireEvent.click(screen.getByRole('button'));
47+
48+
await waitFor(() => {
49+
expect(screen.getByText(/copy url/i)).toBeDefined();
50+
});
51+
});
52+
53+
it('renders icon even when copy action fails', async () => {
54+
Object.assign(navigator, {
55+
clipboard: {
56+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
57+
},
58+
});
59+
60+
render(<CopyRepoButton />);
61+
62+
fireEvent.click(screen.getByRole('button'));
63+
64+
await waitFor(() => {
65+
expect(screen.getByTestId('copy-icon')).toBeDefined();
66+
});
67+
});
68+
69+
it('allows repeated failed copy attempts without crashing', async () => {
70+
Object.assign(navigator, {
71+
clipboard: {
72+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
73+
},
74+
});
75+
76+
render(<CopyRepoButton />);
77+
78+
const button = screen.getByRole('button');
79+
80+
fireEvent.click(button);
81+
fireEvent.click(button);
82+
fireEvent.click(button);
83+
84+
await waitFor(() => {
85+
expect(screen.getByRole('button')).toBeDefined();
86+
});
87+
});
88+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import CopyRepoButton from './CopyRepoButton';
5+
6+
vi.mock('lucide-react', () => ({
7+
Copy: () => <svg data-testid="copy-icon" />,
8+
}));
9+
10+
describe('CopyRepoButton theme contrast behavior', () => {
11+
it('includes light mode background and border contrast classes', () => {
12+
render(<CopyRepoButton />);
13+
14+
const button = screen.getByRole('button', { name: /copy url/i });
15+
16+
expect(button.className).toContain('bg-white/60');
17+
expect(button.className).toContain('border-black/10');
18+
});
19+
20+
it('includes dark mode background and border contrast classes', () => {
21+
render(<CopyRepoButton />);
22+
23+
const button = screen.getByRole('button', { name: /copy url/i });
24+
25+
expect(button.className).toContain('dark:bg-white/5');
26+
expect(button.className).toContain('dark:border-white/10');
27+
});
28+
29+
it('keeps readable font weight for button text', () => {
30+
render(<CopyRepoButton />);
31+
32+
expect(screen.getByRole('button').className).toContain('font-semibold');
33+
});
34+
35+
it('preserves spacing classes so icon and text do not overlap', () => {
36+
render(<CopyRepoButton />);
37+
38+
const button = screen.getByRole('button');
39+
40+
expect(button.className).toContain('inline-flex');
41+
expect(button.className).toContain('items-center');
42+
expect(button.className).toContain('gap-2');
43+
});
44+
45+
it('keeps transition and hover classes without removing contrast styling', () => {
46+
render(<CopyRepoButton />);
47+
48+
const button = screen.getByRole('button');
49+
50+
expect(button.className).toContain('transition-all');
51+
expect(button.className).toContain('hover:scale-105');
52+
expect(button.className).toContain('bg-white/60');
53+
expect(button.className).toContain('dark:bg-white/5');
54+
});
55+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { render } from '@testing-library/react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { BoxIcon, CheckIcon, CloseIcon, CopyIcon, ZapIcon } from './Icons';
5+
6+
type IconProps = React.SVGProps<SVGSVGElement>;
7+
8+
vi.mock('lucide-react', () => ({
9+
Copy: (props: IconProps) => <svg data-testid="copy-icon" {...props} />,
10+
Zap: (props: IconProps) => <svg data-testid="zap-icon" {...props} />,
11+
Box: (props: IconProps) => <svg data-testid="box-icon" {...props} />,
12+
Check: (props: IconProps) => <svg data-testid="check-icon" {...props} />,
13+
X: (props: IconProps) => <svg data-testid="close-icon" {...props} />,
14+
}));
15+
16+
describe('Icons empty fallback behavior', () => {
17+
it('renders CopyIcon without crashing', () => {
18+
const { getByTestId } = render(<CopyIcon />);
19+
expect(getByTestId('copy-icon')).toBeDefined();
20+
});
21+
22+
it('renders ZapIcon without crashing', () => {
23+
const { getByTestId } = render(<ZapIcon />);
24+
expect(getByTestId('zap-icon')).toBeDefined();
25+
});
26+
27+
it('renders BoxIcon without crashing', () => {
28+
const { getByTestId } = render(<BoxIcon />);
29+
expect(getByTestId('box-icon')).toBeDefined();
30+
});
31+
32+
it('renders CheckIcon with fallback styling', () => {
33+
const { getByTestId } = render(<CheckIcon />);
34+
const icon = getByTestId('check-icon');
35+
36+
expect(icon.getAttribute('stroke')).toBe('#10b981');
37+
});
38+
39+
it('renders CloseIcon without runtime errors', () => {
40+
const { getByTestId } = render(<CloseIcon />);
41+
expect(getByTestId('close-icon')).toBeDefined();
42+
});
43+
});

0 commit comments

Comments
 (0)