From 19b4df3bdb69da9eb01d9fbcdbd51ca73080f151 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:31:18 +0000 Subject: [PATCH 1/2] chess: pick a promotion on the board, lichess-style The pawn lands on the last rank and the pieces stack down its file over a dimmed board; a tap off them puts the pawn back. Replaces the button row below the board, where the pawn sat back on its starting square. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLwM4Z2DvpU7UvZj6mkVsx --- .../chess/components/ChessTable.module.css | 58 +++++++++++- src/apps/chess/components/ChessTable.tsx | 92 ++++++++++--------- .../components/__tests__/ChessTable.test.tsx | 34 +++++++ 3 files changed, 142 insertions(+), 42 deletions(-) diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css index c627bae..e8fda7c 100644 --- a/src/apps/chess/components/ChessTable.module.css +++ b/src/apps/chess/components/ChessTable.module.css @@ -65,6 +65,7 @@ the squares past a tap. The pieces are sized from the board, not the screen, so they always fit their squares. */ .board { + position: relative; container-type: inline-size; display: grid; grid-template-columns: repeat(8, 1fr); @@ -207,7 +208,6 @@ color: #ff6b5e; } -.promotion, .actions { display: flex; flex-wrap: wrap; @@ -309,3 +309,59 @@ pointer-events: none; cursor: grabbing; } + +/* The promotion picker, over the board: a scrim that dims it and takes the + tap that cancels, and the column of pieces down the promotion file, a + square each, from the far rank. */ +.promotion { + position: absolute; + inset: 0; + z-index: 2; +} + +.promotionScrim { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 0; + border: none; + background: rgba(0, 0, 0, 0.45); + cursor: default; + -webkit-tap-highlight-color: transparent; +} + +.promotionFile { + position: absolute; + top: 0; + z-index: 1; + width: 12.5%; + margin: 0; + padding: 0; + list-style: none; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.6); +} + +.promotionPiece { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1; + padding: 0; + border: none; + border-radius: 50%; + background: radial-gradient(circle, #fff 20%, #b0b0b0 100%); + font-size: 9cqi; + line-height: 1; + cursor: pointer; + touch-action: manipulation; + transition: border-radius 0.1s, background 0.1s; +} + +.promotionPiece:hover, +.promotionPiece:focus-visible { + border-radius: 0; + background: radial-gradient(circle, #f0c96a 20%, #c27a1a 100%); + outline: none; +} diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index fa5dc87..3fdfb56 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -104,7 +104,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { // resignation half made — each keyed to the view it was made against, // so a new position drops it rather than acting on one that is gone. const [picked, setPicked] = useState<{ view: ChessView; square: string } | null>(null) - const [promoting, setPromoting] = useState<{ view: ChessView; from: string; moves: string[] } | null>(null) + const [promoting, setPromoting] = useState<{ view: ChessView; from: string; to: string; moves: string[] } | null>(null) const [confirmResign, setConfirmResign] = useState(null) const [clockChoice, setClockChoice] = useState('3+2') @@ -116,6 +116,13 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const pendingPromotion = promoting?.view === view ? promoting : null const targets = from === null ? [] : targetsFrom(view.legalMoves, from) const board = readBoard(view.fen) + // A promotion asking shows its pawn already on the last rank, under the + // picker, as the board would once it is played. + const shown = new Map(board) + if (pendingPromotion !== null) { + shown.set(pendingPromotion.to, board.get(pendingPromotion.from) ?? '') + shown.delete(pendingPromotion.from) + } const last = lastMoveSquares(view.moves) // The king of the side to move — or, once mated, of the side that was. const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null @@ -125,7 +132,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { const moves = movesTo(view.legalMoves, start, square) setPicked(null) if (moves.length === 1) table.play(moves[0]) - else setPromoting({ view, from: start, moves }) + else setPromoting({ view, from: start, to: square, moves }) } const tap = (square: string) => { @@ -286,7 +293,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { onPointerCancel={onPointerCancel} > {squares.map((square, index) => { - const piece = board.get(square) + const piece = shown.get(square) const mine = piece !== undefined && colorOfPiece(piece) === myColor // a1 dark: a square is dark where its file and rank index sum even. const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0 @@ -328,6 +335,47 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { ) })} + {/* Lichess's picker: the pieces stacked down the file from the + promotion square, which is always on the viewer's far rank, + over a dimmed board a tap on which lets the pawn go back. */} + {pendingPromotion !== null && ( +
{ + if (event.key === 'Escape') { + event.preventDefault() + cancelPromotion() + } + }} + > +
    + {PROMOTIONS.map(({ letter, name }, i) => { + const uci = pendingPromotion.moves.find(move => move.endsWith(letter)) + if (uci === undefined) return null + return ( +
  • + +
  • + ) + })} +
