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) => ( + + ))} + + )} +
); } 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 +

+ +
{turn === 'white' ? "White's turn" : "Black's turn"}
- +
{/* 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

+
+ + + Knight + + +
+
+

Paratype Chess

+

Realtime multiplayer with custom rules

-
+
-

- New Game +

+ Host Game

-
+
{!roomCode ? ( ) : (
- Room code: + Room Code {roomCode}
@@ -184,15 +199,15 @@ export function Lobby() {
-
+
-
- or +
+ or
-

+

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"' }} /> @@ -217,14 +233,19 @@ export function Lobby() {
- {error && ( -
-

{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} -
+
+ {`${color} +
+ ); } 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 */} - + {/* Backdrop + drawer */} - {open && ( - <> -
setOpen(false)} - /> - - - )} +
+ {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) {