feat(rithmomachia): show capture error on hover instead of click

Changed "no valid relation" error to appear as a tooltip when hovering over
an enemy piece with a selected piece, instead of after clicking:

**CaptureErrorDialog changes:**
- Removed OK button (no manual dismiss needed)
- Made dialog non-interactive with pointerEvents: 'none'
- Simplified layout to center-aligned content

**BoardDisplay changes:**
- Added hoveredSquare state to track hovered enemy pieces
- Added handleSvgMouseMove to detect hovers over enemy pieces
- Added handleSvgMouseLeave to clear hover state
- Calculate showHoverError when hovering over invalid capture target
- Display inline hover error tooltip at hovered square position
- Auto-dismisses when mouse leaves (no manual dismiss needed)

This provides instant feedback about invalid captures before clicking,
improving the UX by showing errors as a preview rather than after attempting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-02 13:33:57 -06:00
parent 05228d0235
commit 339b6780f6
2 changed files with 127 additions and 46 deletions

View File

@@ -50,6 +50,9 @@ export function BoardDisplay() {
targetPiece: Piece
} | null>(null)
// Hover state for showing error tooltip
const [hoveredSquare, setHoveredSquare] = useState<string | null>(null)
// Handle closing animation completion
useEffect(() => {
if (closingDialog) {
@@ -306,6 +309,40 @@ export function BoardDisplay() {
}
}
const handleSvgMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!isMyTurn || !selectedSquare) {
setHoveredSquare(null)
return
}
const svg = e.currentTarget
const rect = svg.getBoundingClientRect()
const x = ((e.clientX - rect.left) / rect.width) * boardWidth - labelMargin - padding
const y = ((e.clientY - rect.top) / rect.height) * boardHeight - padding
// Convert to grid coordinates
const col = Math.floor(x / (cellSize + gap))
const row = Math.floor(y / (cellSize + gap))
if (col >= 0 && col < cols && row >= 0 && row < rows) {
const square = `${String.fromCharCode(65 + col)}${8 - row}`
const piece = Object.values(state.pieces).find((p) => p.square === square && !p.captured)
// Only set hovered square if it's an enemy piece
if (piece && piece.color !== playerColor) {
setHoveredSquare(square)
} else {
setHoveredSquare(null)
}
} else {
setHoveredSquare(null)
}
}
const handleSvgMouseLeave = () => {
setHoveredSquare(null)
}
// Calculate target square position for floating capture options
const getTargetSquarePosition = useCallback(() => {
if (!captureTarget) return null
@@ -404,6 +441,48 @@ export function BoardDisplay() {
return findAvailableRelations(moverValue, targetValue)
})()
// Calculate if hovered square shows error (for hover preview)
const showHoverError = (() => {
if (!hoveredSquare || !selectedSquare || captureDialogOpen) return false
const moverPiece = Object.values(state.pieces).find(
(p) => p.square === selectedSquare && !p.captured
)
const targetPiece = Object.values(state.pieces).find(
(p) => p.square === hoveredSquare && !p.captured
)
if (!moverPiece || !targetPiece) return false
const moverValue = getEffectiveValue(moverPiece)
const targetValue = getEffectiveValue(targetPiece)
if (
moverValue === undefined ||
moverValue === null ||
targetValue === undefined ||
targetValue === null
)
return false
const relations = findAvailableRelations(moverValue, targetValue)
return relations.length === 0
})()
// Get position for hover error tooltip
const hoverErrorPosition = (() => {
if (!showHoverError || !hoveredSquare) return null
const file = hoveredSquare.charCodeAt(0) - 65 // A=0
const rank = Number.parseInt(hoveredSquare.slice(1), 10) // 1-8
const row = 8 - rank // Invert for display
const x = labelMargin + padding + file * (cellSize + gap) + cellSize / 2
const y = padding + row * (cellSize + gap) + cellSize / 2
return { x, y }
})()
return (
<div
className={css({
@@ -423,6 +502,8 @@ export function BoardDisplay() {
overflow: 'visible',
})}
onClick={handleSvgClick}
onMouseMove={handleSvgMouseMove}
onMouseLeave={handleSvgMouseLeave}
>
{/* Board background */}
<rect x={0} y={0} width={boardWidth} height={boardHeight} fill="#d1d5db" rx={8} />
@@ -622,6 +703,42 @@ export function BoardDisplay() {
</CaptureProvider>
)
})()}
{/* Hover error tooltip - shows when hovering over invalid capture target */}
{showHoverError && hoverErrorPosition && (
<g transform={`translate(${hoverErrorPosition.x}, ${hoverErrorPosition.y})`}>
<foreignObject
x={-cellSize * 1.8}
y={-cellSize * 0.5}
width={cellSize * 3.6}
height={cellSize}
style={{ overflow: 'visible', pointerEvents: 'none' }}
>
<div
style={{
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
color: '#f1f5f9',
padding: `${cellSize * 0.12}px ${cellSize * 0.18}px`,
borderRadius: `${cellSize * 0.12}px`,
fontSize: `${cellSize * 0.16}px`,
fontWeight: 500,
textAlign: 'center',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: `${cellSize * 0.1}px`,
backdropFilter: 'blur(8px)',
letterSpacing: '0.01em',
pointerEvents: 'none',
}}
>
<span style={{ fontSize: `${cellSize * 0.2}px`, opacity: 0.7 }}></span>
<span>No valid relation</span>
</div>
</foreignObject>
</g>
)}
</svg>
</div>
)

View File

@@ -5,7 +5,7 @@ import { useCaptureContext } from '../../contexts/CaptureContext'
* Error notification when no capture is possible
*/
export function CaptureErrorDialog() {
const { layout, closing, dismissDialog } = useCaptureContext()
const { layout, closing } = useCaptureContext()
const { targetPos, cellSize } = layout
const entranceSpring = useSpring({
from: { opacity: 0, y: -20 },
@@ -40,58 +40,22 @@ export function CaptureErrorDialog() {
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${cellSize * 0.15}px`,
justifyContent: 'center',
gap: `${cellSize * 0.1}px`,
backdropFilter: 'blur(8px)',
letterSpacing: '0.01em',
pointerEvents: 'none',
}}
onClick={(e) => e.stopPropagation()}
>
<div
<span
style={{
display: 'flex',
alignItems: 'center',
gap: `${cellSize * 0.1}px`,
flex: 1,
fontSize: `${cellSize * 0.2}px`,
opacity: 0.7,
}}
>
<span
style={{
fontSize: `${cellSize * 0.2}px`,
opacity: 0.7,
}}
>
</span>
<span>No valid relation</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
dismissDialog()
}}
style={{
padding: `${cellSize * 0.06}px ${cellSize * 0.12}px`,
borderRadius: `${cellSize * 0.08}px`,
border: 'none',
background: 'rgba(148, 163, 184, 0.2)',
color: '#cbd5e1',
fontSize: `${cellSize * 0.13}px`,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.15s ease',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(148, 163, 184, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(148, 163, 184, 0.2)'
}}
>
OK
</button>
</span>
<span>No valid relation</span>
</div>
</foreignObject>
</animated.g>