feat(chess): drop-target hover indicator + personalized turn banner

Board now renders three visual states during a drag: faint dot for
legal quiet-move targets, ring for legal capture targets, and a bright
emerald fill when the cursor is actually over a valid drop square so
the user sees exactly where the piece will land. Hovering an invalid
square shows a subtle red tint telling the player the drag will snap
back on release.

Turn indicator shows "Your turn" (with a pulsing emerald dot and
green card treatment) when it`s the local player`s turn in
multiplayer, and "Opponent`s turn" otherwise. Solo play falls back
to the neutral "White`s turn" / "Black`s turn" phrasing.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 14:41:01 -06:00
commit ae87772277
No known key found for this signature in database
3 changed files with 148 additions and 15 deletions

View file

@ -270,8 +270,11 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate",
await drag(pageB, 'b8', 'c6'); // 6. black Nc6
await expect(pageA.locator('[data-square="c6"] [data-piece="black-knight"]')).toBeVisible();
await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('White');
await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText('White');
// After 6 half-moves the turn returns to white. In multiplayer the
// indicator is personalized: the white player (pageA) sees "Your
// turn"; the black player (pageB) sees "Opponent's turn".
await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Your turn");
await expect(pageB.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn");
// ── Step 4: ctx B disconnects (simulates network drop) ───────────────────
await pageB.close();
@ -279,7 +282,8 @@ test("multiplayer: two contexts, reconnect at move 7, Scholar's Mate checkmate",
// ── Step 5: ctx A plays move 7 (Qh5) during B's grace window ─────────────
await drag(pageA, 'd1', 'h5'); // 7. white Qh5
await expect(pageA.locator('[data-square="h5"] [data-piece="white-queen"]')).toBeVisible();
await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText('Black');
// After Qh5 it's black to move. PageA is white → sees "Opponent's turn".
await expect(pageA.locator('[data-testid="turn-indicator"]')).toContainText("Opponent's turn");
// ── Step 6: ctx B reconnects ─────────────────────────────────────────────
// A new page in the SAME browser context inherits sessionStorage

View file

@ -61,7 +61,12 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
// Drag state
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
// The square the cursor is currently hovering during a drag. Distinct
// from `draggedPiece.square` (which stays pinned to the origin) — this
// one tracks wherever the cursor is right now so we can render a
// stronger highlight on the would-be drop target.
const [hoverSquare, setHoverSquare] = useState<number | null>(null);
// Promotion picker state
const [promotionMove, setPromotionMove] = useState<{ from: number, to: number, color: PieceColor } | null>(null);
@ -91,13 +96,34 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
const handleDragEnd = () => {
setDraggedPiece(null);
setHoverSquare(null);
};
const handleDragOver = (e: React.DragEvent, square: number) => {
if (!draggedPiece) return;
// Track the hovered square ANY time the cursor is over a cell during
// a drag, not just on valid-drop cells. We still gate preventDefault
// on `highlightedSquares.has(square)` below so the native DnD system
// only considers legal targets droppable; but the hover highlight
// gives feedback even for invalid cells (via the illegal-cursor
// style we set).
if (hoverSquare !== square) setHoverSquare(square);
if (highlightedSquares.has(square)) {
e.preventDefault(); // allow drop
e.dataTransfer.dropEffect = 'move';
} else {
// Legal-move set doesn't include this square → show the "no-drop"
// cursor so the user knows releasing here will snap back.
e.dataTransfer.dropEffect = 'none';
}
};
const handleDragLeave = (e: React.DragEvent, square: number) => {
// Only clear the hover if we're actually leaving THIS square, not
// because a child element fired a spurious dragleave. Compare to
// currentTarget so cursor moves within the cell don't flicker.
if (hoverSquare === square && e.currentTarget === e.target) {
setHoverSquare(null);
}
};
@ -118,6 +144,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
}
}
setDraggedPiece(null);
setHoverSquare(null);
};
// Generate board squares (rank 7 down to 0, file 0 to 7)
@ -139,6 +166,22 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
// translated over them. Promoting the cell itself fixes that.
const isHostingDragged = draggedPiece?.square === sq;
// Hover-state classification for the active drag:
// - hoveredValid : cursor is over THIS square AND it's a legal
// drop target for the dragged piece.
// - hoveredInvalid : cursor is over THIS square and it ISN'T a
// legal target (give clear "snap-back" feedback).
// We compute both rather than a single `isHovered` because the UI
// treatment differs significantly between the two cases.
const isHovered = hoverSquare === sq && draggedPiece !== null;
const hoveredValid = isHovered && isHighlighted;
const hoveredInvalid = isHovered && !isHighlighted && !isHostingDragged;
// Is the legal target a capture? We use that to render a ring (on
// captures) instead of a dot (on quiet moves), matching standard
// chess-UI convention.
const isCaptureTarget =
isHighlighted && piece !== undefined && piece.color !== turn;
squares.push(
<div
key={sq}
@ -147,14 +190,64 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
isDark ? 'bg-[#B58863]' : 'bg-[#F0D9B5]'
} shadow-[inset_0_0_8px_rgba(0,0,0,0.15)] ${isHostingDragged ? 'z-50' : ''}`}
onDragOver={(e) => handleDragOver(e, sq)}
onDragLeave={(e) => handleDragLeave(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-black/15 pointer-events-none ring-4 ring-inset ring-black/20 z-10 rounded-full m-4" />
{/*
* Legal-target affordance — two visual forms:
* - Quiet move (empty destination): small central dot
* - Capture (destination has enemy piece): ring around cell
* Both are superseded by the stronger hoveredValid treatment
* below when the cursor is actually over the cell.
*/}
{isHighlighted && !hoveredValid && !isCaptureTarget && (
<div
data-role="drop-target"
className="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
>
<div className="w-1/3 h-1/3 rounded-full bg-black/25" />
</div>
)}
{isHighlighted && !hoveredValid && isCaptureTarget && (
<div
data-role="drop-target-capture"
className="absolute inset-1 pointer-events-none z-10 rounded-full ring-[6px] ring-inset ring-black/25"
/>
)}
{/*
* Hovered + legal: bright green glow so the player sees exactly
* where the piece will land on release. Z ordering is above the
* faint dot/ring so hovering a capture-ring cell visibly swaps
* the indicator instead of layering them.
*/}
{hoveredValid && (
<motion.div
data-role="drop-hover-valid"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={`absolute inset-0 pointer-events-none z-10 ${
isCaptureTarget
? 'ring-[6px] ring-inset ring-emerald-400/80 bg-emerald-400/25'
: 'bg-emerald-400/35 ring-4 ring-inset ring-emerald-500/50'
}`}
/>
)}
{/*
* Hovered + illegal: subtle red tint telling the user that
* releasing here will snap the piece back to origin. Subtle
* rather than alarming — it's not an error, just a hint.
*/}
{hoveredInvalid && (
<div
data-role="drop-hover-invalid"
className="absolute inset-0 pointer-events-none z-10 bg-red-500/15"
/>
)}
{isCheckedKing && (

View file

@ -216,15 +216,51 @@ function GameLayout({
</div>
<div className="flex items-center gap-3">
<div
data-testid="turn-indicator"
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'}`}
/>
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
</div>
{/*
* Turn indicator. In multiplayer we personalize the text:
* - Your turn (turn === myColor)
* - Opponent's turn (turn !== myColor)
* In local/solo play (myColor === null) we fall back to the
* neutral "White's turn" / "Black's turn" phrasing.
*
* When it's the local player's turn we also upgrade the
* visual treatment (green tint + pulsing dot) so the call to
* action is unmistakable.
*/}
{(() => {
const isMyTurn = myColor !== null && turn === myColor;
const isOpponentTurn = myColor !== null && turn !== myColor;
const label = isMyTurn
? 'Your turn'
: isOpponentTurn
? "Opponent's turn"
: turn === 'white'
? "White's turn"
: "Black's turn";
const containerClass = isMyTurn
? 'flex items-center gap-2 px-4 py-2 bg-emerald-50 border border-emerald-300 rounded-md font-semibold text-emerald-900 shadow-sm'
: 'flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm';
return (
<div
data-testid="turn-indicator"
data-my-turn={isMyTurn ? 'true' : 'false'}
className={containerClass}
>
{isMyTurn ? (
<motion.div
className="w-3 h-3 rounded-full bg-emerald-500 shadow-inner"
animate={{ scale: [1, 1.25, 1], opacity: [1, 0.7, 1] }}
transition={{ repeat: Infinity, duration: 1.5, ease: 'easeInOut' }}
/>
) : (
<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'}`}
/>
)}
<span>{label}</span>
</div>
);
})()}
{/* Undo is only meaningful in local mode (server is authoritative
* in multiplayer); the hook exposes canUndo=false there so the