feat(chess): add rule-toggle UI with compatibility warnings (P3.11)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 16:05:04 -06:00
commit 116cbb42b5
No known key found for this signature in database
2 changed files with 106 additions and 9 deletions

View file

@ -1,5 +1,6 @@
import { Routes, Route } from 'react-router-dom'
import { GameView } from '../ui/GameView'
import { RulesView } from '../ui/RulesView'
export function App() {
return (
@ -7,7 +8,7 @@ export function App() {
<Routes>
<Route path="/" element={<Home />} />
<Route path="/game" element={<GameView />} />
<Route path="/rules" element={<Rules />} />
<Route path="/rules" element={<RulesView />} />
<Route path="/save" element={<Save />} />
</Routes>
</div>
@ -22,14 +23,6 @@ function Home() {
)
}
function Rules() {
return (
<main data-testid="page-rules">
<h1>Rules</h1>
</main>
)
}
function Save() {
return (
<main data-testid="page-save">

View file

@ -0,0 +1,104 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { PRESET_REGISTRY } from '../presets/index.js';
export function RulesView({ isGameActive }: { isGameActive?: boolean }) {
const navigate = useNavigate();
const [selected, setSelected] = useState<Set<string>>(new Set());
const presets = PRESET_REGISTRY.getAll();
const hasIncompatibilities = useMemo(() => {
for (const a of selected) {
const aDef = presets.find((p) => p.id === a);
if (!aDef) continue;
for (const b of selected) {
if (a === b) continue;
if (aDef.incompatibleWith.includes(b)) return true;
}
}
return false;
}, [selected, presets]);
const togglePreset = (id: string) => {
if (isGameActive) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleApply = () => {
setSelected(new Set());
navigate('/game');
};
return (
<div className="p-6 max-w-3xl mx-auto space-y-6" data-testid="page-rules">
<h1 className="text-3xl font-bold">Preset Rules</h1>
{isGameActive && (
<div className="bg-gray-100 text-gray-700 p-4 rounded-md">
Rules can only be changed between games
</div>
)}
{hasIncompatibilities && (
<div data-testid="compat-warning" className="bg-amber-100 text-amber-800 p-4 rounded-md font-semibold">
Warning: Some selected rules are mutually incompatible.
</div>
)}
<div className="space-y-4">
{presets.map((preset) => (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-lg p-4 flex items-center justify-between transition-colors ${
isGameActive ? 'opacity-50 grayscale' : 'hover:border-blue-400'
}`}
>
<div className="pr-4">
<h3 className="text-lg font-semibold">{preset.name}</h3>
<p className="text-sm text-gray-600 mt-1">{preset.description}</p>
</div>
<button
type="button"
data-role="toggle"
disabled={isGameActive}
onClick={() => togglePreset(preset.id)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
selected.has(preset.id) ? 'bg-blue-600' : 'bg-gray-200'
} ${isGameActive ? 'cursor-not-allowed' : ''}`}
role="switch"
aria-checked={selected.has(preset.id)}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
selected.has(preset.id) ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
))}
</div>
<div className="pt-6">
<button
type="button"
data-action="start-new-game"
onClick={handleApply}
className="w-full bg-blue-600 text-white rounded-lg py-3 px-4 font-semibold hover:bg-blue-700 transition-colors"
>
Apply and start new game
</button>
</div>
</div>
);
}