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": { "dependencies": {
"@paratype/rete": "workspace:*", "@paratype/rete": "workspace:*",
"@tailwindcss/vite": "^4.2.2", "@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", "react-router-dom": "^7.14.1",
"sonner": "^2.0.7",
"tailwindcss": "^4.2.2" "tailwindcss": "^4.2.2"
}, },
"devDependencies": { "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", "@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@types/react": "^19.0.0", "vite": "^6.0.0"
"@types/react-dom": "^19.0.0"
} }
} }

View file

@ -1,5 +1,5 @@
import { useEffect } from 'react' 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 { Lobby } from '../ui/Lobby'
import { GameView } from '../ui/GameView' import { GameView } from '../ui/GameView'
import { RulesView } from '../ui/RulesView' import { RulesView } from '../ui/RulesView'
@ -9,9 +9,12 @@ import { useChessEngine } from '../hooks/useChessEngine'
import { ChessEngine } from '../engine' import { ChessEngine } from '../engine'
import { loadAutoSave } from '../persist/autosave.js' import { loadAutoSave } from '../persist/autosave.js'
import type { AttrKey, FactValue, EntityId } from '@paratype/rete' import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
import { AnimatePresence, motion } from 'motion/react'
import { Toaster } from 'sonner'
export function App() { export function App() {
const chessState = useChessEngine() const chessState = useChessEngine()
const location = useLocation()
// Restore autosave on mount — [] deps is intentional (run once, avoid infinite loop) // Restore autosave on mount — [] deps is intentional (run once, avoid infinite loop)
useEffect(() => { useEffect(() => {
@ -25,17 +28,33 @@ export function App() {
}, []); // intentional: run once on mount, chessState ref is stable }, []); // intentional: run once on mount, chessState ref is stable
return ( return (
<div data-testid="app-root"> <div data-testid="app-root" className="min-h-screen bg-neutral-50 text-neutral-900 font-sans selection:bg-amber-200">
<Routes> <Toaster position="top-center" richColors closeButton />
<Route path="/" element={<Lobby />} /> <AnimatePresence mode="wait">
<Route path="/game" element={<GameView engineState={chessState} />} /> <Routes location={location} key={location.pathname}>
<Route path="/rules" element={<RulesView chessState={chessState} />} /> <Route path="/" element={<PageTransition><Lobby /></PageTransition>} />
<Route path="/save" element={<SaveWrapper chessState={chessState} />} /> <Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
</Routes> <Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
</Routes>
</AnimatePresence>
</div> </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> }) { function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
const navigate = useNavigate() const navigate = useNavigate()
@ -53,7 +72,7 @@ function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEng
} }
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8 py-8">
<SavePanel <SavePanel
currentFacts={chessState.facts} currentFacts={chessState.facts}
onLoad={handleLoad} 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 { ChessEngine, type GameResult } from '../engine';
import type { PieceType } from '../schema'; import type { PieceType } from '../schema';
import { saveAutoSave } from '../persist/autosave.js'; import { saveAutoSave } from '../persist/autosave.js';
import * as audio from '../audio';
export function useChessEngine() { export function useChessEngine() {
const engineRef = useRef<ChessEngine | null>(null); const engineRef = useRef<ChessEngine | null>(null);
@ -47,6 +48,20 @@ export function useChessEngine() {
const result = engine.applyMove(move, promoteTo); const result = engine.applyMove(move, promoteTo);
saveAutoSave(engine.session.allFacts()); saveAutoSave(engine.session.allFacts());
setTick(t => t + 1); // trigger re-render 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 result;
} }
return null; return null;
@ -68,6 +83,10 @@ export function useChessEngine() {
} }
}, []); }, []);
const lastMove = moveHistoryRef.current.length > 0
? moveHistoryRef.current[moveHistoryRef.current.length - 1]
: null;
return { return {
engine, engine,
turn: getTurn(), turn: getTurn(),
@ -78,5 +97,6 @@ export function useChessEngine() {
undo, undo,
canUndo: moveHistoryRef.current.length > 0, canUndo: moveHistoryRef.current.length > 0,
loadEngine, loadEngine,
lastMove,
}; };
} }

View file