+
+ )} {me?.color !== undefined && } {/* On the page, not the board: the board clips its overflow, and a @@ -345,44 +393,6 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { document.body )} - {pendingPromotion !== null && ( -
{ - if (event.key === 'Escape') { - event.preventDefault() - cancelPromotion() - } - }} - > - {PROMOTIONS.map(({ letter, name }, i) => { - const uci = pendingPromotion.moves.find(move => move.endsWith(letter)) - if (uci === undefined) return null - return ( - - ) - })} - -
- )} - {view.phase === 'playing' && me !== undefined && (
{confirmResign === view ? ( diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index d4d3329..d6fccc2 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -203,6 +203,40 @@ describe('ChessTable', () => { expect(t.play).not.toHaveBeenCalled() }) + it('a promotion asking shows the pawn on the last rank, the picker over it, and puts it back on cancel', () => { + mountWith(view()) + fireEvent.click(square('e7')) + fireEvent.click(square('e8')) + expect(square('e7')).toHaveAccessibleName('e7') + expect(square('e8')).toHaveAccessibleName('e8, white pawn') + const picker = screen.getByRole('group', { name: 'promote to' }) + // on the board, down the e-file: the fifth column from the left + expect(screen.getByRole('group', { name: 'board' })).toContainElement(picker) + expect(within(picker).getByRole('list')).toHaveStyle({ left: '50%' }) + expect(within(picker).getAllByRole('button').map(b => b.getAttribute('aria-label'))).toEqual(['Queen', 'Rook', 'Bishop', 'Knight', 'Cancel']) + fireEvent.click(within(picker).getByRole('button', { name: 'Cancel' })) + expect(square('e7')).toHaveAccessibleName('e7, white pawn') + expect(square('e8')).toHaveAccessibleName('e8') + }) + + it('for Black, the picker stands on the promotion file as Black sees it', () => { + // Black Pe2 against Kh1, bob on turn: e is the fourth column from Black's left. + mountWith( + view({ + fen: '7k/8/8/8/8/8/4p3/7K b - - 0 1', + sideToMove: 'black', + currentPlayerId: 'bob', + legalMoves: ['e2e1b', 'e2e1n', 'e2e1q', 'e2e1r', 'h8g8'] + }), + {}, + 'bob' + ) + fireEvent.click(square('e2')) + fireEvent.click(square('e1')) + expect(square('e1')).toHaveAccessibleName('e1, black pawn') + expect(within(screen.getByRole('group', { name: 'promote to' })).getByRole('list')).toHaveStyle({ left: '37.5%' }) + }) + // A move made on the board instead abandons the promotion: the picker // must not linger to send a second move for the same turn. it('another move on the board drops a pending promotion', () => { From 22e4af103785d7046615192228325051a8bcbc01 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:39:33 +0000 Subject: [PATCH 2/2] chess: keep the keyboard off the board while a promotion asks The scrim already blocks the pointer; inert on the squares does the same for Tab. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLwM4Z2DvpU7UvZj6mkVsx --- src/apps/chess/components/ChessTable.tsx | 2 ++ .../chess/components/__tests__/ChessTable.test.tsx | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index 3fdfb56..8c1ff8a 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -314,6 +314,8 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { data-check={piece !== undefined && piece === checkedKing ? 'true' : undefined} data-grab={grab ? 'true' : undefined} data-dragging={ghost !== null && from === square ? 'true' : undefined} + // Under the picker's scrim, out of reach of the keyboard as of the pointer. + inert={pendingPromotion !== null} onPointerDown={event => onPointerDown(event, square)} onClick={() => tap(square)} > diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index d6fccc2..e48b9be 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -219,6 +219,19 @@ describe('ChessTable', () => { expect(square('e8')).toHaveAccessibleName('e8') }) + it('a promotion asking takes the squares out of reach, and gives them back once answered', () => { + mountWith(view()) + const cells = () => within(screen.getByRole('group', { name: 'board' })).getAllByRole('button', { name: /^[a-h][1-8]\b/ }) + fireEvent.click(square('e7')) + expect(cells().filter(cell => cell.hasAttribute('inert'))).toHaveLength(0) + fireEvent.click(square('e8')) + expect(cells().filter(cell => cell.hasAttribute('inert'))).toHaveLength(64) + expect(within(screen.getByRole('group', { name: 'promote to' })).getByRole('button', { name: 'Queen' })).not.toHaveAttribute('inert') + fireEvent.keyDown(screen.getByRole('group', { name: 'promote to' }), { key: 'Escape' }) + expect(cells().filter(cell => cell.hasAttribute('inert'))).toHaveLength(0) + expect(square('e7')).toHaveFocus() + }) + it('for Black, the picker stands on the promotion file as Black sees it', () => { // Black Pe2 against Kh1, bob on turn: e is the fourth column from Black's left. mountWith(