From 278370a630176d995e76ff0f7b063cec432b1841 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 23:28:33 -0600 Subject: [PATCH] feat(ui): pinned modifier inspection panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/chess/e2e/modifier-profiles.spec.ts | 52 +++++++++++ packages/chess/src/ui/Board.tsx | 8 +- packages/chess/src/ui/GameView.tsx | 20 ++++ packages/chess/src/ui/ModifierPinnedPanel.tsx | 91 +++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 packages/chess/src/ui/ModifierPinnedPanel.tsx diff --git a/packages/chess/e2e/modifier-profiles.spec.ts b/packages/chess/e2e/modifier-profiles.spec.ts index fc2e1f5..39ad53f 100644 --- a/packages/chess/e2e/modifier-profiles.spec.ts +++ b/packages/chess/e2e/modifier-profiles.spec.ts @@ -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 ───────────────────────── test.describe('Modifier Profiles — Lobby integration (T26)', () => { diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index 07eaeb9..f81b25e 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -39,6 +39,11 @@ interface BoardProps { * (null). Used by GameView to show/hide the modifier tooltip. */ 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 { @@ -47,7 +52,7 @@ interface PieceState { 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 // but doing it once in a useMemo keeps the Piece render path clean. const overlays: PieceOverlayComponent[] = useMemo( @@ -327,6 +332,7 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec className="absolute inset-0 z-20" onMouseEnter={() => onPieceHover?.(piece.id)} onMouseLeave={() => onPieceHover?.(null)} + onClick={() => onPieceClick?.(piece.id)} > (null); + // Audio toggle const [isMuted, setIsMuted] = useState(audio.isMuted()); const toggleMute = () => { @@ -403,6 +407,7 @@ function GameLayout({ checkedKingSquare={checkedKingSquare} activePresetIds={activations.map((a) => a.id)} onPieceHover={handlePieceHover} + onPieceClick={(id) => setPinnedPieceId((prev) => (prev === id ? null : id))} {...(state.engine !== null ? { engine: state.engine } : {})} /> @@ -413,6 +418,21 @@ function GameLayout({ )} + {/* 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 && ( +
+ setPinnedPieceId(null)} + /> +
+ )} + {/* Overlay for game over to prevent further interaction visually */} {isGameOver && ( 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. 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 ( +
+ {/* Header */} +
+

+ {color} {pieceType} +

+ +
+ + {/* Modifier list */} +
+ {modifierRows.length > 0 ? ( +
    + {modifierRows.map((row) => ( +
  • + + {row.label} + + + {row.description} + +
  • + ))} +
+ ) : ( +

No active modifiers

+ )} +
+
+ ); +}