@ -3,12 +3,18 @@ import type { ChessFact, PieceColor, PieceType } from '../schema';
import { squareToAlgebraic, squareOf, squareColor } from '../coord'; import { squareToAlgebraic, squareOf, squareColor } from '../coord';
import type { LegalMove } from '../rules/types'; import type { LegalMove } from '../rules/types';
import { Piece } from './Piece'; import { Piece } from './Piece';
import { AnimatePresence, motion } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
interface BoardProps { interface BoardProps {
facts: ChessFact[]; facts: ChessFact[];
legalMoves: LegalMove[]; legalMoves: LegalMove[];
onMove: (from: number, to: number) => void; onMove: (from: number, to: number, promoteTo?: PieceType) => void;
turn: PieceColor; 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 { interface PieceState {
@ -17,7 +23,7 @@ interface PieceState {
color: PieceColor; 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 } // Build pieces map: square -> { id, type, color }
const pieces = useMemo(() => { const pieces = useMemo(() => {
const map = new Map<number, PieceState>(); const map = new Map<number, PieceState>();
@ -51,6 +57,9 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
// Drag state // Drag state
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null); 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 // Compute highlighted squares based on current dragged piece
const highlightedSquares = useMemo(() => { const highlightedSquares = useMemo(() => {
@ -88,7 +97,18 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
const handleDrop = (e: React.DragEvent, targetSquare: number) => { const handleDrop = (e: React.DragEvent, targetSquare: number) => {
e.preventDefault(); e.preventDefault();
if (draggedPiece && highlightedSquares.has(targetSquare)) { 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); setDraggedPiece(null);
}; };
@ -101,6 +121,8 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
const isDark = squareColor(sq) === 'dark'; const isDark = squareColor(sq) === 'dark';
const algebraic = squareToAlgebraic(sq); const algebraic = squareToAlgebraic(sq);
const isHighlighted = highlightedSquares.has(sq); const isHighlighted = highlightedSquares.has(sq);
const isLastMove = lastMove?.from === sq || lastMove?.to === sq;
const isCheckedKing = checkedKingSquare === sq;
const piece = pieces.get(sq); const piece = pieces.get(sq);
@ -109,36 +131,56 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
key={sq} key={sq}
data-square={algebraic} data-square={algebraic}
className={`relative aspect-square flex items-center justify-center ${ 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)} onDragOver={(e) => handleDragOver(e, sq)}
onDrop={(e) => handleDrop(e, sq)} onDrop={(e) => handleDrop(e, sq)}
> >
{isLastMove && (
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
)}
{isHighlighted && ( {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 && ( <AnimatePresence>
<div className="absolute inset-0 z-20"> {piece && (
<Piece <motion.div
color={piece.color} key={piece.id}
type={piece.type} initial={{ opacity: 0, scale: 0.5 }}
pieceId={piece.id} animate={{ opacity: 1, scale: 1 }}
square={sq} exit={{ opacity: 0, scale: 0, transition: { duration: 0.2 } }}
onDragStart={handleDragStart} className="absolute inset-0 z-20"
onDragEnd={handleDragEnd} >
/> <Piece
</div> 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) */} {/* Rank/File labels (optional but helpful for debug) */}
{f === 0 && ( {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} {r + 1}
</div> </div>
)} )}
{r === 0 && ( {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)} {String.fromCharCode(97 + f)}
</div> </div>
)} )}
@ -148,8 +190,41 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
} }
return ( 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"> <div className="relative">
{squares} <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> </div>
); );
} }

View file

@ -1,7 +1,12 @@
import { Board } from './Board'; import { Board } from './Board';
import { RulesDrawer } from './RulesDrawer'; import { RulesDrawer } from './RulesDrawer';
import { useChessEngine } from '../hooks/useChessEngine'; 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 { interface GameViewProps {
engineState?: ReturnType<typeof useChessEngine>; engineState?: ReturnType<typeof useChessEngine>;
@ -12,29 +17,93 @@ export function GameView({ engineState }: GameViewProps) {
const localChessState = useChessEngine(); const localChessState = useChessEngine();
const state = engineState || localChessState; 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) => { const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
applyMove(from, to, 'queen'); applyMove(from, to, promoteTo || 'queen');
}; };
const isGameOver = result !== 'ongoing'; 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 ( 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 /> <RulesDrawer />
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4"> <div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
{/* Header/Info section */} {/* Header/Info section */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<h1 className="text-3xl font-bold tracking-tight text-neutral-800"> <div className="flex items-center gap-4">
Chess <h1 className="text-3xl font-bold tracking-tight text-neutral-800">
</h1> 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 className="flex items-center gap-3">
<div <div
data-testid="turn-indicator" 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 <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'}`} 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> <span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
</div> </div>
<button <motion.button
whileTap={canUndo ? { scale: 0.95 } : {}}
data-action="undo" data-action="undo"
onClick={undo} onClick={undo}
disabled={!canUndo} disabled={!canUndo}
@ -50,19 +120,24 @@ export function GameView({ engineState }: GameViewProps) {
title="Undo last move" title="Undo last move"
> >
Undo Undo
</button> </motion.button>
</div> </div>
</div> </div>
{/* Game Result Banner */} {/* Game Result Banner */}
{isGameOver && ( <AnimatePresence>
<div {isGameOver && (
data-testid="game-over" <motion.div
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm" initial={{ opacity: 0, scale: 0.9, y: -20 }}
> animate={{ opacity: 1, scale: 1, y: 0 }}
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`} transition={{ type: 'spring', damping: 20 }}
</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-', '')}`}
</motion.div>
)}
</AnimatePresence>
</div> </div>
<div className="w-full px-4 relative"> <div className="w-full px-4 relative">
@ -71,14 +146,20 @@ export function GameView({ engineState }: GameViewProps) {
legalMoves={legalMoves} legalMoves={legalMoves}
turn={turn} turn={turn}
onMove={handleMove} onMove={handleMove}
lastMove={lastMove}
checkedKingSquare={checkedKingSquare}
/> />
{/* Overlay for game over to prevent further interaction visually */} {/* Overlay for game over to prevent further interaction visually */}
{isGameOver && ( {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>
</div> </motion.div>
); );
} }

View file

@ -1,5 +1,6 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { exportGame, importGame } from '../persist/io'; import { exportGame, importGame } from '../persist/io';
import { toast } from 'sonner';
interface ImportExportProps { interface ImportExportProps {
currentFacts?: Array<{ id: number; attr: string; value: unknown }>; 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.download = `chess-game-${Date.now()}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success('Game exported');
} catch (err) { } catch (err) {
console.error('Export failed:', 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 text = event.target?.result as string;
const facts = importGame(text); const facts = importGame(text);
onLoad(facts); onLoad(facts);
toast.success('Game imported');
// Reset file input so the same file can be selected again // Reset file input so the same file can be selected again
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} catch (err) { } 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 = () => { reader.onerror = () => {
setError('Failed to read file'); setError('Failed to read file');
toast.error('Failed to read file');
}; };
reader.readAsText(file); reader.readAsText(file);
}; };

View file

@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { pieceAssets } from '../assets/pieces';
const WS_URL = const WS_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ?? (import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
@ -131,49 +133,62 @@ export function Lobby() {
return ( return (
<main <main
data-testid="page-home" 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="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0IiBoZWlnaHQ9IjQiPgo8cmVjdCB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSIjZmZmIiAvPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSIjY2NjIiAvPgo8L3N2Zz4=')] opacity-50 z-0"></div>
<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> <motion.div
<p className="text-slate-500 text-sm mt-1">Play realtime multiplayer</p> 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 <button
data-action="start-new-game"
onClick={() => navigate('/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> </button>
</div> </div>
<div className="p-6 space-y-8"> <div className="p-8 space-y-8">
<section className="space-y-4"> <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">
New Game Host Game
</h2> </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 ? ( {!roomCode ? (
<button <button
data-action="create-room" data-action="create-room"
onClick={handleCreate} onClick={handleCreate}
disabled={loading} 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'} {loading && !codeInput ? 'Creating...' : 'Create Room'}
</button> </button>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-center"> <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 <span
data-testid="room-code" 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} {roomCode}
</span> </span>
</div> </div>
<button <button
onClick={() => navigate('/game')} 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 Enter Room
</button> </button>
@ -184,15 +199,15 @@ export function Lobby() {
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <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>
<div className="relative flex justify-center text-sm"> <div className="relative flex justify-center text-sm font-medium">
<span className="px-2 bg-white text-slate-400">or</span> <span className="px-3 bg-[#f8f9fa] text-neutral-400">or</span>
</div> </div>
</div> </div>
<section className="space-y-4"> <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 Join Game
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
@ -203,13 +218,14 @@ export function Lobby() {
onChange={(e) => setCodeInput(e.target.value.toUpperCase())} onChange={(e) => setCodeInput(e.target.value.toUpperCase())}
placeholder="Enter 6-letter code" placeholder="Enter 6-letter code"
maxLength={6} 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 <button
data-action="join-room" data-action="join-room"
onClick={handleJoin} onClick={handleJoin}
disabled={loading || !codeInput.trim()} 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'} {loading && codeInput ? 'Joining...' : 'Join Room'}
</button> </button>
@ -217,14 +233,19 @@ export function Lobby() {
</section> </section>
</div> </div>
{error && ( <AnimatePresence>
<div {error && (
data-testid="lobby-error" <motion.div
className="bg-red-50 border-t border-red-100 p-4 text-center" initial={{ height: 0, opacity: 0 }}
> animate={{ height: 'auto', opacity: 1 }}
<p className="text-sm text-red-600 font-medium">{error}</p> exit={{ height: 0, opacity: 0 }}
</div> 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> </div>
</main> </main>
); );

View file

@ -1,4 +1,7 @@
import type { PieceColor, PieceType } from '../schema'; 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 { export interface PieceProps {
color: PieceColor; color: PieceColor;
@ -9,44 +12,55 @@ export interface PieceProps {
onDragEnd: () => void; onDragEnd: () => void;
} }
const PIECE_SYMBOLS: Record<PieceColor, Record<PieceType, string>> = { /**
white: { * Piece component.
king: '♔', *
queen: '♕', * We wrap the draggable DOM node in an outer `motion.div` that owns the
rook: '♖', * FLIP layout animation (via `layoutId`). The inner plain `<div>` owns the
bishop: '♗', * HTML5 native drag-and-drop: Playwright's `locator.dragTo()` and the
knight: '♘', * browser's DnD subsystem both dispatch events on this node. Keeping the
pawn: '♙', * two responsibilities separate avoids a type clash — `motion.div`'s
}, * `onDragStart` prop is typed for its own pointer-based drag gesture,
black: { * which is incompatible with `React.DragEvent`. The extra DOM node costs
king: '♚', * nothing and sidesteps the cast.
queen: '♛', */
rook: '♜',
bishop: '♝',
knight: '♞',
pawn: '♟',
},
};
export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) { 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 ( return (
<div <motion.div
draggable layoutId={`piece-${pieceId}`}
data-piece={`${color}-${type}`} className="w-full h-full"
data-piece-id={pieceId} transition={{ type: 'spring', stiffness: 400, damping: 25 }}
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"
> >
{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 { useState, useEffect } from 'react';
import { PRESET_REGISTRY } from '../presets/index.js'; 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. * A collapsible side-drawer for toggling preset rules mid-game.
@ -29,16 +32,20 @@ export function RulesDrawer() {
const presets = PRESET_REGISTRY.getAll(); const presets = PRESET_REGISTRY.getAll();
const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id)); const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id));
const toggle = (id: string) => { const toggle = (id: string, name: string) => {
if (activeIds.has(id)) { const isActivating = !activeIds.has(id);
if (!isActivating) {
PRESET_REGISTRY.deactivate(id); PRESET_REGISTRY.deactivate(id);
toast(`Preset disabled: ${name}`);
} else { } else {
try { try {
PRESET_REGISTRY.activate(id); PRESET_REGISTRY.activate(id);
toast.info(`Preset enabled: ${name}`);
} catch (err) { } catch (err) {
// activate() throws for missing requires or incompatibilities. We // activate() throws for missing requires or incompatibilities. We
// surface the reason via the UI only for the user's next refresh. // surface the reason via the UI only for the user's next refresh.
console.warn(`Could not activate ${id}:`, err); console.warn(`Could not activate ${id}:`, err);
toast.error(`Could not activate ${name}`);
} }
} }
setTick((t) => t + 1); setTick((t) => t + 1);
@ -47,131 +54,138 @@ export function RulesDrawer() {
return ( return (
<> <>
{/* Trigger pill — always visible, top-right of game view */} {/* Trigger pill — always visible, top-right of game view */}
<button <motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="button" type="button"
data-action="open-rules-drawer" data-action="open-rules-drawer"
onClick={() => setOpen(true)} 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" aria-label="Toggle rules"
> >
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> <Settings2 size={16} />
<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>
Rules Rules
{activeIds.size > 0 && ( {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} {activeIds.size}
</span> </span>
)} )}
</button> </motion.button>
{/* Backdrop + drawer */} {/* Backdrop + drawer */}
{open && ( <AnimatePresence>
<> {open && (
<div <>
className="fixed inset-0 bg-black/30 z-40 transition-opacity" <motion.div
onClick={() => setOpen(false)} initial={{ opacity: 0 }}
/> animate={{ opacity: 1 }}
<aside exit={{ opacity: 0 }}
data-testid="rules-drawer" className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40"
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" onClick={() => setOpen(false)}
style={{ animation: 'slide-in 0.2s ease-out' }} />
> <motion.aside
<header className="p-5 border-b border-neutral-200 flex items-center justify-between bg-neutral-50"> initial={{ x: '100%' }}
<div> animate={{ x: 0 }}
<h2 className="text-xl font-bold text-neutral-900">Live Rules</h2> exit={{ x: '100%' }}
<p className="text-xs text-neutral-500 mt-0.5"> transition={{ type: 'spring', damping: 25, stiffness: 200 }}
Toggle rules mid-game — applies on the next move data-testid="rules-drawer"
</p> 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"
</div> >
<button <header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
type="button" <div>
data-action="close-rules-drawer" <h2 className="text-xl font-bold tracking-tight text-neutral-900">Live Rules</h2>
onClick={() => setOpen(false)} <p className="text-sm text-neutral-500 mt-1">
className="p-2 hover:bg-neutral-200 rounded-md transition-colors text-neutral-600" Toggle rules mid-game
aria-label="Close" </p>
> </div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <button
<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" /> type="button"
</svg> data-action="close-rules-drawer"
</button> onClick={() => setOpen(false)}
</header> 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"> <div className="flex-1 overflow-y-auto p-6 space-y-4">
{presets.map((preset) => { {presets.map((preset) => {
const isOn = activeIds.has(preset.id); const isOn = activeIds.has(preset.id);
const blockedBy = preset.incompatibleWith.filter((id) => const blockedBy = preset.incompatibleWith.filter((id) =>
activeIds.has(id), activeIds.has(id),
); );
const missingReqs = preset.requires.filter( const missingReqs = preset.requires.filter(
(id) => !activeIds.has(id), (id) => !activeIds.has(id),
); );
const blocked = const blocked =
!isOn && (blockedBy.length > 0 || missingReqs.length > 0); !isOn && (blockedBy.length > 0 || missingReqs.length > 0);
return ( return (
<div <div
key={preset.id} key={preset.id}
data-preset={preset.id} data-preset={preset.id}
className={`border rounded-lg p-3 transition-colors ${ className={`border rounded-xl p-4 transition-all duration-200 ${
isOn isOn
? 'border-blue-300 bg-blue-50' ? 'border-neutral-300 bg-neutral-50 shadow-sm'
: blocked : blocked
? 'border-neutral-200 bg-neutral-50 opacity-60' ? 'border-neutral-100 bg-neutral-50/50 opacity-60'
: 'border-neutral-200 hover:border-blue-300' : 'border-neutral-200 hover:border-neutral-300'
}`} }`}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-900 text-sm"> <h3 className="font-semibold text-neutral-900 text-sm">
{preset.name} {preset.name}
</h3> </h3>
<p className="text-xs text-neutral-600 mt-0.5 leading-relaxed"> <p className="text-sm text-neutral-500 mt-1 leading-relaxed">
{preset.description} {preset.description}
</p>
{blockedBy.length > 0 && (
<p className="text-xs text-amber-700 mt-1.5">
Conflicts with active: {blockedBy.join(', ')}
</p> </p>
)} {blockedBy.length > 0 && (
{missingReqs.length > 0 && ( <p className="text-xs font-medium text-amber-600 mt-2">
<p className="text-xs text-amber-700 mt-1.5"> Conflicts with active: {blockedBy.join(', ')}
Requires: {missingReqs.join(', ')} </p>
</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> </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>
</div>
<footer className="p-4 border-t border-neutral-200 bg-neutral-50 text-xs text-neutral-500 text-center"> <footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
{activeIds.size === 0 {activeIds.size === 0
? 'Standard FIDE chess — no presets active' ? 'Standard FIDE chess — no presets active'
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`} : `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
</footer> </footer>
</aside> </motion.aside>
</> </>
)} )}
</AnimatePresence>
</> </>
); );
} }

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { EntityId } from '@paratype/rete'; import type { EntityId } from '@paratype/rete';
import { toast } from 'sonner';
export interface SaveSlot { export interface SaveSlot {
name: string; name: string;
@ -54,11 +55,13 @@ export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${slot.name}`, JSON.stringify(slot)); localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${slot.name}`, JSON.stringify(slot));
setNewSlotName(''); setNewSlotName('');
loadSlots(); loadSlots();
toast.success('Game saved', { description: `Saved as "${slot.name}"` });
}; };
const handleDelete = (name: string) => { const handleDelete = (name: string) => {
localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${name}`); localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${name}`);
loadSlots(); loadSlots();
toast('Save deleted', { description: `Deleted "${name}"` });
}; };
return ( return (
@ -114,7 +117,10 @@ export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
data-action="load" 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" 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 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;
}