···44 * Layout:
55 * - Opponent player bar (top) with clock
66 * - Chess board (center)
77- * - Your player bar (bottom) with clock
77+ * - Your player bar (bottom) with clock + inline Resign button
88 * - Move list sidebar (right, collapses below on mobile)
99 * - Game status overlay (when game ends)
1010- * - Resign button (multiplayer only)
1110 *
1211 * In solo mode (both sides are the same player), the board always shows
1312 * white's perspective and both sides are always movable. Solo games are
···176175 />
177176 ) : undefined;
178177178178+ // ---------------------------------------------------------------------------
179179+ // Resign control rendered inline on the local player's bar.
180180+ //
181181+ // Inlining keeps it always visible (the player bar is never below the fold
182182+ // even on short viewports) and groups the action with the player it acts
183183+ // upon. Always rendered while the game is active — in solo mode, it just
184184+ // ends the practice game and returns to the lobby (the server's resign
185185+ // reducer happens to record "black" as the winner, but GameStatus suppresses
186186+ // win/loss framing for solo games).
187187+ // ---------------------------------------------------------------------------
188188+ const resignAction = activeGame.status === 'active' ? (
189189+ showResignConfirm ? (
190190+ <div className="flex items-center gap-1.5">
191191+ <span className="hidden text-xs font-medium text-wood-300 sm:inline">Resign?</span>
192192+ <button
193193+ onClick={handleResign}
194194+ className="rounded-md bg-red-700 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-white shadow-sm transition-colors hover:bg-red-600"
195195+ aria-label="Confirm resign"
196196+ >
197197+ Yes
198198+ </button>
199199+ <button
200200+ onClick={() => setShowResignConfirm(false)}
201201+ className="rounded-md border border-wood-500 bg-wood-800 px-2.5 py-1 text-xs font-medium text-wood-200 transition-colors hover:border-wood-300 hover:text-wood-100"
202202+ aria-label="Cancel resign"
203203+ >
204204+ No
205205+ </button>
206206+ </div>
207207+ ) : (
208208+ <button
209209+ onClick={() => setShowResignConfirm(true)}
210210+ className="rounded-md border border-red-900/70 bg-wood-900 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-red-300 shadow-sm transition-colors hover:border-red-600 hover:bg-red-900/50 hover:text-red-100"
211211+ >
212212+ Resign
213213+ </button>
214214+ )
215215+ ) : undefined;
216216+179217 return (
180218 <div className="flex flex-1 flex-col items-center justify-center gap-4 p-4 lg:flex-row">
181219 {/* Board + player bars column */}
···211249 />
212250 </div>
213251214214- {/* You (bottom) */}
252252+ {/* You (bottom) — Resign button rendered inline via the action slot */}
215253 <PlayerBar
216254 displayName={isSolo ? (bottomColor === 'white' ? 'White' : 'Black') : (displayName ?? 'You')}
217255 handle={isSolo ? undefined : (handle ?? undefined)}
···219257 isActive={bottomIsActive}
220258 color={bottomColor}
221259 clock={bottomClock}
260260+ action={resignAction}
222261 />
223223-224224- {/* Resign button — multiplayer only; resigning against yourself makes no sense */}
225225- {activeGame.status === 'active' && !isSolo && (
226226- <div className="mt-1 flex justify-center">
227227- {showResignConfirm ? (
228228- <div className="flex items-center gap-2">
229229- <span className="text-sm text-wood-400">Resign?</span>
230230- <button
231231- onClick={handleResign}
232232- className="rounded px-3 py-1 text-sm bg-red-700 text-white transition-colors hover:bg-red-800"
233233- >
234234- Yes
235235- </button>
236236- <button
237237- onClick={() => setShowResignConfirm(false)}
238238- className="rounded border border-wood-600 px-3 py-1 text-sm text-wood-300 transition-colors hover:border-wood-500 hover:text-wood-100"
239239- >
240240- No
241241- </button>
242242- </div>
243243- ) : (
244244- <button
245245- onClick={() => setShowResignConfirm(true)}
246246- className="rounded px-4 py-1.5 text-sm text-wood-500 transition-colors hover:bg-wood-700 hover:text-wood-300"
247247- >
248248- Resign
249249- </button>
250250- )}
251251- </div>
252252- )}
253262 </div>
254263255264 {/* Move list sidebar */}
+5
client/src/components/game/GameStatus.tsx
···4242 title = 'Draw';
4343 subtitle = 'The game ended in a draw.';
4444 break;
4545+ case 'resigned':
4646+ emoji = '🏳';
4747+ title = 'Game ended';
4848+ subtitle = 'You ended the practice game.';
4949+ break;
4550 default:
4651 title = 'Game over';
4752 subtitle = '';
+9-1
client/src/components/game/PlayerBar.tsx
···22 * PlayerBar — displays player info above/below the chess board.
33 *
44 * Shows avatar, display name, handle, a turn indicator, and (for timed
55- * games) the player's remaining clock time.
55+ * games) the player's remaining clock time. May optionally render a
66+ * trailing `action` slot (e.g., the Resign button on the local player's
77+ * bar) — kept generic so PlayerBar stays unaware of game-level controls.
68 */
79810import type { ReactNode } from 'react';
···1517 color: 'white' | 'black';
1618 /** Optional clock node rendered on the right side of the bar */
1719 clock?: ReactNode;
2020+ /** Optional action node rendered at the far right (e.g. Resign button) */
2121+ action?: ReactNode;
1822}
19232024export function PlayerBar({
···2428 isActive,
2529 color,
2630 clock,
3131+ action,
2732}: PlayerBarProps) {
2833 return (
2934 <div
···6873 {isActive && (
6974 <div className="h-2 w-2 animate-pulse rounded-full bg-felt-400" />
7075 )}
7676+7777+ {/* Optional trailing action (e.g. Resign) */}
7878+ {action}
7179 </div>
7280 );
7381}