diff --git a/packages/chess/package.json b/packages/chess/package.json
index f52fee1..362fcae 100644
--- a/packages/chess/package.json
+++ b/packages/chess/package.json
@@ -16,15 +16,20 @@
"dependencies": {
"@paratype/rete": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
+ "canvas-confetti": "^1.9.4",
+ "lucide-react": "^1.8.0",
+ "motion": "^12.38.0",
"react-router-dom": "^7.14.1",
+ "sonner": "^2.0.7",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
- "vite": "^6.0.0",
+ "@types/canvas-confetti": "^1.9.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0"
+ "vite": "^6.0.0"
}
}
diff --git a/packages/chess/src/app/App.tsx b/packages/chess/src/app/App.tsx
index 7400266..d91e462 100644
--- a/packages/chess/src/app/App.tsx
+++ b/packages/chess/src/app/App.tsx
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
-import { Routes, Route, useNavigate } from 'react-router-dom'
+import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import { Lobby } from '../ui/Lobby'
import { GameView } from '../ui/GameView'
import { RulesView } from '../ui/RulesView'
@@ -9,9 +9,12 @@ import { useChessEngine } from '../hooks/useChessEngine'
import { ChessEngine } from '../engine'
import { loadAutoSave } from '../persist/autosave.js'
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
+import { AnimatePresence, motion } from 'motion/react'
+import { Toaster } from 'sonner'
export function App() {
const chessState = useChessEngine()
+ const location = useLocation()
// Restore autosave on mount — [] deps is intentional (run once, avoid infinite loop)
useEffect(() => {
@@ -25,17 +28,33 @@ export function App() {
}, []); // intentional: run once on mount, chessState ref is stable
return (
-
-
- } />
- } />
- } />
- } />
-
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
)
}
+function PageTransition({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
function SaveWrapper({ chessState }: { chessState: ReturnType
}) {
const navigate = useNavigate()
@@ -53,7 +72,7 @@ function SaveWrapper({ chessState }: { chessState: ReturnType
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/black-king.svg b/packages/chess/src/assets/pieces/black-king.svg
new file mode 100644
index 0000000..ba2ac9f
--- /dev/null
+++ b/packages/chess/src/assets/pieces/black-king.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/black-knight.svg b/packages/chess/src/assets/pieces/black-knight.svg
new file mode 100644
index 0000000..399a8f3
--- /dev/null
+++ b/packages/chess/src/assets/pieces/black-knight.svg
@@ -0,0 +1,41 @@
+
+
+
+Wikimedia Error
+
+
+
+
+
+
+
Error
+
+
Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/black-pawn.svg b/packages/chess/src/assets/pieces/black-pawn.svg
new file mode 100644
index 0000000..899c3f9
--- /dev/null
+++ b/packages/chess/src/assets/pieces/black-pawn.svg
@@ -0,0 +1,41 @@
+
+
+
+Wikimedia Error
+
+
+
+
+
+
+
Error
+
+
Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/black-queen.svg b/packages/chess/src/assets/pieces/black-queen.svg
new file mode 100644
index 0000000..e557734
--- /dev/null
+++ b/packages/chess/src/assets/pieces/black-queen.svg
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/black-rook.svg b/packages/chess/src/assets/pieces/black-rook.svg
new file mode 100644
index 0000000..4eec43c
--- /dev/null
+++ b/packages/chess/src/assets/pieces/black-rook.svg
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/index.ts b/packages/chess/src/assets/pieces/index.ts
new file mode 100644
index 0000000..cc13878
--- /dev/null
+++ b/packages/chess/src/assets/pieces/index.ts
@@ -0,0 +1,31 @@
+import whiteKing from "./white-king.svg";
+import whiteQueen from "./white-queen.svg";
+import whiteRook from "./white-rook.svg";
+import whiteBishop from "./white-bishop.svg";
+import whiteKnight from "./white-knight.svg";
+import whitePawn from "./white-pawn.svg";
+import blackKing from "./black-king.svg";
+import blackQueen from "./black-queen.svg";
+import blackRook from "./black-rook.svg";
+import blackBishop from "./black-bishop.svg";
+import blackKnight from "./black-knight.svg";
+import blackPawn from "./black-pawn.svg";
+
+export const pieceAssets = {
+ white: {
+ king: whiteKing,
+ queen: whiteQueen,
+ rook: whiteRook,
+ bishop: whiteBishop,
+ knight: whiteKnight,
+ pawn: whitePawn,
+ },
+ black: {
+ king: blackKing,
+ queen: blackQueen,
+ rook: blackRook,
+ bishop: blackBishop,
+ knight: blackKnight,
+ pawn: blackPawn,
+ },
+} as const;
diff --git a/packages/chess/src/assets/pieces/white-bishop.svg b/packages/chess/src/assets/pieces/white-bishop.svg
new file mode 100644
index 0000000..3a8eaa2
--- /dev/null
+++ b/packages/chess/src/assets/pieces/white-bishop.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/white-king.svg b/packages/chess/src/assets/pieces/white-king.svg
new file mode 100644
index 0000000..632ca1a
--- /dev/null
+++ b/packages/chess/src/assets/pieces/white-king.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/chess/src/assets/pieces/white-knight.svg b/packages/chess/src/assets/pieces/white-knight.svg
new file mode 100644
index 0000000..a5f31c6
--- /dev/null
+++ b/packages/chess/src/assets/pieces/white-knight.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/white-pawn.svg b/packages/chess/src/assets/pieces/white-pawn.svg
new file mode 100644
index 0000000..b265fe1
--- /dev/null
+++ b/packages/chess/src/assets/pieces/white-pawn.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/white-queen.svg b/packages/chess/src/assets/pieces/white-queen.svg
new file mode 100644
index 0000000..8df7c8f
--- /dev/null
+++ b/packages/chess/src/assets/pieces/white-queen.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/pieces/white-rook.svg b/packages/chess/src/assets/pieces/white-rook.svg
new file mode 100644
index 0000000..0574ca6
--- /dev/null
+++ b/packages/chess/src/assets/pieces/white-rook.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/chess/src/assets/sounds/capture.ogg b/packages/chess/src/assets/sounds/capture.ogg
new file mode 100644
index 0000000..b496672
Binary files /dev/null and b/packages/chess/src/assets/sounds/capture.ogg differ
diff --git a/packages/chess/src/assets/sounds/castle.ogg b/packages/chess/src/assets/sounds/castle.ogg
new file mode 100644
index 0000000..d585a0f
Binary files /dev/null and b/packages/chess/src/assets/sounds/castle.ogg differ
diff --git a/packages/chess/src/assets/sounds/check.ogg b/packages/chess/src/assets/sounds/check.ogg
new file mode 100644
index 0000000..8367da9
Binary files /dev/null and b/packages/chess/src/assets/sounds/check.ogg differ
diff --git a/packages/chess/src/assets/sounds/checkmate.ogg b/packages/chess/src/assets/sounds/checkmate.ogg
new file mode 100644
index 0000000..8367da9
Binary files /dev/null and b/packages/chess/src/assets/sounds/checkmate.ogg differ
diff --git a/packages/chess/src/assets/sounds/move.ogg b/packages/chess/src/assets/sounds/move.ogg
new file mode 100644
index 0000000..d585a0f
Binary files /dev/null and b/packages/chess/src/assets/sounds/move.ogg differ
diff --git a/packages/chess/src/assets/sounds/promote.ogg b/packages/chess/src/assets/sounds/promote.ogg
new file mode 100644
index 0000000..8367da9
Binary files /dev/null and b/packages/chess/src/assets/sounds/promote.ogg differ
diff --git a/packages/chess/src/audio.ts b/packages/chess/src/audio.ts
new file mode 100644
index 0000000..b68d451
--- /dev/null
+++ b/packages/chess/src/audio.ts
@@ -0,0 +1,48 @@
+import moveSound from "./assets/sounds/move.ogg";
+import captureSound from "./assets/sounds/capture.ogg";
+import checkSound from "./assets/sounds/check.ogg";
+import checkmateSound from "./assets/sounds/checkmate.ogg";
+import castleSound from "./assets/sounds/castle.ogg";
+import promoteSound from "./assets/sounds/promote.ogg";
+
+type SoundName =
+ | "move"
+ | "capture"
+ | "check"
+ | "checkmate"
+ | "castle"
+ | "promote";
+
+const sources: Record = {
+ move: moveSound,
+ capture: captureSound,
+ check: checkSound,
+ checkmate: checkmateSound,
+ castle: castleSound,
+ promote: promoteSound,
+};
+
+const MUTE_KEY = "paratype-chess:v1:muted";
+
+export function isMuted(): boolean {
+ if (typeof localStorage === "undefined") return false;
+ return localStorage.getItem(MUTE_KEY) === "1";
+}
+
+export function setMuted(m: boolean): void {
+ if (typeof localStorage === "undefined") return;
+ localStorage.setItem(MUTE_KEY, m ? "1" : "0");
+}
+
+export function play(name: SoundName): void {
+ if (isMuted()) return;
+ try {
+ const audio = new Audio(sources[name]);
+ audio.volume = 0.5;
+ audio.play().catch(() => {
+ // Ignore user-gesture autoplay block
+ });
+ } catch {
+ // Ignore errors
+ }
+}
diff --git a/packages/chess/src/hooks/useChessEngine.ts b/packages/chess/src/hooks/useChessEngine.ts
index 2e3c4b9..0a9084c 100644
--- a/packages/chess/src/hooks/useChessEngine.ts
+++ b/packages/chess/src/hooks/useChessEngine.ts
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react';
import { ChessEngine, type GameResult } from '../engine';
import type { PieceType } from '../schema';
import { saveAutoSave } from '../persist/autosave.js';
+import * as audio from '../audio';
export function useChessEngine() {
const engineRef = useRef(null);
@@ -47,6 +48,20 @@ export function useChessEngine() {
const result = engine.applyMove(move, promoteTo);
saveAutoSave(engine.session.allFacts());
setTick(t => t + 1); // trigger re-render
+
+ // Play appropriate sound
+ if (result === 'checkmate') {
+ audio.play('checkmate');
+ } else if (engine.session.allFacts().some(f => f.attr === 'InCheck' && f.value === true)) {
+ audio.play('check');
+ } else if (move.isCapture) {
+ audio.play('capture');
+ } else if (promoteTo && move.promoteTo) {
+ audio.play('promote');
+ } else {
+ audio.play('move'); // TODO: differentiate castle
+ }
+
return result;
}
return null;
@@ -68,6 +83,10 @@ export function useChessEngine() {
}
}, []);
+ const lastMove = moveHistoryRef.current.length > 0
+ ? moveHistoryRef.current[moveHistoryRef.current.length - 1]
+ : null;
+
return {
engine,
turn: getTurn(),
@@ -78,5 +97,6 @@ export function useChessEngine() {
undo,
canUndo: moveHistoryRef.current.length > 0,
loadEngine,
+ lastMove,
};
}
diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx
index d479b6e..e92cf5e 100644
--- a/packages/chess/src/ui/Board.tsx
+++ b/packages/chess/src/ui/Board.tsx
@@ -3,12 +3,18 @@ import type { ChessFact, PieceColor, PieceType } from '../schema';
import { squareToAlgebraic, squareOf, squareColor } from '../coord';
import type { LegalMove } from '../rules/types';
import { Piece } from './Piece';
+import { AnimatePresence, motion } from 'motion/react';
+import { pieceAssets } from '../assets/pieces';
interface BoardProps {
facts: ChessFact[];
legalMoves: LegalMove[];
- onMove: (from: number, to: number) => void;
+ onMove: (from: number, to: number, promoteTo?: PieceType) => void;
turn: PieceColor;
+ /** Last move played — extra fields ignored. Accepting the wider shape lets
+ * callers pass the hook's return value directly without stripping keys. */
+ lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined;
+ checkedKingSquare?: number | null | undefined;
}
interface PieceState {
@@ -17,7 +23,7 @@ interface PieceState {
color: PieceColor;
}
-export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
+export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSquare }: BoardProps) {
// Build pieces map: square -> { id, type, color }
const pieces = useMemo(() => {
const map = new Map();
@@ -51,6 +57,9 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
// Drag state
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
+
+ // Promotion picker state
+ const [promotionMove, setPromotionMove] = useState<{ from: number, to: number, color: PieceColor } | null>(null);
// Compute highlighted squares based on current dragged piece
const highlightedSquares = useMemo(() => {
@@ -88,7 +97,18 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
const handleDrop = (e: React.DragEvent, targetSquare: number) => {
e.preventDefault();
if (draggedPiece && highlightedSquares.has(targetSquare)) {
- onMove(draggedPiece.square, targetSquare);
+ // Check if this is a promotion move
+ const isPromotion = legalMoves.some(m =>
+ m.pieceId === draggedPiece.id &&
+ m.to === targetSquare &&
+ m.promoteTo !== undefined
+ );
+
+ if (isPromotion) {
+ setPromotionMove({ from: draggedPiece.square, to: targetSquare, color: turn });
+ } else {
+ onMove(draggedPiece.square, targetSquare);
+ }
}
setDraggedPiece(null);
};
@@ -101,6 +121,8 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
const isDark = squareColor(sq) === 'dark';
const algebraic = squareToAlgebraic(sq);
const isHighlighted = highlightedSquares.has(sq);
+ const isLastMove = lastMove?.from === sq || lastMove?.to === sq;
+ const isCheckedKing = checkedKingSquare === sq;
const piece = pieces.get(sq);
@@ -109,36 +131,56 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
key={sq}
data-square={algebraic}
className={`relative aspect-square flex items-center justify-center ${
- isDark ? 'bg-amber-700' : 'bg-amber-100'
- }`}
+ isDark ? 'bg-[#B58863]' : 'bg-[#F0D9B5]'
+ } shadow-[inset_0_0_8px_rgba(0,0,0,0.15)]`}
onDragOver={(e) => handleDragOver(e, sq)}
onDrop={(e) => handleDrop(e, sq)}
>
+ {isLastMove && (
+
+ )}
+
{isHighlighted && (
-
+
+ )}
+
+ {isCheckedKing && (
+
)}
- {piece && (
-
- )}
+
+ {piece && (
+
+
+
+ )}
+
{/* Rank/File labels (optional but helpful for debug) */}
{f === 0 && (
-
+
{r + 1}
)}
{r === 0 && (
-
+
{String.fromCharCode(97 + f)}
)}
@@ -148,8 +190,41 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
}
return (
-
- {squares}
+
+
+ {squares}
+
+
+
+ {promotionMove && (
+
+ {(['queen', 'rook', 'bishop', 'knight'] as const).map((type) => (
+ {
+ onMove(promotionMove.from, promotionMove.to, type);
+ setPromotionMove(null);
+ }}
+ className="w-16 h-16 sm:w-20 sm:h-20 hover:bg-neutral-100/80 rounded-xl flex items-center justify-center transition-colors active:scale-95 shadow-sm border border-transparent hover:border-neutral-200"
+ >
+
+
+ ))}
+
+ )}
+
);
}
diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx
index 145a5d1..c24e9c0 100644
--- a/packages/chess/src/ui/GameView.tsx
+++ b/packages/chess/src/ui/GameView.tsx
@@ -1,7 +1,12 @@
import { Board } from './Board';
import { RulesDrawer } from './RulesDrawer';
import { useChessEngine } from '../hooks/useChessEngine';
-import type { ChessFact, ChessAttrMap } from '../schema';
+import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
+import { useEffect, useState } from 'react';
+import confetti from 'canvas-confetti';
+import { motion, AnimatePresence } from 'motion/react';
+import { Volume2, VolumeX } from 'lucide-react';
+import * as audio from '../audio';
interface GameViewProps {
engineState?: ReturnType
;
@@ -12,29 +17,93 @@ export function GameView({ engineState }: GameViewProps) {
const localChessState = useChessEngine();
const state = engineState || localChessState;
- const { facts, legalMoves, turn, result, applyMove, undo, canUndo } = state;
+ const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove } = state;
- const handleMove = (from: number, to: number) => {
- applyMove(from, to, 'queen');
+ const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
+ applyMove(from, to, promoteTo || 'queen');
};
const isGameOver = result !== 'ongoing';
+
+ // Confetti on checkmate
+ useEffect(() => {
+ if (result === 'checkmate') {
+ const duration = 3000;
+ const end = Date.now() + duration;
+
+ const frame = () => {
+ confetti({
+ particleCount: 5,
+ angle: 60,
+ spread: 55,
+ origin: { x: 0 },
+ colors: ['#F0D9B5', '#B58863', '#fff']
+ });
+ confetti({
+ particleCount: 5,
+ angle: 120,
+ spread: 55,
+ origin: { x: 1 },
+ colors: ['#F0D9B5', '#B58863', '#fff']
+ });
+
+ if (Date.now() < end) {
+ requestAnimationFrame(frame);
+ }
+ };
+ frame();
+ }
+ }, [result]);
+
+ // Audio toggle
+ const [isMuted, setIsMuted] = useState(audio.isMuted());
+ const toggleMute = () => {
+ const next = !isMuted;
+ audio.setMuted(next);
+ setIsMuted(next);
+ };
+
+ // Find checked king for indicator
+ const checkedKingSquare = facts.find(
+ f => f.attr === 'InCheck' && f.value === true
+ ) ? facts.find(
+ f => f.attr === 'PieceType' && f.value === 'king' &&
+ facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
+ )?.id ? facts.find(f3 => f3.id === facts.find(
+ f => f.attr === 'PieceType' && f.value === 'king' &&
+ facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
+ )?.id && f3.attr === 'Position')?.value as number : null : null;
return (
-
+
{/* Header/Info section */}
-
- Chess
-
+
+
+ Chess
+
+
+ {isMuted ? : }
+
+
{turn === 'white' ? "White's turn" : "Black's turn"}
-
Undo
-
+
{/* Game Result Banner */}
- {isGameOver && (
-
- {result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
-
- )}
+
+ {isGameOver && (
+
+ {result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
+
+ )}
+
@@ -71,14 +146,20 @@ export function GameView({ engineState }: GameViewProps) {
legalMoves={legalMoves}
turn={turn}
onMove={handleMove}
+ lastMove={lastMove}
+ checkedKingSquare={checkedKingSquare}
/>
{/* Overlay for game over to prevent further interaction visually */}
{isGameOver && (
-
+
)}
-
+
);
}
diff --git a/packages/chess/src/ui/ImportExport.tsx b/packages/chess/src/ui/ImportExport.tsx
index 28d9287..9bbb607 100644
--- a/packages/chess/src/ui/ImportExport.tsx
+++ b/packages/chess/src/ui/ImportExport.tsx
@@ -1,5 +1,6 @@
import { useState, useRef } from 'react';
import { exportGame, importGame } from '../persist/io';
+import { toast } from 'sonner';
interface ImportExportProps {
currentFacts?: Array<{ id: number; attr: string; value: unknown }>;
@@ -22,8 +23,10 @@ export function ImportExport({ currentFacts, onLoad }: ImportExportProps) {
a.download = `chess-game-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
+ toast.success('Game exported');
} catch (err) {
console.error('Export failed:', err);
+ toast.error('Export failed');
}
};
@@ -42,14 +45,18 @@ export function ImportExport({ currentFacts, onLoad }: ImportExportProps) {
const text = event.target?.result as string;
const facts = importGame(text);
onLoad(facts);
+ toast.success('Game imported');
// Reset file input so the same file can be selected again
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (err) {
- setError(err instanceof Error ? err.message : 'Unknown import error');
+ const msg = err instanceof Error ? err.message : 'Unknown import error';
+ setError(msg);
+ toast.error('Import failed', { description: msg });
}
};
reader.onerror = () => {
setError('Failed to read file');
+ toast.error('Failed to read file');
};
reader.readAsText(file);
};
diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx
index 40d6200..6164b53 100644
--- a/packages/chess/src/ui/Lobby.tsx
+++ b/packages/chess/src/ui/Lobby.tsx
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
+import { motion, AnimatePresence } from 'motion/react';
+import { pieceAssets } from '../assets/pieces';
const WS_URL =
(import.meta as { env?: Record
}).env?.['VITE_WS_URL'] ??
@@ -131,49 +133,62 @@ export function Lobby() {
return (
-
-
-
Chess
-
Play realtime multiplayer
+
+
+
+
+
+
+
+
+
Paratype Chess
+
Realtime multiplayer with custom rules
navigate('/game')}
- className="mt-3 text-xs text-slate-500 hover:text-slate-900 underline underline-offset-2"
+ className="mt-4 text-sm font-semibold text-neutral-500 hover:text-neutral-900 transition-colors underline underline-offset-4 decoration-neutral-300 hover:decoration-neutral-900"
>
- or play solo (local)
+ Play Solo (Local)
-
+
-
- New Game
+
+ Host Game
-
+
{!roomCode ? (
{loading && !codeInput ? 'Creating...' : 'Create Room'}
) : (
- Room code:
+ Room Code
{roomCode}
navigate('/game')}
- className="w-full bg-blue-600 text-white font-medium py-2.5 px-4 rounded-md hover:bg-blue-700 transition-colors"
+ className="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 transition-all shadow-md active:scale-[0.98]"
>
Enter Room
@@ -184,15 +199,15 @@ export function Lobby() {
-
-
+
Join Game
@@ -203,13 +218,14 @@ export function Lobby() {
onChange={(e) => setCodeInput(e.target.value.toUpperCase())}
placeholder="Enter 6-letter code"
maxLength={6}
- className="w-full font-mono text-center text-lg py-2.5 px-4 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent placeholder:text-slate-400"
+ className="w-full font-mono font-bold text-center text-xl tracking-[0.2em] py-3 px-4 bg-white/80 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-neutral-300 shadow-inner transition-shadow"
+ style={{ fontFeatureSettings: '"ss01", "cv11"' }}
/>
{loading && codeInput ? 'Joining...' : 'Join Room'}
@@ -217,14 +233,19 @@ export function Lobby() {
- {error && (
-
- )}
+
+ {error && (
+
+ {error}
+
+ )}
+
);
diff --git a/packages/chess/src/ui/Piece.tsx b/packages/chess/src/ui/Piece.tsx
index 3472a3f..13850c5 100644
--- a/packages/chess/src/ui/Piece.tsx
+++ b/packages/chess/src/ui/Piece.tsx
@@ -1,4 +1,7 @@
import type { PieceColor, PieceType } from '../schema';
+import { pieceAssets } from '../assets/pieces';
+import { motion } from 'motion/react';
+import type { DragEvent as ReactDragEvent } from 'react';
export interface PieceProps {
color: PieceColor;
@@ -9,44 +12,55 @@ export interface PieceProps {
onDragEnd: () => void;
}
-const PIECE_SYMBOLS: Record
> = {
- white: {
- king: '♔',
- queen: '♕',
- rook: '♖',
- bishop: '♗',
- knight: '♘',
- pawn: '♙',
- },
- black: {
- king: '♚',
- queen: '♛',
- rook: '♜',
- bishop: '♝',
- knight: '♞',
- pawn: '♟',
- },
-};
-
+/**
+ * Piece component.
+ *
+ * We wrap the draggable DOM node in an outer `motion.div` that owns the
+ * FLIP layout animation (via `layoutId`). The inner plain `` owns the
+ * HTML5 native drag-and-drop: Playwright's `locator.dragTo()` and the
+ * browser's DnD subsystem both dispatch events on this node. Keeping the
+ * two responsibilities separate avoids a type clash — `motion.div`'s
+ * `onDragStart` prop is typed for its own pointer-based drag gesture,
+ * which is incompatible with `React.DragEvent`. The extra DOM node costs
+ * nothing and sidesteps the cast.
+ */
export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) {
- const symbol = PIECE_SYMBOLS[color][type];
-
+ const imgSrc = pieceAssets[color][type];
+
+ const handleDragStart = (e: ReactDragEvent
) => {
+ e.dataTransfer.effectAllowed = 'move';
+ // Firefox requires non-empty drag data for dragstart to take effect.
+ e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
+ // Suppress the browser's default ghost image — we'd rather the piece
+ // stay visually in place while motion handles the move animation.
+ const img = new Image();
+ img.src =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
+ e.dataTransfer.setDragImage(img, 0, 0);
+ onDragStart(pieceId, square);
+ };
+
return (
- {
- // Set drag image (optional, but good for native dnd)
- e.dataTransfer.effectAllowed = 'move';
- // Need to set some data for Firefox to allow dragging
- e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
- onDragStart(pieceId, square);
- }}
- onDragEnd={onDragEnd}
- className="flex items-center justify-center w-full h-full text-5xl cursor-grab active:cursor-grabbing select-none hover:bg-white/20 rounded"
+
- {symbol}
-
+
+
+
+
);
}
diff --git a/packages/chess/src/ui/RulesDrawer.tsx b/packages/chess/src/ui/RulesDrawer.tsx
index 20e4777..e07a72e 100644
--- a/packages/chess/src/ui/RulesDrawer.tsx
+++ b/packages/chess/src/ui/RulesDrawer.tsx
@@ -1,5 +1,8 @@
import { useState, useEffect } from 'react';
import { PRESET_REGISTRY } from '../presets/index.js';
+import { AnimatePresence, motion } from 'motion/react';
+import { Settings2, X } from 'lucide-react';
+import { toast } from 'sonner';
/**
* A collapsible side-drawer for toggling preset rules mid-game.
@@ -29,16 +32,20 @@ export function RulesDrawer() {
const presets = PRESET_REGISTRY.getAll();
const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id));
- const toggle = (id: string) => {
- if (activeIds.has(id)) {
+ const toggle = (id: string, name: string) => {
+ const isActivating = !activeIds.has(id);
+ if (!isActivating) {
PRESET_REGISTRY.deactivate(id);
+ toast(`Preset disabled: ${name}`);
} else {
try {
PRESET_REGISTRY.activate(id);
+ toast.info(`Preset enabled: ${name}`);
} catch (err) {
// activate() throws for missing requires or incompatibilities. We
// surface the reason via the UI only for the user's next refresh.
console.warn(`Could not activate ${id}:`, err);
+ toast.error(`Could not activate ${name}`);
}
}
setTick((t) => t + 1);
@@ -47,131 +54,138 @@ export function RulesDrawer() {
return (
<>
{/* Trigger pill — always visible, top-right of game view */}
- setOpen(true)}
- className="fixed top-4 right-4 z-40 flex items-center gap-2 bg-white px-4 py-2 rounded-full shadow-md border border-neutral-200 hover:border-blue-400 hover:shadow-lg transition-all text-sm font-medium text-neutral-700"
+ className="fixed top-4 right-4 z-40 flex items-center gap-2 bg-white/90 backdrop-blur-sm px-4 py-2.5 rounded-full shadow-sm border border-neutral-200/60 hover:border-neutral-300 hover:shadow transition-colors text-sm font-semibold text-neutral-700"
aria-label="Toggle rules"
>
-
-
-
+
Rules
{activeIds.size > 0 && (
-
+
{activeIds.size}
)}
-
+
{/* Backdrop + drawer */}
- {open && (
- <>
- setOpen(false)}
- />
-
-
+
+ {open && (
+ <>
+ setOpen(false)}
+ />
+
+
-
- {presets.map((preset) => {
- const isOn = activeIds.has(preset.id);
- const blockedBy = preset.incompatibleWith.filter((id) =>
- activeIds.has(id),
- );
- const missingReqs = preset.requires.filter(
- (id) => !activeIds.has(id),
- );
- const blocked =
- !isOn && (blockedBy.length > 0 || missingReqs.length > 0);
+
+ {presets.map((preset) => {
+ const isOn = activeIds.has(preset.id);
+ const blockedBy = preset.incompatibleWith.filter((id) =>
+ activeIds.has(id),
+ );
+ const missingReqs = preset.requires.filter(
+ (id) => !activeIds.has(id),
+ );
+ const blocked =
+ !isOn && (blockedBy.length > 0 || missingReqs.length > 0);
- return (
-
-
-
-
- {preset.name}
-
-
- {preset.description}
-
- {blockedBy.length > 0 && (
-
- Conflicts with active: {blockedBy.join(', ')}
+ return (
+
+
+
+
+ {preset.name}
+
+
+ {preset.description}
- )}
- {missingReqs.length > 0 && (
-
- Requires: {missingReqs.join(', ')}
-
- )}
+ {blockedBy.length > 0 && (
+
+ Conflicts with active: {blockedBy.join(', ')}
+
+ )}
+ {missingReqs.length > 0 && (
+
+ Requires: {missingReqs.join(', ')}
+
+ )}
+
+
toggle(preset.id, preset.name)}
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
+ isOn ? 'bg-neutral-900' : 'bg-neutral-200'
+ } ${blocked ? 'cursor-not-allowed' : ''}`}
+ role="switch"
+ aria-checked={isOn}
+ >
+
+
-
toggle(preset.id)}
- className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
- isOn ? 'bg-blue-600' : 'bg-neutral-300'
- } ${blocked ? 'cursor-not-allowed' : ''}`}
- role="switch"
- aria-checked={isOn}
- >
-
-
-
- );
- })}
-
+ );
+ })}
+
-
- {activeIds.size === 0
- ? 'Standard FIDE chess — no presets active'
- : `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
-
-
- >
- )}
+
+ {activeIds.size === 0
+ ? 'Standard FIDE chess — no presets active'
+ : `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
+
+
+ >
+ )}
+
>
);
}
diff --git a/packages/chess/src/ui/SavePanel.tsx b/packages/chess/src/ui/SavePanel.tsx
index 1daa65d..7fc66d3 100644
--- a/packages/chess/src/ui/SavePanel.tsx
+++ b/packages/chess/src/ui/SavePanel.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import type { EntityId } from '@paratype/rete';
+import { toast } from 'sonner';
export interface SaveSlot {
name: string;
@@ -54,11 +55,13 @@ export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${slot.name}`, JSON.stringify(slot));
setNewSlotName('');
loadSlots();
+ toast.success('Game saved', { description: `Saved as "${slot.name}"` });
};
const handleDelete = (name: string) => {
localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${name}`);
loadSlots();
+ toast('Save deleted', { description: `Deleted "${name}"` });
};
return (
@@ -114,7 +117,10 @@ export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
onLoad(slot.facts)}
+ onClick={() => {
+ onLoad(slot.facts);
+ toast.success('Game loaded');
+ }}
className="px-4 py-1.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 active:bg-neutral-100 transition-colors"
>
Load
diff --git a/packages/chess/src/vite-env.d.ts b/packages/chess/src/vite-env.d.ts
new file mode 100644
index 0000000..d6af361
--- /dev/null
+++ b/packages/chess/src/vite-env.d.ts
@@ -0,0 +1,21 @@
+///
+
+// Vite's static asset imports — these return a URL string at runtime. The
+// `vite/client` triple-slash directive above already declares most of them,
+// but we add `.ogg` explicitly since Vite only emits types for a known
+// subset of asset extensions (mp3/wav/flac are in; ogg is not).
+declare module '*.ogg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.svg' {
+ const src: string;
+ export default src;
+}
+
+declare module '*.svg?react' {
+ import type { FunctionComponent, SVGProps } from 'react';
+ const ReactComponent: FunctionComponent>;
+ export default ReactComponent;
+}