Chess board
A chess position drawn from a FEN string, with move entry by tap or drag.
On this page
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { ChessBoard } from "@bosunkit/components/chess-board";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { usePrefersReducedMotion } from "@bosunkit/components/motion";
import { InfoTip } from "@bosunkit/components/popover";
import { Tooltip } from "@bosunkit/components/tooltip";
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type Dispatch,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
type SetStateAction,
} from "react";
export const description: string =
"A game the crew plays as White, with move controls, the move list, the engine's evaluation and best line, " +
"the opening, the material taken, and Undo and Resign.";
/** One position of the game: the move that reached it, and the engine's reading of it. */
interface Ply {
/** The move that reached this position in SAN; empty for the starting position. */
san: string;
/** The same move in UCI form, which the board tints. */
uci: string;
fen: string;
/** The square of the king in check; empty when no king is in check. */
check: string;
/** The ECO name of this exact position; empty when no named line ends here. */
opening: string;
/** Whether the opening book has a reply for this position (book.Default().Lookup). */
inBook: boolean;
/** The search score in centipawns from White's side (the engine scores from the side to move). */
evalCp: number;
/**
* Plies to a forced mate from White's side, positive when White mates; unset when the search found none, as in
* every position of this game.
*/
mateInPlies?: number;
/**
* The centipawns the move that reached this position lost against the engine's best move, from the mover's side:
* the best line's score less the played move's, both from one MultiPV search of the position before (as
* dialogue/chess/evidence.go derives a move's CentipawnLoss); 0 when the move was the best.
*/
loss: number;
/** The engine's best move in the position before, when the move played was not it; empty otherwise. */
better: string;
/**
* The move the side not to move threatens, in SAN: its best move were it its turn (the position searched with the
* move passed, to depth 12), shown when it is a capture or a check that gains at least a pawn over the score with
* the move not passed; empty when there is none or the side to move is in check.
*/
threat: string;
/** The depth the search completed, in plies. */
depth: number;
/** The deepest ply any line of the search reached, past `depth` through extensions and the capture search. */
selDepth: number;
/** The positions the search visited. */
nodes: number;
/** How long the search took, in milliseconds, on one thread of the developer's computer that produced the data. */
searchMs: number;
/** The principal variation in SAN, starting with the best move for the side to move. */
pv: readonly string[];
}
// Produced by core/chess (packages/bosun/core/chess): the line replayed with ParseSAN and Game.Push from the starting
// position, the opening named by book.Default().Name and looked up with Lookup, each position searched by
// NewSearcher(NewHCE()) on one thread to depth 12, and each move's loss from SearchMultiPV (3 lines, depth 12) of the
// position before it, with SearchMove scoring a played move outside those lines; each threat from the same search of
// the position with the side to move swapped and no en passant square; the search effort from SearchResult's SelDepth,
// Nodes and Time. Run with GOEXPERIMENT=simd.
const GAME: readonly Ply[] = [
{
san: "",
uci: "",
fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
check: "",
opening: "",
inBook: true,
evalCp: 23,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 16,
nodes: 147957,
searchMs: 78,
pv: ["e4", "d5", "exd5", "Nf6", "Bb5+", "Bd7", "Bc4", "b5", "Bd3"],
},
{
san: "e4",
uci: "e2e4",
fen: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
check: "",
opening: "",
inBook: true,
evalCp: 21,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 16,
nodes: 273880,
searchMs: 149,
pv: ["e5", "Nf3", "Nf6", "Nxe5", "Qe7", "Nc3", "Nxe4", "d4", "Nxc3", "bxc3", "Nc6", "Bf4"],
},
{
san: "e5",
uci: "e7e5",
fen: "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2",
check: "",
opening: "King's Pawn Game",
inBook: true,
evalCp: 19,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 12,
nodes: 134438,
searchMs: 73,
pv: ["Nc3", "Nf6"],
},
{
san: "Nf3",
uci: "g1f3",
fen: "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2",
check: "",
opening: "King's Knight Opening",
inBook: true,
evalCp: 28,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 16,
nodes: 198683,
searchMs: 113,
pv: ["Nc6", "Nc3", "Nf6", "d4", "Bb4", "d5", "Nxe4", "dxc6", "Nxc3", "bxc3", "Bxc3+", "Bd2", "Bxa1", "cxb7"],
},
{
san: "Nc6",
uci: "b8c6",
fen: "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3",
check: "",
opening: "King's Knight Opening: Normal Variation",
inBook: true,
evalCp: 19,
loss: 14,
better: "Nf6",
threat: "",
depth: 12,
selDepth: 15,
nodes: 188025,
searchMs: 106,
pv: ["Nc3", "Nf6", "Bb5", "Bb4", "d3", "Bxc3+", "bxc3", "d6", "Be3"],
},
{
san: "Bc4",
uci: "f1c4",
fen: "r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3",
check: "",
opening: "Italian Game",
inBook: true,
evalCp: 9,
loss: 25,
better: "Nc3",
threat: "",
depth: 12,
selDepth: 15,
nodes: 209234,
searchMs: 141,
pv: ["Nf6", "Nc3", "Bc5", "d3", "O-O", "O-O", "d6", "a3"],
},
{
san: "Nf6",
uci: "g8f6",
fen: "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4",
check: "",
opening: "Italian Game: Two Knights Defense",
inBook: false,
evalCp: -17,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 16,
nodes: 252966,
searchMs: 164,
pv: ["Qe2", "Bc5", "Nc3", "O-O", "d3", "d6", "O-O", "Bg4"],
},
{
san: "Ng5",
uci: "f3g5",
fen: "r1bqkb1r/pppp1ppp/2n2n2/4p1N1/2B1P3/8/PPPP1PPP/RNBQK2R b KQkq - 5 4",
check: "",
opening: "Italian Game: Two Knights Defense, Knight Attack",
inBook: false,
evalCp: -12,
loss: 31,
better: "d3",
threat: "Nxf7",
depth: 12,
selDepth: 25,
nodes: 376002,
searchMs: 259,
pv: ["d5", "exd5", "Nb4", "d6"],
},
{
san: "d5",
uci: "d7d5",
fen: "r1bqkb1r/ppp2ppp/2n2n2/3pp1N1/2B1P3/8/PPPP1PPP/RNBQK2R w KQkq d6 0 5",
check: "",
opening: "Italian Game: Two Knights Defense, Knight Attack, Normal Variation",
inBook: false,
evalCp: -3,
loss: 0,
better: "",
threat: "dxc4",
depth: 12,
selDepth: 14,
nodes: 300523,
searchMs: 189,
pv: ["exd5", "Nxd5"],
},
{
san: "exd5",
uci: "e4d5",
fen: "r1bqkb1r/ppp2ppp/2n2n2/3Pp1N1/2B5/8/PPPP1PPP/RNBQK2R b KQkq - 0 5",
check: "",
opening: "",
inBook: false,
evalCp: -17,
loss: 0,
better: "",
threat: "dxc6",
depth: 12,
selDepth: 18,
nodes: 329661,
searchMs: 203,
pv: ["Nxd5", "Nc3"],
},
{
san: "Nxd5",
uci: "f6d5",
fen: "r1bqkb1r/ppp2ppp/2n5/3np1N1/2B5/8/PPPP1PPP/RNBQK2R w KQkq - 0 6",
check: "",
opening: "",
inBook: false,
evalCp: -14,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 23,
nodes: 522301,
searchMs: 323,
pv: ["O-O", "Be7", "Qf3", "Bxg5", "Bxd5", "Qf6", "Bxc6+", "bxc6", "Re1", "O-O", "Qe4"],
},
{
san: "Nxf7",
uci: "g5f7",
fen: "r1bqkb1r/ppp2Npp/2n5/3np3/2B5/8/PPPP1PPP/RNBQK2R b KQkq - 0 6",
check: "",
opening: "Italian Game: Two Knights Defense, Fried Liver Attack",
inBook: false,
evalCp: -109,
loss: 103,
better: "O-O",
threat: "Nxd8",
depth: 12,
selDepth: 24,
nodes: 368305,
searchMs: 235,
pv: ["Kxf7", "Qf3+", "Ke6", "Nc3", "Ne7", "d4"],
},
{
san: "Kxf7",
uci: "e8f7",
fen: "r1bq1b1r/ppp2kpp/2n5/3np3/2B5/8/PPPP1PPP/RNBQK2R w KQ - 0 7",
check: "",
opening: "",
inBook: false,
evalCp: -133,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 27,
nodes: 606665,
searchMs: 384,
pv: ["Qf3+", "Ke6", "Nc3", "Ne7", "d4"],
},
{
san: "Qf3+",
uci: "d1f3",
fen: "r1bq1b1r/ppp2kpp/2n5/3np3/2B5/5Q2/PPPP1PPP/RNB1K2R b KQ - 1 7",
check: "f7",
opening: "",
inBook: false,
evalCp: -143,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 22,
nodes: 341112,
searchMs: 225,
pv: ["Ke6", "Nc3", "Nb4", "Bb3", "c6", "a3", "Qa5", "O-O"],
},
{
san: "Ke6",
uci: "f7e6",
fen: "r1bq1b1r/ppp3pp/2n1k3/3np3/2B5/5Q2/PPPP1PPP/RNB1K2R w KQ - 2 8",
check: "",
opening: "",
inBook: false,
evalCp: -137,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 26,
nodes: 374199,
searchMs: 236,
pv: ["Nc3", "Nb4", "Nxd5", "Nxd5", "d4", "c6", "dxe5", "b5", "Bd3"],
},
{
san: "Nc3",
uci: "b1c3",
fen: "r1bq1b1r/ppp3pp/2n1k3/3np3/2B5/2N2Q2/PPPP1PPP/R1B1K2R b KQ - 3 8",
check: "",
opening: "",
inBook: false,
evalCp: -119,
loss: 0,
better: "",
threat: "Bxd5+",
depth: 12,
selDepth: 23,
nodes: 442283,
searchMs: 280,
pv: ["Ne7", "Qe4", "c6", "d4", "Kd7", "dxe5", "Nxc3", "e6+", "Ke8", "bxc3", "Nd5", "Bd2", "Qd6", "Rb1"],
},
{
san: "Nb4",
uci: "c6b4",
fen: "r1bq1b1r/ppp3pp/4k3/3np3/1nB5/2N2Q2/PPPP1PPP/R1B1K2R w KQ - 4 9",
check: "",
opening: "",
inBook: false,
evalCp: -112,
loss: 34,
better: "Ne7",
threat: "Nxc2+",
depth: 12,
selDepth: 22,
nodes: 724208,
searchMs: 518,
pv: ["Qe4", "b5"],
},
{
san: "a3",
uci: "a2a3",
fen: "r1bq1b1r/ppp3pp/4k3/3np3/1nB5/P1N2Q2/1PPP1PPP/R1B1K2R b KQ - 0 9",
check: "",
opening: "",
inBook: false,
evalCp: -296,
loss: 221,
better: "d4",
threat: "axb4",
depth: 12,
selDepth: 29,
nodes: 782643,
searchMs: 515,
pv: ["Nxc2+", "Kd1"],
},
{
san: "Nxc2+",
uci: "b4c2",
fen: "r1bq1b1r/ppp3pp/4k3/3np3/2B5/P1N2Q2/1PnP1PPP/R1B1K2R w KQ - 0 10",
check: "e1",
opening: "",
inBook: false,
evalCp: -304,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 25,
nodes: 275466,
searchMs: 184,
pv: [
"Kd1",
"Nxa1",
"Bxd5+",
"Kd7",
"d4",
"c6",
"Qf5+",
"Kc7",
"Qxe5+",
"Qd6",
"Bc4",
"b5",
"Bf7",
"Qxe5",
"dxe5",
"Bg4+",
"f3",
"Bf5",
],
},
{
san: "Kd1",
uci: "e1d1",
fen: "r1bq1b1r/ppp3pp/4k3/3np3/2B5/P1N2Q2/1PnP1PPP/R1BK3R b - - 1 10",
check: "",
opening: "",
inBook: false,
evalCp: -271,
loss: 0,
better: "",
threat: "Bxd5+",
depth: 12,
selDepth: 29,
nodes: 496807,
searchMs: 320,
pv: ["Nxa1", "Bxd5+", "Kd7", "d4", "Qf6", "Qg4+", "Kd8"],
},
{
san: "Nxa1",
uci: "c2a1",
fen: "r1bq1b1r/ppp3pp/4k3/3np3/2B5/P1N2Q2/1P1P1PPP/n1BK3R w - - 0 11",
check: "",
opening: "",
inBook: false,
evalCp: -259,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 26,
nodes: 1149461,
searchMs: 755,
pv: [
"Bxd5+",
"Kd7",
"Qf5+",
"Kd6",
"Qb1",
"c6",
"Ba2",
"Kc7",
"Qxa1",
"Qg5",
"g3",
"Qg4+",
"Ke1",
"Qf3",
"Rg1",
"Bf5",
"b4",
"Be7",
"Bc4",
],
},
{
san: "Bxd5+",
uci: "c4d5",
fen: "r1bq1b1r/ppp3pp/4k3/3Bp3/8/P1N2Q2/1P1P1PPP/n1BK3R b - - 0 11",
check: "e6",
opening: "",
inBook: false,
evalCp: -254,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 27,
nodes: 839858,
searchMs: 537,
pv: ["Kd7"],
},
{
san: "Kd7",
uci: "e6d7",
fen: "r1bq1b1r/pppk2pp/8/3Bp3/8/P1N2Q2/1P1P1PPP/n1BK3R w - - 1 12",
check: "",
opening: "",
inBook: false,
evalCp: -283,
loss: 0,
better: "",
threat: "",
depth: 12,
selDepth: 27,
nodes: 567240,
searchMs: 355,
pv: ["d4", "c6"],
},
];
// The legal destinations of each White piece at every position where White is to move, keyed by ply, from
// Position.GenerateLegal in core/chess. "c3:b1,a2" reads: the piece on c3 can move to b1 or a2.
const CREW_TARGETS: Readonly<Record<number, string>> = {
0: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3,c4 d2:d3,d4 e2:e3,e4 f2:f3,f4 g1:f3,h3 g2:g3,g4 h2:h3,h4",
2: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3,c4 d1:e2,f3,g4,h5 d2:d3,d4 e1:e2 f1:e2,d3,c4,b5,a6 f2:f3,f4 g1:e2,f3,h3 g2:g3,g4 h2:h3,h4",
4: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3,c4 d1:e2 d2:d3,d4 e1:e2 f1:e2,d3,c4,b5,a6 f3:g1,d4,h4,e5,g5 g2:g3,g4 h1:g1 h2:h3,h4",
6: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3 c4:f1,e2,b3,d3,b5,d5,a6,e6,f7 d1:e2 d2:d3,d4 e1:f1,e2,g1 f3:g1,d4,h4,e5,g5 g2:g3,g4 h1:f1,g1 h2:h3,h4",
8: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3 c4:f1,e2,b3,d3,b5,d5,a6 d1:e2,f3,g4,h5 d2:d3,d4 e1:f1,e2,g1 e4:d5 f2:f3,f4 g2:g3,g4 g5:f3,h3,e6,f7,h7 h1:f1,g1 h2:h3,h4",
10: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3 c4:f1,e2,b3,d3,b5,d5,a6 d1:e2,f3,g4,h5 d2:d3,d4 e1:f1,e2,g1 f2:f3,f4 g2:g3,g4 g5:f3,h3,e4,e6,f7,h7 h1:f1,g1 h2:h3,h4",
12: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3 c4:f1,e2,b3,d3,b5,d5,a6 d1:e2,f3,g4,h5 d2:d3,d4 e1:f1,e2,g1 f2:f3,f4 g2:g3,g4 h1:f1,g1 h2:h3,h4",
14: "a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3 c4:f1,e2,b3,d3,b5,d5,a6 d2:d3,d4 e1:d1,f1,e2,g1 f3:d1,e2,a3,b3,c3,d3,e3,g3,h3,e4,f4,g4,d5,f5,h5,f6,f7,f8 g2:g3,g4 h1:f1,g1 h2:h3,h4",
16: "a1:b1 a2:a3,a4 b2:b3 c3:b1,d1,e2,a4,e4,b5,d5 c4:f1,e2,b3,d3,b5,d5,a6 d2:d3,d4 e1:d1,f1,e2,g1 f3:d1,e2,d3,e3,g3,h3,e4,f4,g4,d5,f5,h5,f6,f7,f8 g2:g3,g4 h1:f1,g1 h2:h3,h4",
18: "e1:d1,f1,e2",
20: "a3:a4 b2:b3,b4 c3:b1,a2,e2,a4,e4,b5,d5 c4:f1,a2,e2,b3,d3,b5,d5,a6 d1:e1,e2 d2:d3,d4 f3:e2,d3,e3,g3,h3,e4,f4,g4,d5,f5,h5,f6,f7,f8 g2:g3,g4 h1:e1,f1,g1 h2:h3,h4",
22: "a3:a4 b2:b3,b4 c3:b1,a2,e2,a4,e4,b5 d1:e1,e2 d2:d3,d4 d5:a2,b3,c4,e4,c6,e6,b7,f7,g8 f3:e2,d3,e3,g3,h3,e4,f4,g4,f5,h5,f6,f7,f8 g2:g3,g4 h1:e1,f1,g1 h2:h3,h4",
};
/** The legal destinations of the piece on a square at a ply where the crew is to move. */
function targetsFrom(ply: number, square: string): readonly string[] {
const entry: string | undefined = (CREW_TARGETS[ply] ?? "")
.split(" ")
.find((candidate: string): boolean => candidate.startsWith(`${square}:`));
return entry === undefined ? [] : entry.slice(3).split(",");
}
/** An empty board, for a ply outside the game; every caller passes a ply of the game. */
const NO_PLY: Ply = {
inBook: false,
selDepth: 0,
nodes: 0,
searchMs: 0,
loss: 0,
better: "",
threat: "",
san: "",
uci: "",
fen: "8/8/8/8/8/8/8/8",
check: "",
opening: "",
evalCp: 0,
depth: 0,
pv: [],
};
/** A position of the game by ply. */
function plyAt(ply: number): Ply {
return GAME.at(ply) ?? NO_PLY;
}
/** The opening the game reached last at or before a ply: a name holds until a later position has its own. */
function openingAt(ply: number): string {
for (let index: number = ply; index >= 0; index--) {
const name: string = plyAt(index).opening;
if (name !== "") return name;
}
return "";
}
/** The engine's reading from the crew's side, as a sentence, as a score with its owner, and as one short line. */
interface Standing {
words: string;
score: string;
/** The verdict and the score on one line: "bosun is clearly better · +2.8", "Level · you +0.2". */
short: string;
}
/**
* The engine's reading from the crew's side (White here): who stands better in words, the bands Annotate in core/chess
* uses (under half a pawn level, under 1.5 slightly, under 4 clearly, beyond that winning), and the score with its
* owner in pawns to one decimal, signed as the material line is: "bosun +2.8".
*/
function standing(ply: Ply): Standing {
if (ply.mateInPlies !== undefined) {
const moves: number = Math.ceil(Math.abs(ply.mateInPlies) / 2);
const words: string = ply.mateInPlies > 0 ? `You mate in ${String(moves)}` : `bosun mates in ${String(moves)}`;
return { words: `${words}.`, score: "mate", short: words };
}
const pawns: string = (Math.abs(ply.evalCp) / 100).toFixed(1);
const owner: string = ply.evalCp > 0 ? "you" : "bosun";
const score: string = pawns === "0.0" ? "0.0" : `${owner} +${pawns}`;
const size: number = Math.abs(ply.evalCp);
if (size < 50) {
return { words: "The position is level.", score, short: `Level · ${score}` };
}
const degree: string = size < 150 ? "slightly better" : size < 400 ? "clearly better" : "winning";
const words: string = ply.evalCp > 0 ? `You are ${degree}` : `bosun is ${degree}`;
return { words: `${words}.`, score, short: `${words} · +${pawns}` };
}
/** White's share of the evaluation bar in percent: half at a level score, approaching the ends as one side wins. */
function whiteShare(evalCp: number): number {
return 100 / (1 + Math.exp(-evalCp / 250));
}
const PIECE_VALUES: Readonly<Record<string, number>> = { p: 1, n: 3, b: 3, r: 5, q: 9 };
const START_COUNTS: Readonly<Record<string, number>> = { q: 1, r: 2, b: 2, n: 2, p: 8 };
const GLYPHS: Readonly<Record<string, string>> = {
Q: "♕",
R: "♖",
B: "♗",
N: "♘",
P: "♙",
q: "♛",
r: "♜",
b: "♝",
n: "♞",
p: "♟",
};
const PIECE_NAMES: Readonly<Record<string, [string, string]>> = {
q: ["queen", "queens"],
r: ["rook", "rooks"],
b: ["bishop", "bishops"],
n: ["knight", "knights"],
p: ["pawn", "pawns"],
};
interface Taken {
/** The taken pieces as glyphs, most valuable first. */
glyphs: string;
/** The same pieces in words, for a screen reader. */
words: string;
}
/** The pieces of one color missing from a position, counted against the starting set. */
function takenPieces(fen: string, color: "white" | "black"): Taken {
const placement: string = fen.split(" ")[0] ?? "";
const glyphs: string[] = [];
const words: string[] = [];
for (const piece of ["q", "r", "b", "n", "p"]) {
const letter: string = color === "white" ? piece.toUpperCase() : piece;
let onBoard: number = 0;
for (const character of placement) {
if (character === letter) onBoard++;
}
const missing: number = Math.max(0, (START_COUNTS[piece] ?? 0) - onBoard);
if (missing === 0) continue;
glyphs.push((GLYPHS[letter] ?? "").repeat(missing));
const [one, many]: [string, string] = PIECE_NAMES[piece] ?? ["", ""];
words.push(missing === 1 ? one : `${String(missing)} ${many}`);
}
return { glyphs: glyphs.join(""), words: words.join(", ") };
}
/** The material balance from the crew's side (White), in points. */
function materialBalance(fen: string): number {
let balance: number = 0;
for (const character of fen.split(" ")[0] ?? "") {
const value: number = PIECE_VALUES[character.toLowerCase()] ?? 0;
balance += character === character.toUpperCase() ? value : -value;
}
return balance;
}
// The evaluation bar's two parts: the crew's (White's) share from the bottom, bosun's above it, in the colors the
// board draws White's and Black's pieces in.
const YOUR_SHARE: string = "var(--text)";
const BOSUN_SHARE: string = "color-mix(in srgb, var(--text-dim) 30%, transparent)";
/** The height of a row of the move list. */
const MOVE_ROW_HEIGHT: string = "1.6em";
/** The move list's columns: the move number, White's move and Black's, shared by the list and its headings. */
const MOVE_COLUMNS: string = "2em 1fr 1fr";
const SMALL_TEXT: CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.4,
color: "var(--text-dim)",
};
/** The number of positions after the start: the plies played. */
const PLIES_PLAYED: number = GAME.length - 1;
export default function GameReview(): ReactElement {
// The game as it stands: Undo shortens it by the crew's move and bosun's reply.
const [plyCount, setPlyCount]: [number, Dispatch<SetStateAction<number>>] = useState<number>(PLIES_PLAYED);
const [shownPly, setShownPly]: [number, Dispatch<SetStateAction<number>>] = useState<number>(PLIES_PLAYED);
const [targets, setTargets]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
readonly string[]
>([]);
const [resigned, setResigned]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const [confirmingResign, setConfirmingResign]: [boolean, Dispatch<SetStateAction<boolean>>] =
useState<boolean>(false);
const reducedMotion: boolean = usePrefersReducedMotion();
const moveList: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
// Keep the shown move in view: scroll the list, never the page, by the least that shows the move's whole row.
useEffect((): void => {
const list: HTMLDivElement | null = moveList.current;
if (list === null) return;
revealShownMove(list, reducedMotion ? "auto" : "smooth");
}, [shownPly, plyCount, reducedMotion]);
// And when the list changes height, as it does when a phone turns on its side and the list shrinks to the rows left
// beside the board: the shown move stays in view at once.
useEffect((): (() => void) | undefined => {
const list: HTMLDivElement | null = moveList.current;
if (list === null || typeof ResizeObserver === "undefined") return undefined;
const observer: ResizeObserver = new ResizeObserver((): void => {
revealShownMove(list, "auto");
});
observer.observe(list);
return (): void => {
observer.disconnect();
};
}, []);
const reviewing: boolean = shownPly < plyCount;
const position: Ply = plyAt(shownPly);
const opening: string = openingAt(shownPly);
const balance: number = materialBalance(position.fen);
const takenByCrew: Taken = takenPieces(position.fen, "black");
const takenByBosun: Taken = takenPieces(position.fen, "white");
const share: number = whiteShare(position.evalCp);
const reading: Standing = standing(position);
const bestMove: string | undefined = position.pv.at(0);
const continuation: readonly string[] = position.pv.slice(1);
function show(ply: number): void {
setShownPly(Math.min(Math.max(ply, 0), plyCount));
setTargets([]);
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
switch (event.key) {
case "ArrowLeft":
show(shownPly - 1);
break;
case "ArrowRight":
show(shownPly + 1);
break;
case "Home":
show(0);
break;
case "End":
show(plyCount);
break;
default:
return;
}
event.preventDefault();
}
function undo(): void {
const previous: number = Math.max(plyCount - 2, 0);
setPlyCount(previous);
setShownPly(previous);
setTargets([]);
}
const moveRows: ReactElement[] = [];
for (let ply: number = 1; ply <= plyCount; ply += 2) {
const moveNumber: number = (ply + 1) / 2;
moveRows.push(
<div key={ply} style={{ display: "contents" }}>
<span style={{ color: "var(--text-dim)", fontSize: 11 }}>{moveNumber}.</span>
<MoveButton ply={ply} current={shownPly === ply} onSelect={show} />
{ply + 1 <= plyCount ? <MoveButton ply={ply + 1} current={shownPly === ply + 1} onSelect={show} /> : <span />}
</div>,
);
}
return (
// The group takes focus so the arrow keys step through the game; its buttons pass the keys up to it.
<div role="group" aria-label="Chess game" tabIndex={0} onKeyDown={handleKeyDown} className="chess-game">
<style href="bosunkit-chess-game-example" precedence="default">
{LAYOUT}
</style>
<div className="chess-game__layout">
<div className="chess-game__main">
<div className="chess-game__board">
<Tooltip
title="Evaluation bar"
content="Your share fills from the bottom in White's color, bosun's from the top; half each is level."
side="left"
>
<div
role="img"
tabIndex={0}
aria-label={`Evaluation bar, ${reading.score}: you, White, at the bottom, bosun at the top`}
style={{
display: "flex",
flexDirection: "column",
width: 8,
borderRadius: "var(--radius-sm)",
overflow: "hidden",
border: "1px solid var(--border)",
}}
>
<div style={{ flexGrow: 100 - share, background: BOSUN_SHARE }} />
<div style={{ flexGrow: share, background: YOUR_SHARE }} />
</div>
</Tooltip>
<div style={{ flex: 1, minWidth: 0 }}>
<ChessBoard
fen={position.fen}
playerColor="white"
canMove={!reviewing && !resigned}
lastMoveUCI={position.uci}
checkSquare={position.check === "" ? undefined : position.check}
targets={reviewing ? undefined : targets}
onSelect={(square: string | null): void => {
setTargets(square === null ? [] : targetsFrom(plyCount, square));
}}
onMove={(): void => {
// The example has no engine to answer a move, so the board stays as it is.
setTargets([]);
}}
/>
</div>
</div>
{/* One line of fixed height: a capture adds a glyph without moving the controls below. */}
<div
className="chess-game__taken"
style={{
...SMALL_TEXT,
display: "flex",
gap: "var(--space-md)",
height: "1.4em",
whiteSpace: "nowrap",
overflow: "hidden",
}}
>
{takenByCrew.words !== "" && (
<span role="img" aria-label={`You took ${takenByCrew.words}`} style={PIECE_GLYPH}>
{takenByCrew.glyphs}
</span>
)}
{takenByBosun.words !== "" && (
<span role="img" aria-label={`bosun took ${takenByBosun.words}`} style={PIECE_GLYPH}>
{takenByBosun.glyphs}
</span>
)}
{balance !== 0 && (
<span style={{ color: balance > 0 ? "var(--green)" : "var(--amber)" }}>
{balance > 0 ? `Material: you +${String(balance)}` : `Material: bosun +${String(-balance)}`}
</span>
)}
</div>
{/* Two groups that never break apart: the move controls, and the game actions; the row wraps between them. */}
<div
className="chess-game__controls"
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "var(--space-xs)",
}}
>
<div role="group" aria-label="Moves" style={{ display: "flex", flexWrap: "nowrap" }}>
<Button
variant="ghost"
size="icon"
aria-label="Start position"
disabled={shownPly === 0}
onClick={(): void => {
show(0);
}}
>
<ChevronFirst size={16} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Previous move"
disabled={shownPly === 0}
onClick={(): void => {
show(shownPly - 1);
}}
>
<ChevronLeft size={16} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Next move"
disabled={!reviewing}
onClick={(): void => {
show(shownPly + 1);
}}
>
<ChevronRight size={16} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Latest move"
disabled={!reviewing}
onClick={(): void => {
show(plyCount);
}}
>
<ChevronLast size={16} aria-hidden />
</Button>
</div>
<div style={{ display: "flex", flexWrap: "nowrap", gap: "var(--space-xs)", marginInlineStart: "auto" }}>
<Button variant="ghost" size="sm" disabled={resigned || plyCount === 0} onClick={undo}>
Undo
</Button>
<Button
variant="ghost"
size="sm"
disabled={resigned}
onClick={(): void => {
setConfirmingResign(true);
}}
>
Resign
</Button>
</div>
</div>
</div>
{/* As tall as the board column on a wide frame: the move list takes what the lines above it leave. */}
<div className="chess-game__side">
<div className="chess-game__side-content" style={SMALL_TEXT}>
<div style={{ display: "flex", alignItems: "center", minHeight: "2em" }}>
{resigned ? (
<Badge variant="amber">Resigned</Badge>
) : reviewing ? (
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Badge variant="amber">Reviewing</Badge>
<span>
<FigurineText san={reviewedMove(shownPly)} side={sideOf(shownPly)} />
</span>
</span>
) : (
<Badge variant="green">Your move</Badge>
)}
</div>
{/* The opening's name when the game has reached one, with where the book stands in its tooltip; before
a named position, the book's state itself. The threat has its own line, short enough to fit whole. */}
<OneLine
full={opening === "" ? bookLine(shownPly) : `${opening}. ${bookLine(shownPly)}.`}
shown={opening === "" ? bookLine(shownPly) : specificOpening(opening)}
color="var(--text)"
info={
<InfoTip size="compact" title="Opening book">
The known opening moves bosun replies with from memory. Once the game leaves them, bosun works out
each reply with a search.
</InfoTip>
}
/>
<OneLine full={threatLine(position)} pieces={position.fen.split(" ")[1] === "w" ? "black" : "white"} />
<OneLine
full={reading.short}
color="var(--text)"
info={
<InfoTip size="compact" title="Score">
Who stands better, in pawns: +1 is about a pawn's worth of advantage. Under half a pawn either way,
the position is level.
</InfoTip>
}
/>
<OneLine
full={searchEffort(position)}
shown={searchSummary(position)}
info={
<InfoTip size="compact" title="Search">
How far bosun looked before it scored the position. Depth is the number of moves ahead it checked
every reply, one side's move each; the second figure is the deepest line it followed. Nodes are the
positions it weighed.
</InfoTip>
}
/>
{/* The best move and the next two of its line on the row; the whole line in the tooltip. */}
<OneLine
full={
bestMove === undefined
? "No move to play"
: `${position.fen.split(" ")[1] === "w" ? "Best for you" : "Best for bosun"}: ${bestMove}${
continuation.length > 0 ? ` (${continuation.join(" ")})` : ""
}`
}
shown={
bestMove === undefined
? "No move to play"
: `${position.fen.split(" ")[1] === "w" ? "Best for you" : "Best for bosun"}: ${bestMove}${
continuation.length > 0
? ` (${continuation.slice(0, 2).join(" ")}${continuation.length > 2 ? " …" : ""})`
: ""
}`
}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--space-xs)",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
<span>Moves</span>
<InfoTip size="compact" title="Move marks">
A mark follows a move that lost ground against the engine's best: ?! half a pawn, ? a pawn, ?? three
pawns.
</InfoTip>
<span style={{ flex: 1 }} />
<span>{plyCount === 1 ? "1 ply" : `${String(plyCount)} plies`}</span>
</div>
<div
aria-hidden="true"
style={{
display: "grid",
gridTemplateColumns: MOVE_COLUMNS,
columnGap: "var(--space-sm)",
fontSize: 10,
textTransform: "uppercase",
letterSpacing: 1,
whiteSpace: "nowrap",
}}
>
<span />
<span style={{ color: SIDE_COLORS.white, ...PIECE_GLYPH }}>♔ You</span>
<span style={{ color: SIDE_COLORS.black, ...PIECE_GLYPH }}>♚ bosun</span>
</div>
<div
ref={moveList}
className="chess-game__moves"
style={{
position: "relative",
display: "grid",
gridTemplateColumns: MOVE_COLUMNS,
gridAutoRows: MOVE_ROW_HEIGHT,
alignContent: "start",
columnGap: "var(--space-sm)",
alignItems: "center",
overflowY: "auto",
fontSize: 13,
color: "var(--text)",
}}
>
{moveRows}
</div>
</div>
</div>
</div>
<TaskDialog
open={confirmingResign}
onOpenChange={(open: boolean): void => {
if (!open) {
setConfirmingResign(false);
}
}}
title="Resign this game?"
actions={
<>
<Button
variant="ghost"
onClick={(): void => {
setConfirmingResign(false);
}}
>
Keep playing
</Button>
<Button
variant="danger"
onClick={(): void => {
setConfirmingResign(false);
setResigned(true);
setTargets([]);
}}
>
Resign
</Button>
</>
}
>
The game ends here and counts as a win for bosun.
</TaskDialog>
</div>
);
}
/** Scrolls the move list by the least that shows the shown move's whole row, or to its top before the first move. */
function revealShownMove(list: HTMLElement, behavior: ScrollBehavior): void {
const current: HTMLElement | null = list.querySelector<HTMLElement>('[aria-current="step"]');
if (current === null) {
list.scrollTo({ top: 0, behavior });
return;
}
const top: number = current.offsetTop;
const bottom: number = top + current.offsetHeight;
if (top < list.scrollTop) {
list.scrollTo({ top, behavior });
} else if (bottom > list.scrollTop + list.clientHeight) {
list.scrollTo({ top: bottom - list.clientHeight, behavior });
}
}
/** A mark for a move that lost ground, with its name. */
interface MoveMark {
symbol: "?!" | "?" | "??";
word: "Inaccuracy" | "Mistake" | "Blunder";
}
/**
* The mark for a move by the centipawns it lost: half a pawn an inaccuracy, a pawn a mistake, three pawns a blunder.
* core/chess measures the loss and names no band; these are the bands chess software has long used with it.
*/
function markOf(loss: number): MoveMark | null {
if (loss >= 300) return { symbol: "??", word: "Blunder" };
if (loss >= 100) return { symbol: "?", word: "Mistake" };
if (loss >= 50) return { symbol: "?!", word: "Inaccuracy" };
return null;
}
/** What a marked move cost, in words: "Mistake: about 1.0 pawn lost; O-O was better." */
function markNote(mark: MoveMark, ply: Ply): string {
const pawns: string = (ply.loss / 100).toFixed(1);
const better: string = ply.better === "" ? "" : `; ${ply.better} was better`;
return `${mark.word}: about ${pawns} ${pawns === "1.0" ? "pawn" : "pawns"} lost${better}.`;
}
// The pieces' names and the glyphs of each side's set, for the move list's figurines and accessible names.
const PIECE_WORDS: Readonly<Record<string, string>> = { K: "king", Q: "queen", R: "rook", B: "bishop", N: "knight" };
const WHITE_FIGURINES: Readonly<Record<string, string>> = { K: "♔", Q: "♕", R: "♖", B: "♗", N: "♘" };
const BLACK_FIGURINES: Readonly<Record<string, string>> = { K: "♚", Q: "♛", R: "♜", B: "♝", N: "♞" };
/** The font the board draws its pieces in; the move list draws its figurines in the same one. */
const PIECE_FONT: string = '"Noto Sans Symbols 2", "DejaVu Sans", "Segoe UI Symbol", "Apple Symbols", sans-serif';
/** Each side's color in the move list: the colors the board draws White's and Black's pieces in. */
const SIDE_COLORS: Readonly<Record<"white" | "black", string>> = { white: "var(--text)", black: "var(--text-dim)" };
/** The side that played a ply: White plays the odd plies. */
function sideOf(ply: number): "white" | "black" {
return ply % 2 === 1 ? "white" : "black";
}
/** A piece letter's name, or nothing for a letter that names no piece (a pawn's move starts with its file). */
function pieceWord(letter: string): string | undefined {
return Object.hasOwn(PIECE_WORDS, letter) ? PIECE_WORDS[letter] : undefined;
}
/** A move in words for a screen reader: "knight takes f7, check", "e-pawn takes d5", "castles kingside". */
function spokenMove(san: string): string {
const ending: string = san.endsWith("#") ? ", checkmate" : san.endsWith("+") ? ", check" : "";
const body: string = san.replace(/[+#]$/, "");
if (body === "O-O-O") return `castles queenside${ending}`;
if (body === "O-O") return `castles kingside${ending}`;
const promotedTo: string | undefined = body.charAt(body.length - 2) === "=" ? pieceWord(body.slice(-1)) : undefined;
const move: string = promotedTo === undefined ? body : body.slice(0, -2);
const piece: string | undefined = pieceWord(move.charAt(0));
const rest: string = piece === undefined ? move : move.slice(1);
const captures: boolean = rest.includes("x");
const to: string = rest.slice(-2);
// What stands before the destination: a disambiguating file or rank, or a capturing pawn's file.
const from: string = rest.slice(0, -2).replace("x", "");
const mover: string =
piece === undefined ? (from === "" ? "pawn" : `${from}-pawn`) : from === "" ? piece : `${piece} from ${from}`;
const promoted: string = promotedTo === undefined ? "" : `, promotes to ${promotedTo}`;
return `${mover} ${captures ? "takes" : "to"} ${to}${promoted}${ending}`;
}
/** A move in figurine notation: the piece letter drawn as the side's own glyph, "♘f3" for White, "♞c6" for Black. */
/** A piece letter at a move's start, the promotion's letter after `=`, or a figurine glyph already written,
wherever it sits in the line. */
const FIGURINE_SPOT: RegExp = /[KQRBN♔♕♖♗♘♚♛♜♝♞]|=[QRBN]/;
/** The figurine glyph characters, drawn as they are when a line already writes them. */
const FIGURINE_GLYPH: RegExp = /[♔♕♖♗♘♚♛♜♝♞]/;
/** The glyph a piece letter of a side draws. */
function figurineGlyph(letter: string, side: "white" | "black"): string {
const glyphs: Readonly<Record<string, string>> = side === "white" ? WHITE_FIGURINES : BLACK_FIGURINES;
return Object.hasOwn(glyphs, letter) ? (glyphs[letter] ?? letter) : letter;
}
/** The piece glyphs set in the piece font the board uses, so a fallback never draws a box for one. */
const PIECE_GLYPH: CSSProperties = { fontFamily: PIECE_FONT };
/** A move in figurine notation, the piece glyphs in spans of the piece font; the letters and digits around them
keep the surrounding type. A piece letter becomes its glyph, a glyph already written stays as it is. */
function FigurineText({ san, side }: { san: string; side: "white" | "black" }): ReactElement {
const nodes: ReactNode[] = [];
let rest: string = san;
while (rest !== "") {
const spot: RegExpExecArray | null = FIGURINE_SPOT.exec(rest);
if (spot !== null && spot.index === 0) {
const token: string = spot[0];
if (token.startsWith("=")) {
nodes.push("=");
}
const letter: string = token.slice(-1);
nodes.push(
<span key={nodes.length} style={PIECE_GLYPH}>
{FIGURINE_GLYPH.test(letter) ? letter : figurineGlyph(letter, side)}
</span>,
);
rest = rest.slice(token.length);
} else {
const next: number = rest.search(FIGURINE_SPOT);
if (next === -1) {
nodes.push(rest);
rest = "";
} else {
nodes.push(rest.slice(0, next));
rest = rest.slice(next);
}
}
}
return <>{nodes}</>;
}
function figurine(san: string, side: "white" | "black"): string {
const glyphs: Readonly<Record<string, string>> = side === "white" ? WHITE_FIGURINES : BLACK_FIGURINES;
return san.replace(/^[KQRBN]|=[QRBN]/g, (letters: string): string => {
const letter: string = letters.slice(-1);
const glyph: string = Object.hasOwn(glyphs, letter) ? (glyphs[letter] ?? letter) : letter;
return letters.startsWith("=") ? `=${glyph}` : glyph;
});
}
/**
* Where the game stands against the opening book at a ply: in it, or the move after which the book had no reply
* ("Out of book after 3... Nf6").
*/
function bookLine(ply: number): string {
if (plyAt(ply).inBook) return "In the opening book";
let left: number = ply;
while (left > 0 && !plyAt(left - 1).inBook) left--;
const moveNumber: string = String(Math.ceil(left / 2));
const san: string = plyAt(left).san;
return left === 0 ? "Out of book" : `Out of book after ${moveNumber}.${sideOf(left) === "black" ? ".." : ""} ${san}`;
}
/** How hard the engine looked, in short: "depth 12/27 · 567k nodes". */
function searchSummary(position: Ply): string {
return `depth ${String(position.depth)}/${String(position.selDepth)} · ${(position.nodes / 1000).toFixed(0)}k nodes`;
}
/** How hard the engine looked: "depth 12, selective 27 · 567k nodes in 355 ms". */
function searchEffort(position: Ply): string {
const thousands: string = (position.nodes / 1000).toFixed(0);
return `depth ${String(position.depth)}, selective ${String(position.selDepth)} · ${thousands}k nodes in ${String(position.searchMs)} ms`;
}
/** The position under review, named by the move that reached it ("after 3... ♞c6"), or the start before any move. */
function reviewedMove(ply: number): string {
if (ply === 0) return "the start";
const side: "white" | "black" = sideOf(ply);
return `after ${String(Math.ceil(ply / 2))}.${side === "black" ? ".." : ""} ${figurine(plyAt(ply).san, side)}`;
}
/** The most specific part of an opening's name, which is what fits the line: "Fried Liver Attack" from "Italian Game: Two
* Knights Defense, Fried Liver Attack". The whole name stays in the line's tooltip. */
function specificOpening(name: string): string {
return name.split(/[:,] /).at(-1) ?? name;
}
/** Who threatens what in a position: the side not to move, named from the crew's side. */
function threatLine(position: Ply): string {
if (position.threat === "") return "No direct threat";
const whiteToMove: boolean = position.fen.split(" ")[1] === "w";
return whiteToMove
? `bosun threatens ${figurine(position.threat, "black")}`
: `You threaten ${figurine(position.threat, "white")}`;
}
// The layout: on a wide frame the board column sets the row's height and the side column fills it, its move list
// scrolling in what is left; on a narrow one the side column goes under the board and the list shows eight rows. A
// frame wide enough for a 480 px board beside the side column draws it at that size, its squares past the 48 px touch
// target. On a short screen (a phone on its side) the board takes the screen's height less its margins and the line
// of taken pieces, and the move controls go to the top of the side column, so the board and the column both fit.
const LAYOUT: string = `
.chess-game {
container: chess-game / inline-size;
width: min(100%, 600px);
}
.chess-game__layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-md);
}
.chess-game__main {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.chess-game__board {
display: flex;
gap: var(--space-xs);
}
.chess-game__side {
position: relative;
min-width: 0;
}
.chess-game__side-content {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.chess-game__moves {
max-height: calc(${MOVE_ROW_HEIGHT} * 8);
}
/* One move reads as its own button: a faint chip that lifts on hover, the current one tinted with its side's
color along its start edge. The row grid keeps its 1.6em pitch, so the chips stay dense. */
.chess-game__move {
appearance: none;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
background: color-mix(in srgb, var(--surface-elevated) 45%, transparent);
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
height: 100%;
padding: 0 var(--space-xs);
border-radius: var(--radius-sm);
}
.chess-game__move:hover {
background: color-mix(in srgb, var(--surface-elevated) 75%, transparent);
border-color: var(--border);
}
.chess-game__move:focus-visible {
outline: var(--bk-focus-width) solid var(--bk-focus-ring);
outline-offset: var(--bk-focus-offset);
}
.chess-game__move[data-current] {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
box-shadow: inset 3px 0 0 var(--move-side-color);
}
@container chess-game (min-width: 540px) {
.chess-game__layout {
grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
}
.chess-game__side-content {
position: absolute;
inset: 0;
}
.chess-game__moves {
flex: 1;
min-height: 0;
max-height: none;
}
}
@container bk-screen (min-width: 760px) {
.chess-game {
width: min(100%, 860px);
}
}
@container chess-game (min-width: 760px) {
.chess-game__layout {
grid-template-columns: minmax(0, 492px) minmax(0, 1fr);
}
}
@container bk-screen (max-height: 30em) {
.chess-game__layout {
grid-template-columns: min(492px, calc(100cqh - 5.5em)) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas: "board controls" "board side" "taken side";
row-gap: var(--space-sm);
}
.chess-game__main {
display: contents;
}
.chess-game__board {
grid-area: board;
}
.chess-game__taken {
grid-area: taken;
}
.chess-game__controls {
grid-area: controls;
}
.chess-game__side {
grid-area: side;
}
.chess-game__side-content {
position: absolute;
inset: 0;
}
.chess-game__moves {
flex: 1;
min-height: 0;
max-height: none;
}
}
`;
interface OneLineProps {
/** The whole text, in the tooltip and for a screen reader. */
full: string;
/** What the line shows, when shorter than the whole text; the whole text by default. */
shown?: string | undefined;
color?: string | undefined;
/** Which side's figurines the line carries, when it does; their glyphs draw in the piece font. */
pieces?: "white" | "black" | undefined;
/** An InfoTip after the text that explains the line's terms; it stays in view when the text is cut short. */
info?: ReactNode;
}
/**
* A line of fixed height that never wraps: it ends in an ellipsis where it runs out of room, and its tooltip, on hover
* or focus, gives the whole text.
*/
function OneLine({ full, shown, color, info, pieces }: OneLineProps): ReactElement {
const line: ReactElement = (
<Tooltip content={full}>
<span
tabIndex={0}
aria-label={full}
style={{
display: "block",
height: "1.4em",
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
color,
}}
>
{pieces === undefined ? (shown ?? full) : <FigurineText san={shown ?? full} side={pieces} />}
</span>
</Tooltip>
);
if (info === undefined) return line;
return (
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-xs)", minWidth: 0 }}>
{line}
{info}
</div>
);
}
interface MoveButtonProps {
ply: number;
current: boolean;
onSelect: (ply: number) => void;
}
/**
* One move in the list in figurine notation, named in words with its number and side. Pressing it shows the position
* the move reached; the shown move carries its side's color along its start edge. A move that lost ground carries its
* mark, and a tooltip says what it cost and which move was better.
*/
function MoveButton({ ply, current, onSelect }: MoveButtonProps): ReactElement {
const side: "white" | "black" = sideOf(ply);
const position: Ply = plyAt(ply);
const mark: MoveMark | null = markOf(position.loss);
const name: string = `Move ${String(Math.ceil(ply / 2))}, ${side === "white" ? "White" : "Black"}, ${spokenMove(position.san)}`;
const button: ReactElement = (
<button
type="button"
className="chess-game__move"
data-current={current ? "true" : undefined}
aria-current={current ? "step" : undefined}
aria-label={mark === null ? name : `${name}. ${markNote(mark, position)}`}
onClick={(): void => {
onSelect(ply);
}}
style={{ "--move-side-color": SIDE_COLORS[side] }}
>
<FigurineText san={position.san} side={side} />
{mark !== null && <strong style={{ marginLeft: "0.15em" }}>{mark.symbol}</strong>}
</button>
);
return mark === null ? button : <Tooltip content={markNote(mark, position)}>{button}</Tooltip>;
}
A chess board draws a position from its FEN string as a grid of squares with Unicode pieces. The side the crew plays
sits at the bottom. A move is entered by tapping a piece and then its square, or by dragging the piece. The board
checks only that the piece belongs to the crew; whether the move is legal is decided by the chess engine, which
answers an illegal move with an error. targets draws the selected piece's legal destinations as dots, lastMoveUCI
tints the squares of the last move, and checkSquare flags the king in check. When the page draws the next position
with its last move, the moved piece slides there and a taken piece fades out; motion={false} and reduced motion draw
the move at once.
The board draws one position and holds no game. The page around it keeps the game: the move list, the position shown
while the crew steps back through it, the engine's evaluation and the actions on the game. The positions, legal moves,
opening names and evaluations in these examples are the output of the chess engine (core/chess) for the positions
shown, written into each example as fixed data.
Playing and reviewing a game
The hero's game shows the page a board sits in. The arrow buttons, and the Left and Right arrow keys while the game has focus, step through the game one move at a time; Home and End go to the start and the latest move. The move list writes each move in figurine notation in its side's colors, under ♔ You and ♚ bosun, and names it in words for a screen reader ("Move 6, White, knight takes f7"); pressing a move shows the position it reached. A move that lost ground against the engine's best carries a mark (?! half a pawn, ? a pawn, ?? three pawns), and its tooltip names the loss and the better move; the marks are explained beside the Moves heading. A step forward slides the moved piece from its square; a step back or a jump is drawn at once. The board accepts a move only at the latest position; an earlier one is for review, marked "Reviewing" with the move that reached it ("after 3... ♞c6"). The engine's reading is from the crew's side: who stands better and by how many pawns ("bosun is clearly better · +2.8"), and below it how hard the engine looked, with the time in its tooltip; an info button after the opening, the score and the search line explains each. The bar beside the board fills from the bottom with the crew's share, names the score for a screen reader, and says how to read it in its tooltip. The best line is the engine's principal variation for the side to move. The threat line names what the other side would play were it its turn, when that is a capture or a check worth at least a pawn ("bosun threatens ♞xc2+"), on a line of its own. The line above it names the last opening the game passed through, with where the book stands in its tooltip; before the game reaches a named opening it shows the book's state itself ("In the opening book", "Out of book after 3... Nf6"). The side column is as tall as the board and its controls: each line in it keeps one line's height and ends in an ellipsis where it runs out of room, with the whole text in its tooltip, and the move list scrolls in the height left. Undo takes back the crew's last move and bosun's reply. This example has no engine to answer a move, so a move entered on the board leaves the position unchanged.
The layout depends on the screen. On a phone held upright the side column goes under the board and the move list shows eight rows. On a tablet the board is drawn at 480 px beside the side column, so each square is past the 48 px touch target. On a phone on its side the board takes the screen's height less its margins and the line of taken pieces, and the move controls go to the top of the side column, so the board and the column fit 393 px.
Examples
Engine analysis
The engine's top three moves for the side to move, each with its score from the crew's side, the line it expects and how hard it looked; the best is shown and the other two open on request. Below them, what the position holds, from the engine's static read of it: material, loose pieces, where the kings stand and the pawn structure.
- 1. Qg4+ bosun +2.6then Kd6 Qb4+ c5 Qe4 Qb6 Re1depth 12, selective 21 · 120k nodes
- bosun is up a rook.
- bosun's pawn on b7 is loose.
- Your king is still in the center and can no longer castle; bosun's king has left its back rank.
- Isolated pawns: yours on d2, bosun's on e5.
- Both sides keep the bishop pair.
Code
import { Button } from "@bosunkit/components/button";
import { ChessBoard } from "@bosunkit/components/chess-board";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@bosunkit/components/collapsible";
import { ChevronRight } from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The engine's analysis of one position from the crew's side: the best moves with their scores, then what the " +
"position holds: material, loose pieces, the kings and the pawns.";
/** One of the engine's candidate moves, with the line it expects and how hard it looked. */
interface Line {
/** The candidate move and the line after it, in SAN. */
pv: readonly string[];
/** The line's score in centipawns from White's side, the crew's. */
evalCp: number;
depth: number;
selDepth: number;
nodes: number;
}
// The game's latest position (1. e4 e5 2. Nf3 Nc6 3. Bc4 Nf6 4. Ng5 d5 5. exd5 Nxd5 6. Nxf7 Kxf7 7. Qf3+ Ke6 8. Nc3
// Nb4 9. a3 Nxc2+ 10. Kd1 Nxa1 11. Bxd5+ Kd7), produced by core/chess (packages/bosun/core/chess, run with
// GOEXPERIMENT=simd): the three lines from NewSearcher(NewHCE()).SearchMultiPV on one thread to depth 12, and the
// position notes from Annotate. A MultiPV search scores each candidate on its own, so its order can differ from a
// single search of the same position, which preferred d4 at -2.83.
const FEN: string = "r1bq1b1r/pppk2pp/8/3Bp3/8/P1N2Q2/1P1P1PPP/n1BK3R w - - 1 12";
const LINES: readonly Line[] = [
{ pv: ["Qg4+", "Kd6", "Qb4+", "c5", "Qe4", "Qb6", "Re1"], evalCp: -262, depth: 12, selDepth: 21, nodes: 119888 },
{ pv: ["Qf5+", "Kd6"], evalCp: -277, depth: 12, selDepth: 28, nodes: 647973 },
{ pv: ["d4", "c6"], evalCp: -283, depth: 12, selDepth: 27, nodes: 567240 },
];
// Annotate's facts for the position, worded from the crew's side: MaterialKind rooks, MaterialCount 1, MaterialCP
// -500; Hanging: Black's pawn on b7; WhiteKing InCenter, BlackKing neither castled nor in the center; WhiteIsolated d2,
// BlackIsolated e5, no passed or doubled pawns; both sides keep the bishop pair.
const NOTES: readonly string[] = [
"bosun is up a rook.",
"bosun's pawn on b7 is loose.",
"Your king is still in the center and can no longer castle; bosun's king has left its back rank.",
"Isolated pawns: yours on d2, bosun's on e5.",
"Both sides keep the bishop pair.",
];
const SMALL_TEXT: CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.4,
color: "var(--text-dim)",
};
/** A score in pawns with the side it favors, as the material line writes it: "bosun +2.6". */
function scoreFromCrewSide(evalCp: number): string {
const pawns: string = (Math.abs(evalCp) / 100).toFixed(1);
if (pawns === "0.0") return "0.0";
return evalCp > 0 ? `you +${pawns}` : `bosun +${pawns}`;
}
/** One candidate: its rank, the move, its score from the crew's side, the line after it and the search effort. */
function LineRow({ line, rank }: { line: Line; rank: number }): ReactElement {
const [move, ...rest]: readonly string[] = line.pv;
return (
<li style={{ display: "flex", flexDirection: "column" }}>
<span style={{ color: "var(--text)" }}>
{rank}. {move} <span style={{ color: "var(--text-dim)" }}>{scoreFromCrewSide(line.evalCp)}</span>
</span>
<span>{rest.length > 0 ? `then ${rest.join(" ")}` : "no further line"}</span>
<span>
depth {line.depth}, selective {line.selDepth} · {(line.nodes / 1000).toFixed(0)}k nodes
</span>
</li>
);
}
function ignore(): void {
// The example shows one position and takes no move.
}
export default function EngineAnalysis(): ReactElement {
const [open, setOpen]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
const best: Line | undefined = LINES.at(0);
const others: readonly Line[] = LINES.slice(1);
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-md)", width: "min(100%, 600px)" }}>
<div style={{ width: "min(100%, 280px)" }}>
<ChessBoard fen={FEN} playerColor="white" canMove={false} lastMoveUCI="e6d7" onMove={ignore} />
</div>
<div
style={{ ...SMALL_TEXT, display: "flex", flexDirection: "column", gap: "var(--space-sm)", flex: "1 1 220px" }}
>
<div style={{ color: "var(--text)" }}>Best moves for you</div>
<ol style={{ listStyle: "none", margin: 0, padding: 0 }}>
{best !== undefined && <LineRow line={best} rank={1} />}
</ol>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
<ChevronRight size={14} aria-hidden style={{ transform: open ? "rotate(90deg)" : "none" }} />
{open ? "Hide the other lines" : `Show ${String(others.length)} more lines`}
</CollapsibleTrigger>
<CollapsiblePanel>
<ol
start={2}
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: "var(--space-sm)",
}}
>
{others.map((line: Line, index: number): ReactElement => (
<LineRow key={line.pv.join(" ")} line={line} rank={index + 2} />
))}
</ol>
</CollapsiblePanel>
</Collapsible>
<div style={{ color: "var(--text)" }}>The position</div>
<ul style={{ margin: 0, paddingInlineStart: "1.2em" }}>
{NOTES.map((note: string): ReactElement => (
<li key={note}>{note}</li>
))}
</ul>
</div>
</div>
);
}
Legal moves
When the crew selects a piece (onSelect), the page asks the engine for its legal destinations and passes them
as targets.
Code
import { ChessBoard } from "@bosunkit/components/chess-board";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"White to move after 1. e4 e5 2. Nf3 Nc6 3. Bc4 Nf6: a selected piece shows its legal destinations as dots, " +
"and a ring on a square that holds a piece it can take.";
// The legal destinations of each White piece in this position, from Position.GenerateLegal in core/chess
// (packages/bosun/core/chess, run with GOEXPERIMENT=simd). "c4:f1,e2" reads: the piece on c4 can move to f1 or e2.
const TARGETS: string =
"a2:a3,a4 b1:a3,c3 b2:b3,b4 c2:c3 c4:f1,e2,b3,d3,b5,d5,a6,e6,f7 d1:e2 d2:d3,d4 e1:f1,e2,g1 " +
"f3:g1,d4,h4,e5,g5 g2:g3,g4 h1:f1,g1 h2:h3,h4";
/** The legal destinations of the piece on a square. */
function targetsFrom(square: string): readonly string[] {
const entry: string | undefined = TARGETS.split(" ").find((candidate: string): boolean =>
candidate.startsWith(`${square}:`),
);
return entry === undefined ? [] : entry.slice(3).split(",");
}
export default function LegalMoves(): ReactElement {
const [targets, setTargets]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
readonly string[]
>([]);
return (
<div style={{ width: "min(100%, 360px)" }}>
<ChessBoard
fen="r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4"
playerColor="white"
canMove
lastMoveUCI="g8f6"
targets={targets}
onSelect={(square: string | null): void => {
setTargets(square === null ? [] : targetsFrom(square));
}}
onMove={(): void => {
// The example has no engine to answer a move, so the board stays as it is.
setTargets([]);
}}
/>
</div>
);
}
Promotion
With onPromotionNeeded set, a pawn move to the last rank goes to the page, which asks for the piece and completes
the move. Without it, the board promotes to a queen.
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { ChessBoard } from "@bosunkit/components/chess-board";
import { TaskDialog } from "@bosunkit/components/task-dialog";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"A pawn reaching the last rank asks which piece it becomes: the board hands the move to the page, " +
"which shows the choice and completes it.";
// A position set up by hand and checked with ParseFEN in core/chess (packages/bosun/core/chess, run with
// GOEXPERIMENT=simd); the positions after each promotion and whether it gives check come from Game.Push and
// Position.InCheck.
const START: string = "r3k3/1P4p1/8/8/8/8/6PK/8 w q - 0 1";
const AFTER_PROMOTION: Readonly<Record<string, { fen: string; check: boolean }>> = {
b7b8q: { fen: "rQ2k3/6p1/8/8/8/8/6PK/8 b q - 0 1", check: true },
b7b8r: { fen: "rR2k3/6p1/8/8/8/8/6PK/8 b q - 0 1", check: true },
b7b8b: { fen: "rB2k3/6p1/8/8/8/8/6PK/8 b q - 0 1", check: false },
b7b8n: { fen: "rN2k3/6p1/8/8/8/8/6PK/8 b q - 0 1", check: false },
b7a8q: { fen: "Q3k3/6p1/8/8/8/8/6PK/8 b - - 0 1", check: true },
b7a8r: { fen: "R3k3/6p1/8/8/8/8/6PK/8 b - - 0 1", check: true },
b7a8b: { fen: "B3k3/6p1/8/8/8/8/6PK/8 b - - 0 1", check: false },
b7a8n: { fen: "N3k3/6p1/8/8/8/8/6PK/8 b - - 0 1", check: false },
};
const TARGETS: Readonly<Record<string, readonly string[]>> = {
b7: ["b8", "a8"],
g2: ["g3", "g4"],
h2: ["g1", "h1", "g3", "h3"],
};
const PIECES: readonly { letter: string; glyph: string; name: string }[] = [
{ letter: "q", glyph: "♕", name: "queen" },
{ letter: "r", glyph: "♖", name: "rook" },
{ letter: "b", glyph: "♗", name: "bishop" },
{ letter: "n", glyph: "♘", name: "knight" },
];
export default function Promotion(): ReactElement {
const [move, setMove]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [pending, setPending]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [targets, setTargets]: [readonly string[], Dispatch<SetStateAction<readonly string[]>>] = useState<
readonly string[]
>([]);
// No promotion has been played while the move is empty.
const after: { fen: string; check: boolean } | undefined = Object.hasOwn(AFTER_PROMOTION, move)
? AFTER_PROMOTION[move]
: undefined;
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", width: "min(100%, 360px)" }}>
{/* The button keeps its place while hidden, so the board below does not move when it appears. */}
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
{after === undefined ? <Badge variant="green">Your move</Badge> : <Badge variant="blue">bosun to move</Badge>}
<Button
variant="ghost"
size="sm"
disabled={after === undefined}
style={{ visibility: after === undefined ? "hidden" : "visible" }}
onClick={(): void => {
setMove("");
}}
>
Set up again
</Button>
</div>
<ChessBoard
fen={after?.fen ?? START}
playerColor="white"
canMove={after === undefined}
lastMoveUCI={after === undefined ? undefined : move.slice(0, 4)}
checkSquare={after?.check === true ? "e8" : undefined}
targets={targets}
onSelect={(square: string | null): void => {
setTargets(square === null ? [] : (TARGETS[square] ?? []));
}}
onPromotionNeeded={(from: string, to: string): void => {
setPending(`${from}${to}`);
}}
onMove={(): void => {
// Only the promotion is answered here; the example has no engine to answer another move.
setTargets([]);
}}
/>
<TaskDialog
open={pending !== ""}
onOpenChange={(open): void => { if (!open) ((): void => {
setPending("");
})(); }}
title="Promote to"
>
<div style={{ display: "flex", gap: "var(--space-sm)", justifyContent: "center" }}>
{PIECES.map((piece: { letter: string; glyph: string; name: string }): ReactElement => (
<Button
key={piece.letter}
aria-label={`promote to ${piece.name}`}
style={{ fontSize: 32, lineHeight: 1 }}
onClick={(): void => {
setMove(`${pending}${piece.letter}`);
setPending("");
}}
>
{piece.glyph}
</Button>
))}
</div>
</TaskDialog>
</div>
);
}
Draw available
A threefold repetition or the fifty-move rule makes a draw available without ending the game: play continues until the crew claims it. The counters under the board say how often the position has occurred and how many moves each side has made since the last capture or pawn move.
Code
import { Badge } from "@bosunkit/components/badge";
import { Button } from "@bosunkit/components/button";
import { ChessBoard } from "@bosunkit/components/chess-board";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"The starting position on the board for the third time: play goes on until the crew claims the draw, " +
"and the claim ends the game.";
// The game 1. Nf3 Nf6 2. Ng1 Ng8 3. Nf3 Nf6 4. Ng1 Ng8, replayed with ParseSAN and Game.Push in core/chess
// (packages/bosun/core/chess, run with GOEXPERIMENT=simd): Game.Status reports DrawRepetition after the last move,
// the position's key appears three times in the game's history (Game.IsRepetition(3)), and the FEN's halfmove clock
// counts 8 plies without a capture or a pawn move, of the 100 the fifty-move rule needs (Position.IsFiftyMove).
const FEN: string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 8 5";
const REPETITIONS: number = 3;
const HALFMOVE_CLOCK: number = 8;
const MOVES: string = "1. Nf3 Nf6 2. Ng1 Ng8 3. Nf3 Nf6 4. Ng1 Ng8";
export default function DrawAvailable(): ReactElement {
const [claimed, setClaimed]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", width: "min(100%, 360px)" }}>
{/* The button keeps its place once the draw is claimed, hidden, so the board below does not move. */}
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
<Badge variant="amber">{claimed ? "Draw by repetition" : "Draw available by repetition"}</Badge>
<Button
variant="primary"
size="sm"
disabled={claimed}
style={{ visibility: claimed ? "hidden" : "visible" }}
onClick={(): void => {
setClaimed(true);
}}
>
Claim draw
</Button>
</div>
<ChessBoard
fen={FEN}
playerColor="white"
canMove={!claimed}
lastMoveUCI="f6g8"
onMove={(): void => {
// The example has no engine to answer a move, so the board stays as it is.
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.4,
color: "var(--text-dim)",
}}
>
<span>{MOVES}</span>
<span>Same position {REPETITIONS} times: a draw is available from 3</span>
<span>{HALFMOVE_CLOCK / 2} of 50 moves without a capture or a pawn move: a draw is available at 50</span>
</div>
</div>
);
}
Start
Code
import { ChessBoard } from "@bosunkit/components/chess-board";
import type { ReactElement } from "react";
export const description: string = "The starting position with white at the bottom and the crew free to move.";
function ignore(): void {
// The example changes nothing on a move.
}
export default function Start(): ReactElement {
return (
<ChessBoard
fen="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
playerColor="white"
canMove
onMove={ignore}
/>
);
}
Black to move in check
Code
import { ChessBoard } from "@bosunkit/components/chess-board";
import type { ReactElement } from "react";
export const description: string =
"Black at the bottom after a queen's move from d8 to h4, with the king on e1 in check and no move allowed.";
function ignore(): void {
// The example changes nothing on a move.
}
export default function BlackToMoveInCheck(): ReactElement {
return (
<ChessBoard
fen="rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3"
playerColor="black"
canMove={false}
lastMoveUCI="d8h4"
checkSquare="e1"
onMove={ignore}
/>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| fenrequired | string | – | Position in FEN; only the piece-placement field is read. |
| playerColorrequired | "white" | "black" | – | Side the crew plays: the board is oriented with this side at the bottom. |
| canMoverequired | boolean | – | Whether move entry is currently allowed (crew's turn, game ongoing). |
| lastMoveUCI | string | undefined | – | Most recent move in UCI form ("e2e4"); its squares are tinted. |
| targets | readonly string[] | undefined | – | Legal destination squares for the selected piece, drawn as dots. |
| checkSquare | string | undefined | – | Square of the king in check, flagged red. |
| onSelect | ((square: string | null) => void) | undefined | – | Fired when the crew selects or deselects one of their pieces, so the page can fetch that piece's legal targets. |
| onPromotionNeeded | ((from: string, to: string) => void) | undefined | – | When set, a promoting pawn move calls this instead of onMove: the page shows the piece picker and completes
the move itself. |
| onMoverequired | (from: string, to: string, promotion: string) => void | – | Called with algebraic squares on a click-click or drag-and-drop move. Promotion auto-queens: the payload supports under-promotion, the UI doesn't yet. |
| motion | boolean | undefined | true | Whether a move slides: the moved piece glides from its origin square, a castling rook with its king, and a
captured piece fades out. On by default; reduced motion draws the move at once. Only a position that is the last
one drawn with lastMoveUCI played slides; any other new position, such as a step back through the game or a
jump of several moves, is drawn at once, and so is a move the crew dragged to its square. |
| label | string | undefined | – | The board's accessible name, in the crew's language; "chess board" in the crew's language by default. |
| className | string | undefined | – | Classes set on the board's own element. |
| testId | string | undefined | – | data-testid on the board; its squares are found by their accessible names. |
| ref | Ref<HTMLDivElement> | undefined | – | The board's own element, the grid. |
Related
- Motion: the duration and curve of a moved piece's slide, which reduced motion removes.
- Button: the move controls and the actions on the game.
- Task dialog: the dialogs that confirm a resignation and ask for a promotion piece.