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
83 changes: 83 additions & 0 deletions frontend/__tests__/AiPersonalityModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { AiPersonalityModal } from "@/app/components/matchmaking/AiPersonalityModal";

const setAiPersonality = vi.fn();

vi.mock("@/context/matchmakingContext", () => ({
useMatchmakingContext: () => ({
aiPersonality: "defensive",
setAiPersonality,
chessVariant: "standard",
}),
}));

vi.mock("@/lib/chessVariants", () => ({
getChessVariantById: () => ({
label: "Standard",
description: "Classic chess rules.",
averageGameTime: "10 min",
}),
}));

describe("AiPersonalityModal accessibility", () => {
beforeEach(() => {
setAiPersonality.mockClear();
});

it("renders a labelled modal dialog with descriptive copy", () => {
render(
<AiPersonalityModal
isOpen
onClose={() => undefined}
onConfirm={() => undefined}
/>,
);

const dialog = screen.getByRole("dialog", { name: "Finalize Match Setup" });

expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAccessibleDescription(
"Lock in your preferred chess format and AI co-pilot before matchmaking starts.",
);
});

it("exposes personality choices as pressed buttons", () => {
render(
<AiPersonalityModal
isOpen
onClose={() => undefined}
onConfirm={() => undefined}
/>,
);

expect(screen.getByRole("button", { name: /^select defensive/i })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByRole("button", { name: /^select aggressive/i })).toHaveAttribute(
"aria-pressed",
"false",
);

fireEvent.click(screen.getByRole("button", { name: /^select sacrificial/i }));

expect(setAiPersonality).toHaveBeenCalledWith("sacrificial");
});

it("closes with the Escape key", () => {
const onClose = vi.fn();

render(
<AiPersonalityModal
isOpen
onClose={onClose}
onConfirm={() => undefined}
/>,
);

fireEvent.keyDown(window, { key: "Escape" });

expect(onClose).toHaveBeenCalledTimes(1);
});
});
39 changes: 35 additions & 4 deletions frontend/app/components/matchmaking/AiPersonalityModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,20 +107,46 @@ export function AiPersonalityModal({
useMatchmakingContext();
const selectedVariant = getChessVariantById(chessVariant);

React.useEffect(() => {
if (!isOpen) {
return;
}

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};

window.addEventListener("keydown", handleKeyDown);

return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, onClose]);

if (!isOpen) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="ai-personality-modal-title"
aria-describedby="ai-personality-modal-description"
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>

{/* Modal panel */}
<div className="relative z-10 w-full max-w-lg mx-4 bg-gray-900 rounded-2xl border border-gray-700 shadow-2xl p-6 space-y-6">
{/* Close button */}
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors duration-200"
aria-label="Close personality selector"
Expand All @@ -143,10 +169,10 @@ export function AiPersonalityModal({

{/* Header */}
<div className="text-center space-y-1">
<h2 className="text-2xl font-bold text-white tracking-wide">
<h2 id="ai-personality-modal-title" className="text-2xl font-bold text-white tracking-wide">
Finalize Match Setup
</h2>
<p className="text-gray-400 text-sm">
<p id="ai-personality-modal-description" className="text-gray-400 text-sm">
Lock in your preferred chess format and AI co-pilot before matchmaking starts.
</p>
</div>
Expand All @@ -171,13 +197,16 @@ export function AiPersonalityModal({
</div>

{/* Personality cards */}
<div className="space-y-3">
<div className="space-y-3" role="group" aria-label="AI personality options">
{PERSONALITIES.map((option) => {
const isSelected = aiPersonality === option.id;
return (
<button
type="button"
key={option.id}
onClick={() => setAiPersonality(option.id)}
aria-pressed={isSelected}
aria-label={`Select ${option.label} AI personality. ${option.description}`}
className={`personality-card w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all duration-250
${
isSelected
Expand All @@ -198,6 +227,7 @@ export function AiPersonalityModal({
</div>
{/* Selection indicator */}
<div
aria-hidden="true"
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all duration-200
${isSelected ? `${option.borderColor} bg-transparent` : "border-gray-600"}`}
>
Expand All @@ -212,6 +242,7 @@ export function AiPersonalityModal({

{/* Confirm button */}
<button
type="button"
onClick={onConfirm}
className="confirm-btn w-full py-3 rounded-xl font-bold text-white text-sm uppercase tracking-widest bg-gradient-to-r from-teal-500 to-blue-700 hover:from-teal-600 hover:to-blue-800 transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]"
>
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ThemeProvider } from "next-themes";

export const metadata: Metadata = {
title: "XLMate",
description: "XLMate Chess on Stellar",
description: "XLMate - Chess on Stellar",
};

export default function RootLayout({
Expand Down
26 changes: 13 additions & 13 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default function Home() {
[gameId, socketSendMove, matchmakingSendMove],
);

// Kick off matchmaking when online mode is selectedbut only after personality is confirmed
// Kick off matchmaking when online mode is selected, but only after personality is confirmed.
// (joinMatchmaking is now called from handlePersonalityConfirm, not here)

useEffect(() => {
Expand Down Expand Up @@ -122,7 +122,7 @@ export default function Home() {
});
if (move) setPosition(game.fen());
} catch {
// illegal move from serverignore
// Illegal move from server; ignore it and wait for the next valid update.
}
}, [lastOpponentMove, game]);

Expand Down Expand Up @@ -194,12 +194,12 @@ export default function Home() {

// Searching / waiting overlay label
const onlineStatusLabel = () => {
if (socketStatus === "reconnecting") return "🔄 Reconnecting...";
if (matchmakingStatus === "match_found") return "Match found! Starting";
if (socketStatus === "reconnecting") return "Reconnecting...";
if (matchmakingStatus === "match_found") return "Match found! Starting...";
if (socketStatus === "connected")
return `🟢 Online Match (you are ${playerColor})`;
return `Online Match (you are ${playerColor})`;
if (matchmakingStatus === "error" || socketStatus === "error")
return `❌ ${matchmakingError ?? "Connection error"}`;
return matchmakingError ?? "Connection error";
return "Online Match";
};

Expand Down Expand Up @@ -249,19 +249,19 @@ export default function Home() {
)}

{gameMode === "online" && matchmakingStatus === "searching" && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center animate-overlay-in" role="dialog" aria-modal="true" aria-label="Searching for opponent">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center animate-overlay-in" role="dialog" aria-modal="true" aria-labelledby="searching-opponent-title" aria-describedby="searching-opponent-description">
<div className="bg-gray-900 p-8 rounded-2xl border border-yellow-500/30 text-center animate-modal-in max-w-sm w-full mx-4">
<div className="flex flex-col items-center gap-4">
<h3 className="text-xl font-bold text-yellow-400">
<h3 id="searching-opponent-title" className="text-xl font-bold text-yellow-400">
Looking for opponent...
</h3>
<span className="relative flex h-10 w-10">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full border-2 border-yellow-400 opacity-75 [animation-duration:0.8s]"></span>
<span className="relative inline-flex rounded-full h-10 w-10 border-2 border-yellow-500 bg-yellow-500/10"></span>
</span>

<p className="text-gray-300 text-sm" aria-live="polite">
{onlinePlayerCount} Players online
<p id="searching-opponent-description" className="text-gray-300 text-sm" aria-live="polite">
{onlinePlayerCount ?? "Unknown"} players online
</p>
<p className="text-cyan-100 text-xs uppercase tracking-[0.24em]">
Queueing for {selectedVariant.label}
Expand All @@ -279,14 +279,14 @@ export default function Home() {
)}

{gameMode === "online" && socketStatus === "reconnecting" && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center animate-overlay-in" role="dialog" aria-modal="true" aria-label="Reconnecting to game">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center animate-overlay-in" role="dialog" aria-modal="true" aria-labelledby="reconnecting-game-title" aria-describedby="reconnecting-game-description">
<div className="bg-gray-900 p-8 rounded-2xl border border-yellow-500/30 text-center animate-modal-in max-w-sm w-full mx-4">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin" />
<h3 className="text-xl font-bold text-yellow-400">
<h3 id="reconnecting-game-title" className="text-xl font-bold text-yellow-400">
Reconnecting...
</h3>
<p className="text-gray-300 text-sm">
<p id="reconnecting-game-description" className="text-gray-300 text-sm">
Attempting to restore connection
</p>
<button
Expand Down
40 changes: 21 additions & 19 deletions frontend/app/puzzles/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export default function PuzzlesPage() {
setIsClaiming(true);

const result = await executeClaim(async () => {
// Simulate on-chain reward claim — in production this would invoke a Soroban contract
// Simulate on-chain reward claim; production would invoke a Soroban contract.
await new Promise((resolve) => setTimeout(resolve, 1500));
return true;
});
Expand Down Expand Up @@ -240,21 +240,22 @@ export default function PuzzlesPage() {
<div className="min-h-screen p-4 md:p-8" role="region" aria-label="Chess Puzzles">
{/* Reward Popup */}
{showReward && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center animate-overlay-in" role="dialog" aria-modal="true" aria-label="Puzzle reward">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center animate-overlay-in" role="dialog" aria-modal="true" aria-labelledby="puzzle-reward-title" aria-describedby="puzzle-reward-description">
<div className="bg-gray-900 p-8 rounded-2xl border border-emerald-500/30 text-center animate-modal-in max-w-sm w-full mx-4">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-yellow-400/20 to-emerald-500/20 flex items-center justify-center">
<FaTrophy className="text-4xl text-yellow-400" />
</div>
<h3 className="text-2xl font-bold text-emerald-400">Puzzle Complete!</h3>
<p className="text-xl text-white">+{rewardAmount} XLM</p>
<div className="text-sm text-gray-400 space-y-1">
<p>Backend verification complete</p>
<p>🎁 Reward ready to claim</p>
<h3 id="puzzle-reward-title" className="text-2xl font-bold text-emerald-400">Puzzle Complete!</h3>
<p id="puzzle-reward-description" className="text-xl text-white">+{rewardAmount} XLM reward ready to claim</p>
<div className="text-sm text-gray-400 space-y-1" aria-label="Reward claim status">
<p>Backend verification complete</p>
<p>Reward ready to claim</p>
</div>
<button
onClick={handleClaimReward}
disabled={isClaiming}
aria-busy={isClaiming}
className="w-full py-3 rounded-xl bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 disabled:opacity-50 text-white font-bold text-sm transition-all duration-300 hover:scale-[1.02] active:scale-[0.98]"
>
{isClaiming ? (
Expand Down Expand Up @@ -334,13 +335,15 @@ export default function PuzzlesPage() {
<button
onClick={handleMoveBack}
disabled={currentMove === 0}
aria-label="Go back one puzzle move"
className="flex-1 px-4 py-2.5 bg-gray-700/60 hover:bg-gray-600/60 disabled:bg-gray-800/40 disabled:text-gray-600 rounded-xl text-white font-medium transition-colors text-sm"
>
<FaTimes className="inline mr-2" />
Back
</button>
<button
onClick={handleReset}
aria-label="Reset current puzzle"
className="flex-1 px-4 py-2.5 bg-gray-700/60 hover:bg-gray-600/60 rounded-xl text-white font-medium transition-colors text-sm"
>
<FaRedo className="inline mr-2" />
Expand All @@ -350,9 +353,10 @@ export default function PuzzlesPage() {
<button
onClick={() => setShowHint(!showHint)}
aria-pressed={showHint}
aria-controls={selectedPuzzle.hint ? "puzzle-hint" : undefined}
className="w-full px-4 py-2.5 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl text-blue-400 font-medium transition-colors text-sm"
>
💡 Hint
Hint
</button>
<button
onClick={handleMoveNext}
Expand All @@ -364,16 +368,16 @@ export default function PuzzlesPage() {
Submit Solution
</>
) : (
"Next Move"
"Next Move"
)}
</button>
</div>

{/* Hint Display */}
{showHint && selectedPuzzle.hint && (
<div className="mt-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg animate-scale-in">
<div id="puzzle-hint" className="mt-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg animate-scale-in">
<p className="text-blue-400 text-sm">
💡 {selectedPuzzle.hint}
{selectedPuzzle.hint}
</p>
</div>
)}
Expand All @@ -390,7 +394,7 @@ export default function PuzzlesPage() {
aria-live="assertive"
>
<p className="font-medium text-sm">
{isCorrect ? 'Correct! Well done!' : 'Not quite right. Try again!'}
{isCorrect ? 'Correct! Well done!' : 'Not quite right. Try again!'}
</p>
</div>
)}
Expand Down Expand Up @@ -436,19 +440,17 @@ export default function PuzzlesPage() {
{MOCK_PUZZLES.map((puzzle, idx) => {
const isCompleted = completedPuzzles.has(puzzle.id);
return (
<div
<button
type="button"
key={puzzle.id}
className={`bg-gray-800/60 p-6 rounded-xl border transition-all duration-300 hover:scale-[1.03] cursor-pointer animate-slide-up ${
className={`w-full bg-gray-800/60 p-6 rounded-xl border text-left transition-all duration-300 hover:scale-[1.03] cursor-pointer animate-slide-up focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-300 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950 ${
isCompleted
? 'border-emerald-500/30 bg-emerald-500/5'
: 'border-gray-700/50 hover:border-gray-600/50'
}`}
style={{ animationDelay: `${idx * 0.05}s` }}
onClick={() => handlePuzzleSelect(puzzle)}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePuzzleSelect(puzzle); } }}
role="button"
tabIndex={0}
aria-label={`${puzzle.title} — ${puzzle.difficulty} difficulty${isCompleted ? ', completed' : ''}`}
aria-label={`${puzzle.title}, ${puzzle.difficulty} difficulty${isCompleted ? ', completed' : ''}`}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-white">{puzzle.title}</h3>
Expand All @@ -466,7 +468,7 @@ export default function PuzzlesPage() {
<span className="text-xs text-gray-500">Puzzle #{puzzle.id}</span>
<span className="text-xs text-emerald-400 font-medium">+0.01 XLM</span>
</div>
</div>
</button>
);
})}
</div>
Expand Down
Loading