feat(ui): hover modifier tooltip
Adds ModifierTooltip component that reads MODIFIER_REGISTRY attrs from engine.session for the hovered piece and renders them as labelled rows. The tooltip always appears on piece hover (piece type + color header) and shows modifier rows only when modifier facts are set on the entity. Board.tsx gains an optional onPieceHover callback; GameView.tsx tracks hoveredPieceId and renders the tooltip absolutely in the board wrapper. A 120ms hide-delay prevents flicker when cursor briefly leaves a piece. Two Playwright tests added: hover shows tooltip with piece name; hover over unmodified piece shows zero modifier-tooltip-row elements.
This commit is contained in:
parent
fa1ef765f8
commit
5f252e2dff
4 changed files with 161 additions and 3 deletions
|
|
@ -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 ─────────────────────────
|
// ── T26: Lobby profile picker + GameView badge ─────────────────────────
|
||||||
|
|
||||||
test.describe('Modifier Profiles — Lobby integration (T26)', () => {
|
test.describe('Modifier Profiles — Lobby integration (T26)', () => {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ interface BoardProps {
|
||||||
/** Live engine reference for the visual effect stream. Optional —
|
/** Live engine reference for the visual effect stream. Optional —
|
||||||
* omit in tests or headless renders; no effects will render. */
|
* omit in tests or headless renders; no effects will render. */
|
||||||
engine?: ChessEngine;
|
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 {
|
interface PieceState {
|
||||||
|
|
@ -42,7 +47,7 @@ interface PieceState {
|
||||||
color: PieceColor;
|
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
|
// Pre-compute overlay components once per render — lookup is cheap
|
||||||
// but doing it once in a useMemo keeps the Piece render path clean.
|
// but doing it once in a useMemo keeps the Piece render path clean.
|
||||||
const overlays: PieceOverlayComponent[] = useMemo(
|
const overlays: PieceOverlayComponent[] = useMemo(
|
||||||
|
|
@ -317,7 +322,12 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
||||||
* FLIP clean.
|
* FLIP clean.
|
||||||
*/}
|
*/}
|
||||||
{piece && (
|
{piece && (
|
||||||
<div key={piece.id} className="absolute inset-0 z-20">
|
<div
|
||||||
|
key={piece.id}
|
||||||
|
className="absolute inset-0 z-20"
|
||||||
|
onMouseEnter={() => onPieceHover?.(piece.id)}
|
||||||
|
onMouseLeave={() => onPieceHover?.(null)}
|
||||||
|
>
|
||||||
<Piece
|
<Piece
|
||||||
color={piece.color}
|
color={piece.color}
|
||||||
type={piece.type}
|
type={piece.type}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Board } from './Board';
|
import { Board } from './Board';
|
||||||
import { RulesDrawer } from './RulesDrawer';
|
import { RulesDrawer } from './RulesDrawer';
|
||||||
|
import { ModifierTooltip } from './ModifierTooltip.js';
|
||||||
import { useChessEngine } from '../hooks/useChessEngine';
|
import { useChessEngine } from '../hooks/useChessEngine';
|
||||||
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
import { useMultiplayerGame } from '../hooks/useMultiplayerGame';
|
||||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||||
|
|
@ -8,7 +9,7 @@ import type { GameResult } from '../engine';
|
||||||
import type { LegalMove } from '../rules/types';
|
import type { LegalMove } from '../rules/types';
|
||||||
import type { ChessEngine } from '../engine';
|
import type { ChessEngine } from '../engine';
|
||||||
import { isInCheck } from '../rules/check';
|
import { isInCheck } from '../rules/check';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import confetti from 'canvas-confetti';
|
import confetti from 'canvas-confetti';
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import { Volume2, VolumeX, Copy, Check } from 'lucide-react';
|
import { Volume2, VolumeX, Copy, Check } from 'lucide-react';
|
||||||
|
|
@ -180,6 +181,23 @@ function GameLayout({
|
||||||
}
|
}
|
||||||
}, [result]);
|
}, [result]);
|
||||||
|
|
||||||
|
// Piece hover — drives the modifier tooltip
|
||||||
|
const [hoveredPieceId, setHoveredPieceId] = useState<number | null>(null);
|
||||||
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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
|
// Audio toggle
|
||||||
const [isMuted, setIsMuted] = useState(audio.isMuted());
|
const [isMuted, setIsMuted] = useState(audio.isMuted());
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
|
|
@ -384,9 +402,17 @@ function GameLayout({
|
||||||
lastMove={lastMove}
|
lastMove={lastMove}
|
||||||
checkedKingSquare={checkedKingSquare}
|
checkedKingSquare={checkedKingSquare}
|
||||||
activePresetIds={activations.map((a) => a.id)}
|
activePresetIds={activations.map((a) => a.id)}
|
||||||
|
onPieceHover={handlePieceHover}
|
||||||
{...(state.engine !== null ? { engine: state.engine } : {})}
|
{...(state.engine !== null ? { engine: state.engine } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modifier tooltip — shows when hovering any piece */}
|
||||||
|
{hoveredPieceId !== null && engine !== null && (
|
||||||
|
<div className="absolute top-2 right-2 z-50 pointer-events-none">
|
||||||
|
<ModifierTooltip pieceId={hoveredPieceId} engine={engine} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Overlay for game over to prevent further interaction visually */}
|
{/* Overlay for game over to prevent further interaction visually */}
|
||||||
{isGameOver && (
|
{isGameOver && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
73
packages/chess/src/ui/ModifierTooltip.tsx
Normal file
73
packages/chess/src/ui/ModifierTooltip.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* ModifierTooltip — hover tooltip showing active modifier facts for a piece.
|
||||||
|
*
|
||||||
|
* Reads the piece's type/color and all MODIFIER_REGISTRY attributes from
|
||||||
|
* the engine session. Renders as an absolute-positioned card; the parent is
|
||||||
|
* responsible for placement (positioned within a `relative` container).
|
||||||
|
*
|
||||||
|
* Source attribution is simplified for T24: any fact that matches a
|
||||||
|
* MODIFIER_REGISTRY attribute is shown without distinguishing per-type /
|
||||||
|
* per-instance / preset origin (T26 can refine this).
|
||||||
|
*/
|
||||||
|
import type { EntityId } from '@paratype/rete';
|
||||||
|
import { MODIFIER_REGISTRY } from '../modifiers/index.js';
|
||||||
|
import type { ChessEngine } from '../engine.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pieceId: number;
|
||||||
|
engine: ChessEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModifierTooltip({ pieceId, engine }: Props) {
|
||||||
|
const { session } = engine;
|
||||||
|
const id = pieceId as unknown as EntityId;
|
||||||
|
|
||||||
|
// Basic piece facts
|
||||||
|
const pieceType = session.get(id, 'PieceType') as string | undefined;
|
||||||
|
const color = session.get(id, 'Color') as string | undefined;
|
||||||
|
|
||||||
|
// Don't render if the piece entity doesn't exist in the session
|
||||||
|
if (!pieceType || !color) return null;
|
||||||
|
|
||||||
|
// Collect modifier attrs that are set on this piece
|
||||||
|
const modifierRows = MODIFIER_REGISTRY.list().flatMap((descriptor) => {
|
||||||
|
const value = session.get(id, descriptor.attrName);
|
||||||
|
if (value === undefined) return [];
|
||||||
|
// descriptor.describe is typed as (value: V) => string; the registry
|
||||||
|
// widens to ModifierDescriptor<unknown> 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 (
|
||||||
|
<div
|
||||||
|
data-testid="modifier-tooltip"
|
||||||
|
className="bg-white border border-neutral-200 rounded-lg shadow-xl p-3 min-w-[160px] max-w-[220px] pointer-events-none select-none"
|
||||||
|
>
|
||||||
|
{/* Piece header */}
|
||||||
|
<p className="text-xs font-bold text-neutral-700 mb-1.5 capitalize">
|
||||||
|
{color} {pieceType}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Modifier rows */}
|
||||||
|
{modifierRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
data-testid="modifier-tooltip-row"
|
||||||
|
className="flex items-center justify-between text-xs py-0.5 gap-2 border-t border-neutral-100 mt-0.5 pt-1"
|
||||||
|
>
|
||||||
|
<span className="text-neutral-500">{row.label}</span>
|
||||||
|
<span className="font-semibold text-neutral-800 text-right">
|
||||||
|
{row.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{modifierRows.length === 0 && (
|
||||||
|
<p className="text-[11px] text-neutral-400 italic">No active modifiers</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue