diff --git a/packages/chess/e2e/modifier-profiles.spec.ts b/packages/chess/e2e/modifier-profiles.spec.ts index 683850e..fc2e1f5 100644 --- a/packages/chess/e2e/modifier-profiles.spec.ts +++ b/packages/chess/e2e/modifier-profiles.spec.ts @@ -142,6 +142,55 @@ test.describe('Modifier Profiles', () => { }); }); +// ── T24: Hover modifier tooltip ────────────────────────────────────────── + +test.describe('Modifier Profiles — hover tooltip (T24)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + + // Clear autosave so Play Solo starts a fresh game. + await page.evaluate(() => { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key !== null && key.startsWith('paratype-chess:v2:autosave:')) { + localStorage.removeItem(key); + } + } + localStorage.removeItem('paratype-chess:v1:autosave'); + }); + + // Navigate into the game view WITHOUT opening the rules drawer, + // so the board is fully interactive (no backdrop overlay). + await page.locator('[data-action="play-solo"]').click(); + await page.waitForURL('**/game'); + }); + + test('hover piece shows modifier tooltip', async ({ page }) => { + // Hover white knight at b1 (standard opening position). + await page.hover('[data-square="b1"]'); + await page.waitForTimeout(200); + + // Tooltip must appear and identify the piece. + await expect( + page.locator('[data-testid="modifier-tooltip"]'), + ).toBeVisible({ timeout: 1000 }); + await expect( + page.locator('[data-testid="modifier-tooltip"]'), + ).toContainText(/knight/i); + }); + + test('hover unmodified piece shows no modifier rows', async ({ page }) => { + // Standard solo game — no modifier profile active. + await page.hover('[data-square="e2"]'); // white pawn + await page.waitForTimeout(200); + + // Tooltip appears (header always shows) but no modifier rows. + await expect( + page.locator('[data-testid="modifier-tooltip-row"]'), + ).toHaveCount(0); + }); +}); + // ── T26: Lobby profile picker + GameView badge ───────────────────────── test.describe('Modifier Profiles — Lobby integration (T26)', () => { diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index c893cee..07eaeb9 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -34,6 +34,11 @@ interface BoardProps { /** Live engine reference for the visual effect stream. Optional — * omit in tests or headless renders; no effects will render. */ engine?: ChessEngine; + /** + * Called when the mouse enters a piece's square (pieceId) or leaves + * (null). Used by GameView to show/hide the modifier tooltip. + */ + onPieceHover?: (pieceId: number | null) => void; } interface PieceState { @@ -42,7 +47,7 @@ interface PieceState { color: PieceColor; } -export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine }: BoardProps) { +export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover }: BoardProps) { // Pre-compute overlay components once per render — lookup is cheap // but doing it once in a useMemo keeps the Piece render path clean. const overlays: PieceOverlayComponent[] = useMemo( @@ -317,7 +322,12 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec * FLIP clean. */} {piece && ( -
+
onPieceHover?.(piece.id)} + onMouseLeave={() => onPieceHover?.(null)} + > (null); + const hoverTimeoutRef = useRef | null>(null); + const handlePieceHover = (pieceId: number | null) => { + if (hoverTimeoutRef.current !== null) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + if (pieceId !== null) { + setHoveredPieceId(pieceId); + } else { + // Short delay so the tooltip doesn't flicker when cursor + // crosses the small gap between the piece and the tooltip card. + hoverTimeoutRef.current = setTimeout(() => setHoveredPieceId(null), 120); + } + }; + // Audio toggle const [isMuted, setIsMuted] = useState(audio.isMuted()); const toggleMute = () => { @@ -384,9 +402,17 @@ function GameLayout({ lastMove={lastMove} checkedKingSquare={checkedKingSquare} activePresetIds={activations.map((a) => a.id)} + onPieceHover={handlePieceHover} {...(state.engine !== null ? { engine: state.engine } : {})} /> + {/* Modifier tooltip — shows when hovering any piece */} + {hoveredPieceId !== null && engine !== null && ( +
+ +
+ )} + {/* Overlay for game over to prevent further interaction visually */} {isGameOver && ( { + const value = session.get(id, descriptor.attrName); + if (value === undefined) return []; + // descriptor.describe is typed as (value: V) => string; the registry + // widens to ModifierDescriptor but the value came from the + // same descriptor's apply(), so the types match at runtime. + const description = (descriptor.describe as (v: unknown) => string)(value); + return [{ id: descriptor.id, label: descriptor.label, description }]; + }); + + return ( +
+ {/* Piece header */} +

+ {color} {pieceType} +

+ + {/* Modifier rows */} + {modifierRows.map((row) => ( +
+ {row.label} + + {row.description} + +
+ ))} + + {/* Empty state */} + {modifierRows.length === 0 && ( +

No active modifiers

+ )} +
+ ); +}