feat(ui): pinned modifier inspection panel
- Create ModifierPinnedPanel.tsx: fixed-position side panel with piece header, modifier list (label + describe() value), and close button - Board.tsx: add onPieceClick prop, fire on piece click (distinct from drag) - GameView.tsx: add pinnedPieceId state; clicking a piece toggles pin; clicking same piece again or × closes panel; panel renders fixed right-4 - 2 new e2e tests: click b1 pins panel with 'knight' text; × dismisses it
This commit is contained in:
parent
cfc68bba51
commit
278370a630
4 changed files with 170 additions and 1 deletions
|
|
@ -191,6 +191,58 @@ test.describe('Modifier Profiles — hover tooltip (T24)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── T25: Pinned modifier inspection panel ────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Modifier Profiles — pinned panel (T25)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Clear any stale 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 WITHOUT opening the rules drawer —
|
||||||
|
// the board must remain fully interactive.
|
||||||
|
await page.locator('[data-action="play-solo"]').click();
|
||||||
|
await page.waitForURL('**/game');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('click piece pins side panel', async ({ page }) => {
|
||||||
|
// Click the b1 square — white knight in the classic starting position.
|
||||||
|
await page.click('[data-square="b1"]');
|
||||||
|
|
||||||
|
// The pinned panel must appear and identify the piece.
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||||||
|
).toBeVisible({ timeout: 2000 });
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||||||
|
).toContainText(/knight/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing pinned panel removes it', async ({ page }) => {
|
||||||
|
// Pin the panel.
|
||||||
|
await page.click('[data-square="b1"]');
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||||||
|
).toBeVisible({ timeout: 2000 });
|
||||||
|
|
||||||
|
// Dismiss via the close button.
|
||||||
|
await page.click('[data-testid="close-pinned-panel"]');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="modifier-pinned-panel"]'),
|
||||||
|
).not.toBeVisible({ timeout: 1000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── 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)', () => {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ interface BoardProps {
|
||||||
* (null). Used by GameView to show/hide the modifier tooltip.
|
* (null). Used by GameView to show/hide the modifier tooltip.
|
||||||
*/
|
*/
|
||||||
onPieceHover?: (pieceId: number | null) => void;
|
onPieceHover?: (pieceId: number | null) => void;
|
||||||
|
/**
|
||||||
|
* Called when the user clicks a piece (not a drag). Used by GameView
|
||||||
|
* to pin the modifier inspection panel for that piece.
|
||||||
|
*/
|
||||||
|
onPieceClick?: (pieceId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PieceState {
|
interface PieceState {
|
||||||
|
|
@ -47,7 +52,7 @@ interface PieceState {
|
||||||
color: PieceColor;
|
color: PieceColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover }: BoardProps) {
|
export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover, onPieceClick }: 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(
|
||||||
|
|
@ -327,6 +332,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
|
||||||
className="absolute inset-0 z-20"
|
className="absolute inset-0 z-20"
|
||||||
onMouseEnter={() => onPieceHover?.(piece.id)}
|
onMouseEnter={() => onPieceHover?.(piece.id)}
|
||||||
onMouseLeave={() => onPieceHover?.(null)}
|
onMouseLeave={() => onPieceHover?.(null)}
|
||||||
|
onClick={() => onPieceClick?.(piece.id)}
|
||||||
>
|
>
|
||||||
<Piece
|
<Piece
|
||||||
color={piece.color}
|
color={piece.color}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Board } from './Board';
|
import { Board } from './Board';
|
||||||
import { RulesDrawer } from './RulesDrawer';
|
import { RulesDrawer } from './RulesDrawer';
|
||||||
import { ModifierTooltip } from './ModifierTooltip.js';
|
import { ModifierTooltip } from './ModifierTooltip.js';
|
||||||
|
import { ModifierPinnedPanel } from './ModifierPinnedPanel.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';
|
||||||
|
|
@ -198,6 +199,9 @@ function GameLayout({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Piece click — pins the modifier inspection panel for that piece.
|
||||||
|
const [pinnedPieceId, setPinnedPieceId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Audio toggle
|
// Audio toggle
|
||||||
const [isMuted, setIsMuted] = useState(audio.isMuted());
|
const [isMuted, setIsMuted] = useState(audio.isMuted());
|
||||||
const toggleMute = () => {
|
const toggleMute = () => {
|
||||||
|
|
@ -403,6 +407,7 @@ function GameLayout({
|
||||||
checkedKingSquare={checkedKingSquare}
|
checkedKingSquare={checkedKingSquare}
|
||||||
activePresetIds={activations.map((a) => a.id)}
|
activePresetIds={activations.map((a) => a.id)}
|
||||||
onPieceHover={handlePieceHover}
|
onPieceHover={handlePieceHover}
|
||||||
|
onPieceClick={(id) => setPinnedPieceId((prev) => (prev === id ? null : id))}
|
||||||
{...(state.engine !== null ? { engine: state.engine } : {})}
|
{...(state.engine !== null ? { engine: state.engine } : {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -413,6 +418,21 @@ function GameLayout({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modifier pinned panel — click a piece to pin; click × or same piece again to unpin.
|
||||||
|
Fixed positioning keeps it in the viewport regardless of scroll and ensures it
|
||||||
|
doesn't displace the board layout on smaller screens. */}
|
||||||
|
{pinnedPieceId !== null && engine !== null && (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', right: '1rem', top: '6rem', zIndex: 50 }}
|
||||||
|
>
|
||||||
|
<ModifierPinnedPanel
|
||||||
|
pieceId={pinnedPieceId}
|
||||||
|
engine={engine}
|
||||||
|
onClose={() => setPinnedPieceId(null)}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
|
|
||||||
91
packages/chess/src/ui/ModifierPinnedPanel.tsx
Normal file
91
packages/chess/src/ui/ModifierPinnedPanel.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**
|
||||||
|
* ModifierPinnedPanel — persistent side panel showing modifier details for
|
||||||
|
* a pinned piece.
|
||||||
|
*
|
||||||
|
* Mirrors the content of ModifierTooltip but as a closeable, pointer-events-
|
||||||
|
* enabled panel that stays visible until the user dismisses it. Rendered at a
|
||||||
|
* fixed position on the right side of the viewport so it never overlaps the
|
||||||
|
* board tooltip.
|
||||||
|
*
|
||||||
|
* The parent passes `pieceId` as a prop; the component reads current session
|
||||||
|
* state on each render so it stays reactive to fact changes (moves, rule
|
||||||
|
* changes, etc.) without any additional subscription setup.
|
||||||
|
*/
|
||||||
|
import type { EntityId } from '@paratype/rete';
|
||||||
|
import { MODIFIER_REGISTRY } from '../modifiers/index.js';
|
||||||
|
import type { ChessEngine } from '../engine.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pieceId: number | null;
|
||||||
|
engine: ChessEngine | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModifierPinnedPanel({ pieceId, engine, onClose }: Props) {
|
||||||
|
if (pieceId === null || engine === null) return null;
|
||||||
|
|
||||||
|
const { session } = engine;
|
||||||
|
const id = pieceId as unknown as EntityId;
|
||||||
|
|
||||||
|
// Basic piece facts — bail if the entity doesn't exist in session.
|
||||||
|
const pieceType = session.get(id, 'PieceType') as string | undefined;
|
||||||
|
const color = session.get(id, 'Color') as string | undefined;
|
||||||
|
if (!pieceType || !color) return null;
|
||||||
|
|
||||||
|
// Collect every modifier attribute that is 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 at the descriptor
|
||||||
|
// level; the registry widens to ModifierDescriptor<unknown>. The value
|
||||||
|
// was written by the same descriptor's apply(), so 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-pinned-panel"
|
||||||
|
className="bg-white border border-neutral-200 rounded-xl shadow-2xl w-64 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-100 bg-neutral-50">
|
||||||
|
<h3 className="text-sm font-bold text-neutral-800 capitalize">
|
||||||
|
{color} {pieceType}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
data-testid="close-pinned-panel"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close pinned panel"
|
||||||
|
className="text-neutral-400 hover:text-neutral-700 transition-colors text-lg leading-none ml-2 focus:outline-none focus:ring-2 focus:ring-neutral-300 rounded"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modifier list */}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
{modifierRows.length > 0 ? (
|
||||||
|
<ul className="space-y-0">
|
||||||
|
{modifierRows.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.id}
|
||||||
|
data-testid="pinned-modifier-row"
|
||||||
|
className="flex items-center justify-between gap-3 py-1.5 border-b border-neutral-100 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-neutral-500 shrink-0">
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-neutral-800 text-right">
|
||||||
|
{row.description}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-400 italic">No active modifiers</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue