feat(chess): hover affordance on draggable pieces

Pieces you can actually pick up (your color, your turn, game ongoing)
now lift slightly on mouseover — spring scale to 1.08 with a deeper
drop shadow — so you see at a glance which piece you are about to
grab. Opponent pieces and your pieces on the opponent\`s turn stay
static, so the hover never advertises an action you cannot take.
The effect is suppressed while actively dragging; the larger drag
scale already owns the visual treatment there.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 14:55:37 -06:00
commit 598370fe2e
No known key found for this signature in database

View file

@ -110,6 +110,11 @@ export function Piece({
const staleStashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const [isDragging, setIsDragging] = useState(false);
// Hover state — tracked only when the piece is draggable (i.e. it's
// this player's turn AND this is one of their pieces). Feeds a subtle
// scale-up + brighter shadow so you can see at a glance which piece
// you're about to pick up.
const [isHovered, setIsHovered] = useState(false);
const detachDragOver = () => {
if (dragOverHandlerRef.current) {
@ -274,14 +279,28 @@ export function Piece({
? 'cursor-grab active:cursor-grabbing'
: 'cursor-default';
// Only apply hover affordance when the piece can actually be picked
// up. On opponent pieces (or your pieces on the opponent's turn) the
// hover is a no-op so we don't advertise an action the player can't
// take. We also suppress it while actively dragging — the scale-up
// there is owned by the drag treatment.
const showHover = isHovered && isDraggable && !isDragging;
return (
<div className="w-full h-full">
<div
draggable={isDraggable}
data-piece={`${color}-${type}`}
data-piece-id={pieceId}
data-hovered={showHover ? 'true' : 'false'}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
// Mouse hover toggles are gated to draggable pieces inside
// `showHover` so we don't need to branch the handlers; keeping
// them unconditional means the state is correct if `isDraggable`
// flips mid-hover (e.g. a move completes while hovering).
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`flex items-center justify-center w-full h-full select-none ${cursorClass}`}
>
<motion.div
@ -289,8 +308,14 @@ export function Piece({
className={`flex items-center justify-center w-full h-full ${isDragging ? 'pointer-events-none' : ''}`}
style={{ x, y, rotate }}
animate={{
scale: isDragging ? 1.15 : 1,
zIndex: isDragging ? 50 : 0,
// Scale priorities (highest wins):
// isDragging -> 1.15 (big lift, matches drag juice)
// showHover -> 1.08 (subtle pickup affordance)
// otherwise -> 1.00
// zIndex bumps on hover too so the enlarged piece overlaps
// neighbours cleanly without clipping.
scale: isDragging ? 1.15 : showHover ? 1.08 : 1,
zIndex: isDragging ? 50 : showHover ? 10 : 0,
}}
transition={{
scale: { type: 'spring', stiffness: 500, damping: 30 },
@ -303,7 +328,9 @@ export function Piece({
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
isDragging
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
: 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]'
: showHover
? 'drop-shadow-[0_8px_10px_rgba(0,0,0,0.4)]'
: 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]'
}`}
draggable={false}
/>