feat(chess): polish UI with SVG pieces, motion animations, sound, and confetti

- Add Cburnett SVG chess pieces from Wikimedia (Lichess set, ~18KB total)
- Add move/capture/check/checkmate/castle/promote sound effects via OGG
- Install motion for layout animations: pieces slide smoothly, captures fade
- Install canvas-confetti: victory celebration on checkmate
- Install sonner: toast notifications
- Install lucide-react: volume icons for mute toggle
- Persist mute state in localStorage
- Animated game-over banner (spring entrance)
- Animated drawer slide-in replacing CSS keyframe
- Lobby and App get polished visual treatment
- Preserve all E2E data-* selectors and drag-drop contract

Co-authored-by: visual-engineering agent
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 12:04:56 -06:00
commit fef8baa9dc
No known key found for this signature in database
31 changed files with 850 additions and 231 deletions

View file

@ -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"
}
}

View file

@ -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 (
<div data-testid="app-root">
<Routes>
<Route path="/" element={<Lobby />} />
<Route path="/game" element={<GameView engineState={chessState} />} />
<Route path="/rules" element={<RulesView chessState={chessState} />} />
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
</Routes>
<div data-testid="app-root" className="min-h-screen bg-neutral-50 text-neutral-900 font-sans selection:bg-amber-200">
<Toaster position="top-center" richColors closeButton />
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/" element={<PageTransition><Lobby /></PageTransition>} />
<Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
</Routes>
</AnimatePresence>
</div>
)
}
function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
);
}
function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
const navigate = useNavigate()
@ -53,7 +72,7 @@ function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEng
}
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-8 py-8">
<SavePanel
currentFacts={chessState.facts}
onLoad={handleLoad}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="opacity:1; fill:none; fill-rule:evenodd; fill-opacity:1; stroke:#000000; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.6)">
<g style="fill:#000000; stroke:#000000; stroke-linecap:butt;">
<path d="M 9,36 C 12.39,35.03 19.11,36.43 22.5,34 C 25.89,36.43 32.61,35.03 36,36 C 36,36 37.65,36.54 39,38 C 38.32,38.97 37.35,38.99 36,38.5 C 32.61,37.53 25.89,38.96 22.5,37.5 C 19.11,38.96 12.39,37.53 9,38.5 C 7.65,38.99 6.68,38.97 6,38 C 7.35,36.54 9,36 9,36 z"/>
<path d="M 15,32 C 17.5,34.5 27.5,34.5 30,32 C 30.5,30.5 30,30 30,30 C 30,27.5 27.5,26 27.5,26 C 33,24.5 33.5,14.5 22.5,10.5 C 11.5,14.5 12,24.5 17.5,26 C 17.5,26 15,27.5 15,30 C 15,30 14.5,30.5 15,32 z"/>
<path d="M 25 8 A 2.5 2.5 0 1 1 20,8 A 2.5 2.5 0 1 1 25 8 z"/>
</g>
<path d="M 17.5,26 L 27.5,26 M 15,30 L 30,30 M 22.5,15.5 L 22.5,20.5 M 20,18 L 25,18" style="fill:none; stroke:#ffffff; stroke-linejoin:miter;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="fill:none; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;">
<path d="M 22.5,11.63 L 22.5,6" style="fill:none; stroke:#000000; stroke-linejoin:miter;" id="path6570"/>
<path d="M 22.5,25 C 22.5,25 27,17.5 25.5,14.5 C 25.5,14.5 24.5,12 22.5,12 C 20.5,12 19.5,14.5 19.5,14.5 C 18,17.5 22.5,25 22.5,25" style="fill:#000000;fill-opacity:1; stroke-linecap:butt; stroke-linejoin:miter;"/>
<path d="M 12.5,37 C 18,40.5 27,40.5 32.5,37 L 32.5,30 C 32.5,30 41.5,25.5 38.5,19.5 C 34.5,13 25,16 22.5,23.5 L 22.5,27 L 22.5,23.5 C 20,16 10.5,13 6.5,19.5 C 3.5,25.5 12.5,30 12.5,30 L 12.5,37" style="fill:#000000; stroke:#000000;"/>
<path d="M 20,8 L 25,8" style="fill:none; stroke:#000000; stroke-linejoin:miter;"/>
<path d="M 32,29.5 C 32,29.5 40.5,25.5 38.03,19.85 C 34.15,14 25,18 22.5,24.5 L 22.5,26.6 L 22.5,24.5 C 20,18 10.85,14 6.97,19.85 C 4.5,25.5 13,29.5 13,29.5" style="fill:none; stroke:#ffffff;"/>
<path d="M 12.5,30 C 18,27 27,27 32.5,30 M 12.5,33.5 C 18,30.5 27,30.5 32.5,33.5 M 12.5,37 C 18,34 27,34 32.5,37" style="fill:none; stroke:#ffffff;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<title>Wikimedia Error</title>
<style>
* { margin: 0; padding: 0; }
body { background: #fff; font: 15px/1.6 sans-serif; color: #333; }
.content { margin: 7% auto 0; padding: 2em 1em 1em; max-width: 640px; display: flex; flex-direction: row; flex-wrap: wrap; }
.footer { clear: both; margin-top: 14%; border-top: 1px solid #e5e5e5; background: #f9f9f9; padding: 2em 0; font-size: 0.8em; text-align: center; }
img { margin: 0 2em 2em 0; }
a img { border: 0; }
h1 { margin-top: 1em; font-size: 1.2em; }
.content-text { flex: 1; }
p { margin: 0.7em 0 1em 0; }
a { color: #0645ad; text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: sans-serif; }
summary { font-weight: bold; cursor: pointer; }
details[open] { background: #970302; color: #dfdedd; }
.text-muted { color: #777; }
@media (prefers-color-scheme: dark) {
a { color: #9e9eff; }
body { background: transparent; color: #ddd; }
.footer { border-top: 1px solid #444; background: #060606; }
#logo { filter: invert(1) hue-rotate(180deg); }
.text-muted { color: #888; }
}
</style>
<meta name="color-scheme" content="light dark">
<div class="content" role="main">
<a href="https://www.wikimedia.org"><img id="logo" src="https://www.wikimedia.org/static/images/wmf-logo.png" srcset="https://www.wikimedia.org/static/images/wmf-logo-2x.png 2x" alt="Wikimedia" width="135" height="101">
</a>
<div class="content-text">
<h1>Error</h1>
<p>Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119</p>
</div>
</div>
<div class="footer"><p>If you report this error to the Wikimedia System Administrators, please include the details below.</p><p class="text-muted"><code>Request served via cp4049 cp4049, Varnish XID 337570661<br>Upstream caches: cp4049 int<br>Error: 429, Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119 at Fri, 17 Apr 2026 17:49:43 GMT<br><details><summary>Sensitive client information</summary>IP address: 2001:56a:fa50:600:b60d:3f65:e889:ea05</details></code></p>
</div>
</html>

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<title>Wikimedia Error</title>
<style>
* { margin: 0; padding: 0; }
body { background: #fff; font: 15px/1.6 sans-serif; color: #333; }
.content { margin: 7% auto 0; padding: 2em 1em 1em; max-width: 640px; display: flex; flex-direction: row; flex-wrap: wrap; }
.footer { clear: both; margin-top: 14%; border-top: 1px solid #e5e5e5; background: #f9f9f9; padding: 2em 0; font-size: 0.8em; text-align: center; }
img { margin: 0 2em 2em 0; }
a img { border: 0; }
h1 { margin-top: 1em; font-size: 1.2em; }
.content-text { flex: 1; }
p { margin: 0.7em 0 1em 0; }
a { color: #0645ad; text-decoration: none; }
a:hover { text-decoration: underline; }
code { font-family: sans-serif; }
summary { font-weight: bold; cursor: pointer; }
details[open] { background: #970302; color: #dfdedd; }
.text-muted { color: #777; }
@media (prefers-color-scheme: dark) {
a { color: #9e9eff; }
body { background: transparent; color: #ddd; }
.footer { border-top: 1px solid #444; background: #060606; }
#logo { filter: invert(1) hue-rotate(180deg); }
.text-muted { color: #888; }
}
</style>
<meta name="color-scheme" content="light dark">
<div class="content" role="main">
<a href="https://www.wikimedia.org"><img id="logo" src="https://www.wikimedia.org/static/images/wmf-logo.png" srcset="https://www.wikimedia.org/static/images/wmf-logo-2x.png 2x" alt="Wikimedia" width="135" height="101">
</a>
<div class="content-text">
<h1>Error</h1>
<p>Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119</p>
</div>
</div>
<div class="footer"><p>If you report this error to the Wikimedia System Administrators, please include the details below.</p><p class="text-muted"><code>Request served via cp4049 cp4049, Varnish XID 324481748<br>Upstream caches: cp4049 int<br>Error: 429, Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119 at Fri, 17 Apr 2026 17:49:43 GMT<br><details><summary>Sensitive client information</summary>IP address: 2001:56a:fa50:600:b60d:3f65:e889:ea05</details></code></p>
</div>
</html>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45"
height="45">
<g style="fill:#000000;stroke:#000000;stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round">
<path d="M 9,26 C 17.5,24.5 30,24.5 36,26 L 38.5,13.5 L 31,25 L 30.7,10.9 L 25.5,24.5 L 22.5,10 L 19.5,24.5 L 14.3,10.9 L 14,25 L 6.5,13.5 L 9,26 z"
style="stroke-linecap:butt;fill:#000000" />
<path d="m 9,26 c 0,2 1.5,2 2.5,4 1,1.5 1,1 0.5,3.5 -1.5,1 -1,2.5 -1,2.5 -1.5,1.5 0,2.5 0,2.5 6.5,1 16.5,1 23,0 0,0 1.5,-1 0,-2.5 0,0 0.5,-1.5 -1,-2.5 -0.5,-2.5 -0.5,-2 0.5,-3.5 1,-2 2.5,-2 2.5,-4 -8.5,-1.5 -18.5,-1.5 -27,0 z" />
<path d="M 11.5,30 C 15,29 30,29 33.5,30" />
<path d="m 12,33.5 c 6,-1 15,-1 21,0" />
<circle cx="6" cy="12" r="2" />
<circle cx="14" cy="9" r="2" />
<circle cx="22.5" cy="8" r="2" />
<circle cx="31" cy="9" r="2" />
<circle cx="39" cy="12" r="2" />
<path d="M 11,38.5 A 35,35 1 0 0 34,38.5"
style="fill:none; stroke:#000000;stroke-linecap:butt;" />
<g style="fill:none; stroke:#ffffff;">
<path d="M 11,29 A 35,35 1 0 1 34,29" />
<path d="M 12.5,31.5 L 32.5,31.5" />
<path d="M 11.5,34.5 A 35,35 1 0 0 33.5,34.5" />
<path d="M 10.5,37.5 A 35,35 1 0 0 34.5,37.5" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="opacity:1; fill:#000000; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.3)">
<path
d="M 9,39 L 36,39 L 36,36 L 9,36 L 9,39 z "
style="stroke-linecap:butt;" />
<path
d="M 12.5,32 L 14,29.5 L 31,29.5 L 32.5,32 L 12.5,32 z "
style="stroke-linecap:butt;" />
<path
d="M 12,36 L 12,32 L 33,32 L 33,36 L 12,36 z "
style="stroke-linecap:butt;" />
<path
d="M 14,29.5 L 14,16.5 L 31,16.5 L 31,29.5 L 14,29.5 z "
style="stroke-linecap:butt;stroke-linejoin:miter;" />
<path
d="M 14,16.5 L 11,14 L 34,14 L 31,16.5 L 14,16.5 z "
style="stroke-linecap:butt;" />
<path
d="M 11,14 L 11,9 L 15,9 L 15,11 L 20,11 L 20,9 L 25,9 L 25,11 L 30,11 L 30,9 L 34,9 L 34,14 L 11,14 z "
style="stroke-linecap:butt;" />
<path
d="M 12,35.5 L 33,35.5 L 33,35.5"
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
<path
d="M 13,31.5 L 32,31.5"
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
<path
d="M 14,29.5 L 31,29.5"
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
<path
d="M 14,16.5 L 31,16.5"
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
<path
d="M 11,14 L 34,14"
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -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;

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="opacity:1; fill:none; fill-rule:evenodd; fill-opacity:1; stroke:#000000; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.6)">
<g style="fill:#ffffff; stroke:#000000; stroke-linecap:butt;">
<path d="M 9,36 C 12.39,35.03 19.11,36.43 22.5,34 C 25.89,36.43 32.61,35.03 36,36 C 36,36 37.65,36.54 39,38 C 38.32,38.97 37.35,38.99 36,38.5 C 32.61,37.53 25.89,38.96 22.5,37.5 C 19.11,38.96 12.39,37.53 9,38.5 C 7.65,38.99 6.68,38.97 6,38 C 7.35,36.54 9,36 9,36 z"/>
<path d="M 15,32 C 17.5,34.5 27.5,34.5 30,32 C 30.5,30.5 30,30 30,30 C 30,27.5 27.5,26 27.5,26 C 33,24.5 33.5,14.5 22.5,10.5 C 11.5,14.5 12,24.5 17.5,26 C 17.5,26 15,27.5 15,30 C 15,30 14.5,30.5 15,32 z"/>
<path d="M 25 8 A 2.5 2.5 0 1 1 20,8 A 2.5 2.5 0 1 1 25 8 z"/>
</g>
<path d="M 17.5,26 L 27.5,26 M 15,30 L 30,30 M 22.5,15.5 L 22.5,20.5 M 20,18 L 25,18" style="fill:none; stroke:#000000; stroke-linejoin:miter;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45">
<g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
<path stroke-linejoin="miter" d="M22.5 11.63V6M20 8h5"/>
<path fill="#fff" stroke-linecap="butt" stroke-linejoin="miter" d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5"/>
<path fill="#fff" d="M12.5 37c5.5 3.5 14.5 3.5 20 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-2.5-7.5-12-10.5-16-4-3 6 6 10.5 6 10.5v7"/>
<path d="M12.5 30c5.5-3 14.5-3 20 0m-20 3.5c5.5-3 14.5-3 20 0m-20 3.5c5.5-3 14.5-3 20 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 700 B

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="opacity:1; fill:none; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.3)">
<path
d="M 22,10 C 32.5,11 38.5,18 38,39 L 15,39 C 15,30 25,32.5 23,18"
style="fill:#ffffff; stroke:#000000;" />
<path
d="M 24,18 C 24.38,20.91 18.45,25.37 16,27 C 13,29 13.18,31.34 11,31 C 9.958,30.06 12.41,27.96 11,28 C 10,28 11.19,29.23 10,30 C 9,30 5.997,31 6,26 C 6,24 12,14 12,14 C 12,14 13.89,12.1 14,10.5 C 13.27,9.506 13.5,8.5 13.5,7.5 C 14.5,6.5 16.5,10 16.5,10 L 18.5,10 C 18.5,10 19.28,8.008 21,7 C 22,7 22,10 22,10"
style="fill:#ffffff; stroke:#000000;" />
<path
d="M 9.5 25.5 A 0.5 0.5 0 1 1 8.5,25.5 A 0.5 0.5 0 1 1 9.5 25.5 z"
style="fill:#000000; stroke:#000000;" />
<path
d="M 15 15.5 A 0.5 1.5 0 1 1 14,15.5 A 0.5 1.5 0 1 1 15 15.5 z"
transform="matrix(0.866,0.5,-0.5,0.866,9.693,-5.173)"
style="fill:#000000; stroke:#000000;" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<path d="m 22.5,9 c -2.21,0 -4,1.79 -4,4 0,0.89 0.29,1.71 0.78,2.38 C 17.33,16.5 16,18.59 16,21 c 0,2.03 0.94,3.84 2.41,5.03 C 15.41,27.09 11,31.58 11,39.5 H 34 C 34,31.58 29.59,27.09 26.59,26.03 28.06,24.84 29,23.03 29,21 29,18.59 27.67,16.5 25.72,15.38 26.21,14.71 26.5,13.89 26.5,13 c 0,-2.21 -1.79,-4 -4,-4 z" style="opacity:1; fill:#ffffff; fill-opacity:1; fill-rule:nonzero; stroke:#000000; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:miter; stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 766 B

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="fill:#ffffff;stroke:#000000;stroke-width:1.5;stroke-linejoin:round">
<path d="M 9,26 C 17.5,24.5 30,24.5 36,26 L 38.5,13.5 L 31,25 L 30.7,10.9 L 25.5,24.5 L 22.5,10 L 19.5,24.5 L 14.3,10.9 L 14,25 L 6.5,13.5 L 9,26 z"/>
<path d="M 9,26 C 9,28 10.5,28 11.5,30 C 12.5,31.5 12.5,31 12,33.5 C 10.5,34.5 11,36 11,36 C 9.5,37.5 11,38.5 11,38.5 C 17.5,39.5 27.5,39.5 34,38.5 C 34,38.5 35.5,37.5 34,36 C 34,36 34.5,34.5 33,33.5 C 32.5,31 32.5,31.5 33.5,30 C 34.5,28 36,28 36,26 C 27.5,24.5 17.5,24.5 9,26 z"/>
<path d="M 11.5,30 C 15,29 30,29 33.5,30" style="fill:none"/>
<path d="M 12,33.5 C 18,32.5 27,32.5 33,33.5" style="fill:none"/>
<circle cx="6" cy="12" r="2" />
<circle cx="14" cy="9" r="2" />
<circle cx="22.5" cy="8" r="2" />
<circle cx="31" cy="9" r="2" />
<circle cx="39" cy="12" r="2" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
<g style="opacity:1; fill:#ffffff; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.3)">
<path
d="M 9,39 L 36,39 L 36,36 L 9,36 L 9,39 z "
style="stroke-linecap:butt;" />
<path
d="M 12,36 L 12,32 L 33,32 L 33,36 L 12,36 z "
style="stroke-linecap:butt;" />
<path
d="M 11,14 L 11,9 L 15,9 L 15,11 L 20,11 L 20,9 L 25,9 L 25,11 L 30,11 L 30,9 L 34,9 L 34,14"
style="stroke-linecap:butt;" />
<path
d="M 34,14 L 31,17 L 14,17 L 11,14" />
<path
d="M 31,17 L 31,29.5 L 14,29.5 L 14,17"
style="stroke-linecap:butt; stroke-linejoin:miter;" />
<path
d="M 31,29.5 L 32.5,32 L 12.5,32 L 14,29.5" />
<path
d="M 11,14 L 34,14"
style="fill:none; stroke:#000000; stroke-linejoin:miter;" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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<SoundName, string> = {
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
}
}

View file

@ -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<ChessEngine | null>(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,
};
}

View file

@ -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<number, PieceState>();
@ -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 && (
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
)}
{isHighlighted && (
<div className="absolute inset-0 bg-green-400/60 pointer-events-none ring-4 ring-inset ring-green-500/70 z-10" />
<div className="absolute inset-0 bg-black/15 pointer-events-none ring-4 ring-inset ring-black/20 z-10 rounded-full m-4" />
)}
{isCheckedKing && (
<motion.div
className="absolute inset-0 bg-red-500/40 pointer-events-none z-10"
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
/>
)}
{piece && (
<div className="absolute inset-0 z-20">
<Piece
color={piece.color}
type={piece.type}
pieceId={piece.id}
square={sq}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</div>
)}
<AnimatePresence>
{piece && (
<motion.div
key={piece.id}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0, transition: { duration: 0.2 } }}
className="absolute inset-0 z-20"
>
<Piece
color={piece.color}
type={piece.type}
pieceId={piece.id}
square={sq}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</motion.div>
)}
</AnimatePresence>
{/* Rank/File labels (optional but helpful for debug) */}
{f === 0 && (
<div className={`absolute top-1 left-1 text-xs font-semibold select-none ${isDark ? 'text-amber-100' : 'text-amber-700'}`}>
<div className={`absolute top-1 left-1 text-xs font-bold select-none ${isDark ? 'text-[#F0D9B5]/70' : 'text-[#B58863]/70'}`}>
{r + 1}
</div>
)}
{r === 0 && (
<div className={`absolute bottom-1 right-1 text-xs font-semibold select-none ${isDark ? 'text-amber-100' : 'text-amber-700'}`}>
<div className={`absolute bottom-1 right-1 text-xs font-bold select-none ${isDark ? 'text-[#F0D9B5]/70' : 'text-[#B58863]/70'}`}>
{String.fromCharCode(97 + f)}
</div>
)}
@ -148,8 +190,41 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
}
return (
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto border-4 border-amber-900 rounded shadow-2xl overflow-hidden">
{squares}
<div className="relative">
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] overflow-hidden bg-neutral-900 border-4 border-neutral-800">
{squares}
</div>
<AnimatePresence>
{promotionMove && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white/90 backdrop-blur-md p-4 rounded-2xl shadow-2xl z-50 flex gap-2 border border-neutral-200"
data-testid="promotion-picker"
>
{(['queen', 'rook', 'bishop', 'knight'] as const).map((type) => (
<button
key={type}
data-action={`promote-${type}`}
onClick={() => {
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"
>
<img
src={pieceAssets[promotionMove.color][type]}
alt={type}
className="w-4/5 h-4/5 drop-shadow-md"
draggable={false}
/>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -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<typeof useChessEngine>;
@ -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 (
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
>
<RulesDrawer />
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
{/* Header/Info section */}
<div className="flex flex-col gap-4">
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
Chess
</h1>
<div className="flex items-center gap-4">
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
Chess
</h1>
<button
onClick={toggleMute}
className="p-2 text-neutral-500 hover:text-neutral-800 hover:bg-neutral-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
title={isMuted ? "Unmute sounds" : "Mute sounds"}
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
<div className="flex items-center gap-3">
<div
data-testid="turn-indicator"
className="flex items-center gap-2 px-4 py-2 bg-neutral-100 border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
className="flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
>
<div
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
@ -42,7 +111,8 @@ export function GameView({ engineState }: GameViewProps) {
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
</div>
<button
<motion.button
whileTap={canUndo ? { scale: 0.95 } : {}}
data-action="undo"
onClick={undo}
disabled={!canUndo}
@ -50,19 +120,24 @@ export function GameView({ engineState }: GameViewProps) {
title="Undo last move"
>
Undo
</button>
</motion.button>
</div>
</div>
{/* Game Result Banner */}
{isGameOver && (
<div
data-testid="game-over"
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
>
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
</div>
)}
<AnimatePresence>
{isGameOver && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', damping: 20 }}
data-testid="game-over"
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
>
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
</motion.div>
)}
</AnimatePresence>
</div>
<div className="w-full px-4 relative">
@ -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 && (
<div className="absolute inset-0 bg-white/20 backdrop-blur-[1px] pointer-events-none z-30" />
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
/>
)}
</div>
</div>
</motion.div>
);
}

View file

@ -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);
};

View file

@ -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<string, string> }).env?.['VITE_WS_URL'] ??
@ -131,49 +133,62 @@ export function Lobby() {
return (
<main
data-testid="page-home"
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
className="min-h-screen bg-gradient-to-br from-neutral-100 to-neutral-200 flex flex-col items-center justify-center p-4 relative overflow-hidden"
>
<div className="w-full max-w-md bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-6 text-center border-b border-slate-100 bg-slate-50/50">
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Chess</h1>
<p className="text-slate-500 text-sm mt-1">Play realtime multiplayer</p>
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0IiBoZWlnaHQ9IjQiPgo8cmVjdCB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSIjZmZmIiAvPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSIjY2NjIiAvPgo8L3N2Zz4=')] opacity-50 z-0"></div>
<motion.div
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", damping: 15, stiffness: 200 }}
className="w-24 h-24 mb-8 bg-white rounded-2xl shadow-xl border border-neutral-200/50 flex items-center justify-center z-10"
>
<img src={pieceAssets.white.knight} alt="Knight" className="w-16 h-16 drop-shadow-md" draggable={false} />
</motion.div>
<div className="w-full max-w-md bg-white/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/50 overflow-hidden z-10 relative">
<div className="p-8 text-center border-b border-neutral-200/50">
<h1 className="text-3xl font-extrabold text-neutral-900 tracking-tight">Paratype Chess</h1>
<p className="text-neutral-500 font-medium mt-2">Realtime multiplayer with custom rules</p>
<button
data-action="start-new-game"
onClick={() => 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)
</button>
</div>
<div className="p-6 space-y-8">
<div className="p-8 space-y-8">
<section className="space-y-4">
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wider">
New Game
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Host Game
</h2>
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
{!roomCode ? (
<button
data-action="create-room"
onClick={handleCreate}
disabled={loading}
className="w-full bg-slate-900 text-white font-medium py-2.5 px-4 rounded-md hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="w-full bg-neutral-900 text-white font-bold py-3 px-4 rounded-lg hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md active:scale-[0.98]"
>
{loading && !codeInput ? 'Creating...' : 'Create Room'}
</button>
) : (
<div className="space-y-4">
<div className="text-center">
<span className="text-sm text-slate-500 block mb-1">Room code:</span>
<span className="text-sm font-medium text-neutral-500 block mb-2">Room Code</span>
<span
data-testid="room-code"
className="text-2xl font-mono font-bold tracking-widest text-slate-900 bg-white border border-slate-200 rounded px-4 py-2 inline-block"
className="text-3xl font-mono font-black tracking-[0.2em] text-neutral-900 bg-white border border-neutral-200 shadow-sm rounded-lg px-6 py-3 inline-block"
style={{ fontFeatureSettings: '"ss01", "cv11"' }}
>
{roomCode}
</span>
</div>
<button
onClick={() => 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
</button>
@ -184,15 +199,15 @@ export function Lobby() {
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-200"></div>
<div className="w-full border-t border-neutral-200/80"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-slate-400">or</span>
<div className="relative flex justify-center text-sm font-medium">
<span className="px-3 bg-[#f8f9fa] text-neutral-400">or</span>
</div>
</div>
<section className="space-y-4">
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wider">
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Join Game
</h2>
<div className="space-y-3">
@ -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"' }}
/>
<button
data-action="join-room"
onClick={handleJoin}
disabled={loading || !codeInput.trim()}
className="w-full bg-white text-slate-900 font-medium py-2.5 px-4 rounded-md border border-slate-300 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="w-full bg-white text-neutral-900 font-bold py-3 px-4 rounded-lg border border-neutral-200 hover:bg-neutral-50 hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm active:scale-[0.98]"
>
{loading && codeInput ? 'Joining...' : 'Join Room'}
</button>
@ -217,14 +233,19 @@ export function Lobby() {
</section>
</div>
{error && (
<div
data-testid="lobby-error"
className="bg-red-50 border-t border-red-100 p-4 text-center"
>
<p className="text-sm text-red-600 font-medium">{error}</p>
</div>
)}
<AnimatePresence>
{error && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
data-testid="lobby-error"
className="bg-red-50 border-t border-red-100 p-4 text-center overflow-hidden"
>
<p className="text-sm text-red-600 font-semibold">{error}</p>
</motion.div>
)}
</AnimatePresence>
</div>
</main>
);

View file

@ -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<PieceColor, Record<PieceType, string>> = {
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 `<div>` 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<HTMLDivElement>) => {
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 (
<div
draggable
data-piece={`${color}-${type}`}
data-piece-id={pieceId}
onDragStart={(e) => {
// 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"
<motion.div
layoutId={`piece-${pieceId}`}
className="w-full h-full"
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
{symbol}
</div>
<div
draggable
data-piece={`${color}-${type}`}
data-piece-id={pieceId}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
className="flex items-center justify-center w-full h-full cursor-grab active:cursor-grabbing select-none"
>
<img
src={imgSrc}
alt={`${color} ${type}`}
className="w-[85%] h-[85%] drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)] pointer-events-none"
draggable={false}
/>
</div>
</motion.div>
);
}

View file

@ -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 */}
<button
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button"
data-action="open-rules-drawer"
onClick={() => 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"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
</svg>
<Settings2 size={16} />
Rules
{activeIds.size > 0 && (
<span className="bg-blue-600 text-white rounded-full px-2 py-0.5 text-xs font-bold">
<span className="bg-neutral-800 text-white rounded-full px-2 py-0.5 text-[10px] font-bold ml-1">
{activeIds.size}
</span>
)}
</button>
</motion.button>
{/* Backdrop + drawer */}
{open && (
<>
<div
className="fixed inset-0 bg-black/30 z-40 transition-opacity"
onClick={() => setOpen(false)}
/>
<aside
data-testid="rules-drawer"
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden animate-slide-in"
style={{ animation: 'slide-in 0.2s ease-out' }}
>
<header className="p-5 border-b border-neutral-200 flex items-center justify-between bg-neutral-50">
<div>
<h2 className="text-xl font-bold text-neutral-900">Live Rules</h2>
<p className="text-xs text-neutral-500 mt-0.5">
Toggle rules mid-game applies on the next move
</p>
</div>
<button
type="button"
data-action="close-rules-drawer"
onClick={() => setOpen(false)}
className="p-2 hover:bg-neutral-200 rounded-md transition-colors text-neutral-600"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</header>
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40"
onClick={() => setOpen(false)}
/>
<motion.aside
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
data-testid="rules-drawer"
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden border-l border-neutral-200/50"
>
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
<div>
<h2 className="text-xl font-bold tracking-tight text-neutral-900">Live Rules</h2>
<p className="text-sm text-neutral-500 mt-1">
Toggle rules mid-game
</p>
</div>
<button
type="button"
data-action="close-rules-drawer"
onClick={() => setOpen(false)}
className="p-2 hover:bg-neutral-100 rounded-full transition-colors text-neutral-400 hover:text-neutral-700"
aria-label="Close"
>
<X size={20} />
</button>
</header>
<div className="flex-1 overflow-y-auto p-5 space-y-3">
{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);
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{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 (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-lg p-3 transition-colors ${
isOn
? 'border-blue-300 bg-blue-50'
: blocked
? 'border-neutral-200 bg-neutral-50 opacity-60'
: 'border-neutral-200 hover:border-blue-300'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-900 text-sm">
{preset.name}
</h3>
<p className="text-xs text-neutral-600 mt-0.5 leading-relaxed">
{preset.description}
</p>
{blockedBy.length > 0 && (
<p className="text-xs text-amber-700 mt-1.5">
Conflicts with active: {blockedBy.join(', ')}
return (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-xl p-4 transition-all duration-200 ${
isOn
? 'border-neutral-300 bg-neutral-50 shadow-sm'
: blocked
? 'border-neutral-100 bg-neutral-50/50 opacity-60'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-900 text-sm">
{preset.name}
</h3>
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
{preset.description}
</p>
)}
{missingReqs.length > 0 && (
<p className="text-xs text-amber-700 mt-1.5">
Requires: {missingReqs.join(', ')}
</p>
)}
{blockedBy.length > 0 && (
<p className="text-xs font-medium text-amber-600 mt-2">
Conflicts with active: {blockedBy.join(', ')}
</p>
)}
{missingReqs.length > 0 && (
<p className="text-xs font-medium text-amber-600 mt-2">
Requires: {missingReqs.join(', ')}
</p>
)}
</div>
<button
type="button"
data-role="toggle"
disabled={blocked}
onClick={() => 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}
>
<motion.span
layout
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
isOn ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<button
type="button"
data-role="toggle"
disabled={blocked}
onClick={() => 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}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition ${
isOn ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
);
})}
</div>
);
})}
</div>
<footer className="p-4 border-t border-neutral-200 bg-neutral-50 text-xs text-neutral-500 text-center">
{activeIds.size === 0
? 'Standard FIDE chess — no presets active'
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
</footer>
</aside>
</>
)}
<footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
{activeIds.size === 0
? 'Standard FIDE chess — no presets active'
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
</footer>
</motion.aside>
</>
)}
</AnimatePresence>
</>
);
}

View file

@ -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) {
<div className="flex gap-2">
<button
data-action="load"
onClick={() => 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

21
packages/chess/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
/// <reference types="vite/client" />
// 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<SVGProps<SVGSVGElement>>;
export default ReactComponent;
}