[@preview]: ./chess.components.md

# Chess Components

[Study the Opera Game](./opera-game.demo.md) · [Play a game](./play.demo.md)

`<chess-game>` displays an annotated replay or a local two-player board.
`<chess-note>` adds Markdown commentary to a replay position. This literate
source pairs the implementation with explanations and working examples.

The wood and green felt take inspiration from [Deep Green](https://deepgreen.app/),
Joachim Bondo’s chess app.

---

<!-- componentName: chess-note -->

## Explain a position

`chess-note` attaches a title and Markdown commentary to a position. Its `ply`
counts half-moves: `0` is the starting position, `1` follows White's first move,
and `2` follows Black's reply. A game shows the latest note at or before its
selected position.

```html
<article class="pmx-chess-note" data-chess-note-ply="{{ ply }}">
  <h3>{{ title }}</h3>
  <div class="pmx-chess-note-body">
    <slot />
  </div>
</article>
```

```css
.pmx-chess-note {
  display: grid;
  gap: 0.35rem;
}

.pmx-chess-note h3 {
  margin: 0;
  font-size: 1rem;
}

.pmx-chess-note-body > :first-child {
  margin-block-start: 0;
}

.pmx-chess-note-body > :last-child {
  margin-block-end: 0;
}
```

### Example: annotate a move

```md
<chess-note ply="3" title="2. Nf3 · Develop with a threat">
  The knight joins the center and attacks e5. Black must answer the threat
  while White prepares to bring out another piece.
</chess-note>
```

<chess-note ply="3" title="2. Nf3 · Develop with a threat">
  The knight joins the center and attacks e5. Black must answer the threat
  while White prepares to bring out another piece.
</chess-note>

---

<!-- componentName: chess-game -->

## Replay or play a game

By default, `chess-game` replays a PGN (Portable Game Notation) move list
alongside its score and notes. Set `mode="play"` for a board where two people
can make moves. Both modes use the same board and pieces.

```html
<section
  id="{{ id }}"
  class="pmx-chess-game"
  data-chess-game
  data-chess-id="{{ id }}"
  data-pmx-beats="explicit"
>
  <div id="{{ id }}-stage" class="pmx-chess-game-stage" data-chess-stage>
    <div class="pmx-chess-game-board-panel">
      <div class="pmx-chess-board-frame">
        <div
          class="pmx-chess-board"
          data-chess-board
          role="grid"
          aria-label="Annotated chess game board"
          aria-rowcount="8"
          aria-colcount="8"
        ></div>
      </div>
      <div class="pmx-chess-game-controls" aria-label="Chess game controls">
        <button
          type="button"
          data-chess-action="first"
          aria-label="First position"
        >
          First
        </button>
        <button
          type="button"
          data-chess-action="prev"
          aria-label="Previous move"
        >
          Prev
        </button>
        <button type="button" data-chess-action="next" aria-label="Next move">
          Next
        </button>
        <button
          type="button"
          data-chess-action="last"
          aria-label="Last position"
        >
          Last
        </button>
      </div>
      <p class="pmx-chess-game-status" aria-live="polite">
        <strong data-chess-ply-label>Starting position</strong>
        <span data-chess-status>Loading chess game</span>
      </p>
    </div>
    <aside class="pmx-chess-game-analysis">
      <div class="pmx-chess-game-moves" data-chess-moves aria-label="Moves">
        <span class="pmx-chess-move-number">0.</span>
        <button
          class="pmx-chess-game-start"
          type="button"
          data-chess-game-move
          data-chess-ply="0"
          data-pmx-beat="{{ id }}:ply-0"
          data-pmx-play-target="#{{ id }}-stage"
        >
          Starting position
        </button>
      </div>
      <div class="pmx-chess-game-note" data-chess-note-panel>
        Select a move to see analysis.
      </div>
    </aside>
  </div>
  <div hidden data-chess-content>
    <slot />
  </div>
</section>
```

The board pairs pale maple with dark walnut. Local textures supply the grain;
layered highlights and shadows create the frame and recessed playing surface.
A fine green felt texture surrounds the board and replay controls. The analysis
panels use the site's theme colors.

```css
.pmx-chess-game {
  --chess-wood: #6b442e;
  --chess-wood-dark: #322016;
  --chess-light: #ead8b7;
  --chess-dark: #79563d;
  --chess-accent: #648779;
  --chess-maple: url("./assets/maple.webp");
  --chess-walnut: url("./assets/walnut.webp");
  --chess-light-square-image: linear-gradient(#f4e6cae0, #f4e6cabc), var(--chess-maple);
  --chess-dark-square-image: linear-gradient(#8e6c4d80, #8e6c4d60), var(--chess-walnut);
  display: block;
  margin-block: 1.5rem;
}

.pmx-chess-game-stage {
  display: grid;
  grid-template-columns: minmax(0, 1.55fr) minmax(12rem, 1fr);
  gap: clamp(1rem, 3vw, 1.5rem);
  align-items: start;
}

.pmx-chess-game-board-panel,
.pmx-chess-game-analysis {
  display: grid;
  gap: 0.8rem;
  min-inline-size: 0;
}

.pmx-chess-game-board-panel {
  padding: clamp(10px, 1.5vw, 18px);
  border-radius: 16px;
  background: linear-gradient(#09271b33, #061a1466), url("./assets/felt.webp");
  background-size: auto, 320px;
  background-color: #153b2c;
  border: 1px solid #183e30;
  box-shadow: inset 0 1px #ffffff18, inset 0 0 18px #04130c44, 0 12px 24px -18px #0008;
}
.pmx-chess-game-board-panel .pmx-chess-game-controls {
  background: #061c1466;
  border-color: #ffffff1c;
}
.pmx-chess-game-board-panel .pmx-chess-game-controls button { color: #edf4ee; }
.pmx-chess-game-board-panel .pmx-chess-game-controls button:hover:not(:disabled) {
  background: #ffffff14;
}
.pmx-chess-game-board-panel .pmx-chess-game-status { color: #bed1c5; }
.pmx-chess-game-board-panel .pmx-chess-game-status strong { color: #f0f5ef; }

.pmx-chess-board-frame {
  padding: clamp(12px, 2vw, 20px);
  border: 1px solid #3c281b;
  border-radius: 10px;
  background: linear-gradient(135deg, #d1a17033, #130b0644), var(--chess-walnut);
  background-size: auto, 480px;
  box-shadow: inset 0 1px 1px #e4c5a480, inset 0 0 0 4px #39241766,
    0 3px 0 #2e1d14, 0 7px 10px #0002, 0 22px 32px -16px #0006;
}

.pmx-chess-board {
  display: grid;
  grid-template-columns: repeat(8, minmax(0, 1fr));
  grid-template-rows: repeat(8, minmax(0, 1fr));
  aspect-ratio: 1;
  overflow: hidden;
  border: 1px solid color-mix(in oklch, black 34%, var(--chess-wood-dark));
  border-radius: 2px;
  background: var(--chess-dark);
  box-shadow: 0 0 0 1px #d9b78780, 0 0 0 3px #21130bb3;
}

.pmx-chess-square {
  position: relative;
  display: grid;
  place-items: center;
  min-inline-size: 0;
  min-block-size: 0;
  color: #2f2118;
  background-color: var(--chess-light);
  background-image: var(--chess-light-square-image);
  background-size: auto, 240px 240px;
}

.pmx-chess-square[data-square-color="dark"] {
  color: #f9ecd2;
  background-color: var(--chess-dark);
  background-image: var(--chess-dark-square-image);
}

.pmx-chess-square[data-last-move="true"] {
  box-shadow: inset 0 0 0 999px
    color-mix(in oklch, var(--chess-accent) 22%, transparent);
}

.pmx-chess-square::before,
.pmx-chess-square::after {
  position: absolute;
  z-index: 2;
  font-family: var(--pmx-font-mono);
  font-size: clamp(0.48rem, 1.5vw, 0.66rem);
  line-height: 1;
  opacity: 0.62;
  pointer-events: none;
}

.pmx-chess-square::before {
  content: attr(data-rank-label);
  inset-block-start: 0.25rem;
  inset-inline-start: 0.25rem;
}

.pmx-chess-square::after {
  content: attr(data-file-label);
  inset-block-end: 0.25rem;
  inset-inline-end: 0.25rem;
}

.pmx-chess-piece {
  position: relative;
  z-index: 1;
  inline-size: 92%;
  block-size: 92%;
  object-fit: contain;
  pointer-events: none;
  filter: drop-shadow(
    1px 3px 1.5px #160e0a66
  );
  transform: translateY(-1.5%);
}

.pmx-chess-game-controls {
  display: flex;
  gap: 4px;
  padding: 4px;
  border: 1px solid var(--pmx-color-border);
  border-radius: 12px;
  background: var(--pmx-color-surface);
}

.pmx-chess-game-controls button,
.pmx-chess-game-moves button {
  box-sizing: border-box;
  border: 1px solid transparent;
  border-radius: 8px;
  color: var(--pmx-color-fg);
  background: transparent;
  cursor: pointer;
  font: inherit;
}

.pmx-chess-game-controls button {
  flex: 1;
  min-block-size: 2.75rem;
  padding: 0.35rem 0.5rem;
  font-size: 0.85rem;
  font-weight: 600;
}

.pmx-chess-game-controls button:hover:not(:disabled),
.pmx-chess-game-moves button:hover:not(:disabled) {
  background: color-mix(
    in oklch,
    var(--chess-accent) 14%,
    var(--pmx-color-surface)
  );
}

.pmx-chess-game button:focus-visible {
  outline: 2px solid var(--pmx-color-focus);
  outline-offset: 2px;
}
.pmx-chess-game-controls button:disabled { opacity: 0.35; cursor: default; }

.pmx-chess-game-status {
  display: grid;
  gap: 0.1rem;
  margin: 0;
  color: var(--pmx-color-muted);
}

.pmx-chess-game-status strong {
  color: var(--pmx-color-fg);
}

.pmx-chess-game-moves {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr) minmax(0, 1fr);
  gap: 2px 6px;
  align-items: center;
  max-block-size: 20rem;
  overflow: auto;
  padding: 8px;
  border: 1px solid var(--pmx-color-border);
  border-radius: 12px;
  background: var(--pmx-color-bg);
  scrollbar-width: thin;
  scrollbar-color: var(--pmx-color-border) transparent;
}

.pmx-chess-score-heading {
  position: sticky;
  top: -8px;
  z-index: 1;
  padding: 10px 8px;
  background: var(--pmx-color-bg);
  border-bottom: 1px solid var(--pmx-color-border);
  color: var(--pmx-color-muted);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.04em;
}
.pmx-chess-move-number {
  color: var(--pmx-color-muted);
  font-family: var(--pmx-font-mono);
  font-size: 0.78rem;
  padding-inline: 8px;
  font-variant-numeric: tabular-nums;
}

.pmx-chess-game-moves button {
  min-inline-size: 0;
  min-block-size: 2.5rem;
  padding: 8px;
  text-align: start;
  font-family: var(--pmx-font-mono);
  font-size: 0.88rem;
  border-radius: 6px;
}

.pmx-chess-game-moves .pmx-chess-game-start {
  grid-column: span 2;
  font-family: var(--pmx-font-body);
  font-size: 0.8rem;
}

.pmx-chess-game-moves button[aria-current="step"] {
  border-color: transparent;
  font-weight: 650;
  background: color-mix(
    in oklch,
    var(--chess-accent) 24%,
    var(--pmx-color-surface)
  );
}

.pmx-chess-game-note {
  order: -1;
  min-block-size: 7rem;
  padding: 1rem;
  font-size: 0.92rem;
  line-height: 1.6;
  border: 1px solid var(--pmx-color-border);
  border-radius: 12px;
  background: var(--pmx-color-surface);
}

.pmx-chess-game-note > :first-child {
  margin-block-start: 0;
}

.pmx-chess-game-note > :last-child {
  margin-block-end: 0;
}


.pmx-chess-game[data-chess-mode="play"] > .pmx-chess-game-stage { opacity: 1; }
.pmx-chess-game[data-chess-mode="play"] .pmx-chess-game-controls { grid-template-columns: 1fr 1fr; }
.pmx-chess-game[data-chess-mode="play"] [data-chess-action][hidden] { display: none; }
.pmx-chess-game[data-chess-mode="play"] .pmx-chess-square { cursor: pointer; }
.pmx-chess-game-moves button:disabled { cursor: default; }
.pmx-chess-square[aria-selected="true"] { box-shadow: inset 0 0 0 3px #f2d08a; }
.pmx-chess-square[data-legal="true"] { box-shadow: inset 0 0 0 4px #73a88b; }
.pmx-chess-square:focus-visible { outline: 3px solid #fff; outline-offset: -4px; z-index: 3; }
.pmx-chess-promotion {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}
.pmx-chess-promotion[hidden] { display: none; }
.pmx-chess-promotion button {
  min-height: 44px;
  flex: 1;
  border: 1px solid #ffffff44;
  border-radius: 8px;
  color: #fff;
  background: #163b2e;
  cursor: pointer;
}

@media (max-width: 47.5rem) {
  .pmx-chess-game { margin-block: 1rem; }

  .pmx-chess-game-stage {
    grid-template-columns: 1fr;
    gap: 0.8rem;
  }

  .pmx-chess-board-frame {
    padding: 0.55rem;
    border-radius: 0.55rem;
    box-shadow:
      inset 0 0 0 0.18rem color-mix(in oklch, white 16%, transparent),
      inset 0 0 0.8rem color-mix(in oklch, black 24%, transparent),
      0 0.8rem 1.4rem color-mix(in oklch, black 18%, transparent);
  }

  .pmx-chess-game-controls {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 0.35rem;
  }

  .pmx-chess-game-controls button {
    min-inline-size: 0;
    padding-inline: 0.25rem;
  }

  .pmx-chess-game-status {
    display: flex;
    justify-content: space-between;
    gap: 0.75rem;
    align-items: baseline;
  }

  .pmx-chess-game-status span {
    text-align: end;
  }

  .pmx-chess-game-analysis {
    gap: 0.65rem;
  }

  .pmx-chess-game-note {
    order: -1;
    min-block-size: 0;
  }

  .pmx-chess-game-moves {
    max-block-size: 13rem;
    padding: 0.5rem;
  }

  .pmx-chess-game-moves button {
    min-block-size: 2.75rem;
  }

  html[data-pmx-play-active] .pmx-chess-game-moves {
    display: none;
  }

  html[data-pmx-play-active] .pmx-chess-game {
    padding-block-end: calc(4.5rem + env(safe-area-inset-bottom));
  }
}
```

The browser parses the PGN, renders its positions, and synchronizes the selected
move with its note. Piece movement uses a short animation; reduced-motion
preferences switch positions immediately.

The board creates its 64 HTML square elements once, then updates the pieces,
labels, and highlights inside them as the position changes. Images of unchanged
pieces stay in place, and the commentary is replaced only when a different note
applies. Keeping these elements lets the browser preserve keyboard focus and
avoids rebuilding the board after every move or selection.

```js
const PIECES = {
  wp: ["wP.svg", "White pawn"],
  wn: ["wN.svg", "White knight"],
  wb: ["wB.svg", "White bishop"],
  wr: ["wR.svg", "White rook"],
  wq: ["wQ.svg", "White queen"],
  wk: ["wK.svg", "White king"],
  bp: ["bP.svg", "Black pawn"],
  bn: ["bN.svg", "Black knight"],
  bb: ["bB.svg", "Black bishop"],
  br: ["bR.svg", "Black rook"],
  bq: ["bQ.svg", "Black queen"],
  bk: ["bK.svg", "Black king"],
}

const board = $("[data-chess-board]")
const stage = $("[data-chess-stage]")
const movesList = $("[data-chess-moves]")
const status = $("[data-chess-status]")
const plyLabel = $("[data-chess-ply-label]")
const notePanel = $("[data-chess-note-panel]")
const content = $("[data-chess-content]")
const gameId = (el.dataset.chessId || "").trim()

function fail(message) {
  if (status) status.textContent = message
  el.dataset.chessError = "true"
}

if (!gameId) {
  fail("Chess game requires an id for stable Player Beats.")
} else {
  const orientation = (attrs.orientation || "white").toLowerCase()
  const piecesBase = attrs.pieces || "./assets/pieces/cburnett"
  const chessModule = attrs["chess-module"] || "./assets/chess-1.4.0.js"
  const playable = attrs.mode === "play"
  if (playable) {
    el.dataset.chessMode = "play"
    el.dataset.pmxBeat = gameId
    board.setAttribute("aria-label", "Playable chess board")
  }
  const initialPly = Number.parseInt(attrs["initial-ply"] || "0", 10) || 0
  const stageId = stage.id || gameId + "-stage"
  const playTarget = "#" + CSS.escape(stageId)
  stage.id = stageId

  function squaresForBoard() {
    const files =
      orientation === "black"
        ? ["h", "g", "f", "e", "d", "c", "b", "a"]
        : ["a", "b", "c", "d", "e", "f", "g", "h"]
    const ranks =
      orientation === "black"
        ? ["1", "2", "3", "4", "5", "6", "7", "8"]
        : ["8", "7", "6", "5", "4", "3", "2", "1"]
    return ranks.flatMap(function (rank, row) {
      return files.map(function (file, column) {
        return { square: file + rank, file, rank, row, column }
      })
    })
  }

  function pieceKey(piece) {
    return piece.color + piece.type
  }

  function squareColor(square) {
    const file = square.charCodeAt(0) - 97
    const rank = Number(square.slice(1)) - 1
    return (file + rank) % 2 === 0 ? "dark" : "light"
  }

  function pieceUrl(key) {
    const filename = PIECES[key][0]
    const base = piecesBase.endsWith("/") ? piecesBase : piecesBase + "/"
    return new URL(base + filename, location.href).href
  }

  function moveLabel(ply, moves) {
    if (ply === 0) return "Starting position"
    const move = moves[ply - 1]
    const number = move.before.split(" ")[5]
    return (move.color === "w" ? number + ". " : number + "... ") + move.san
  }

  function payloadText(element) {
    if (!element) return ""
    if (element.content && typeof element.content.textContent === "string") {
      return element.content.textContent
    }
    return element.textContent || ""
  }

  import(new URL(chessModule, location.href).href)
    .then(async function (module) {
      if (!el.isConnected) return
      const Chess = module.Chess
      const pgnElement = content && content.querySelector("[data-chess-pgn]")
      const parser = new Chess()
      try {
        if (playable && attrs.fen) parser.load(attrs.fen)
        else if (!playable) parser.loadPgn(payloadText(pgnElement).trim(), { strict: false })
      } catch (error) {
        fail((playable ? "Could not load position: " : "Could not parse PGN: ") + error.message)
        return
      }

      let moves = parser.history({ verbose: true })
      const startFen = moves[0] ? moves[0].before : parser.fen()
      let positions = [{ fen: startFen }].concat(
        moves.map(function (move) {
          return { fen: move.after, move }
        }),
      )
      const notes = Array.from(
        content ? content.querySelectorAll("[data-chess-note-ply]") : [],
      )
        .map(function (note) {
          return {
            ply: Number.parseInt(note.dataset.chessNotePly || "0", 10) || 0,
            content: note.cloneNode(true),
          }
        })
        .sort(function (left, right) {
          return left.ply - right.ply
        })
      const squares = squaresForBoard()
      let ply = Math.max(0, Math.min(moves.length, initialPly))
      let pieceAnimation = null
      let input = null
      let renderedNote = null

      function settlePieceAnimation() {
        if (!pieceAnimation) return
        const animation = pieceAnimation
        pieceAnimation = null
        try {
          animation.finish()
        } catch {
          animation.cancel()
        }
        delete el.dataset.chessAnimating
      }

      cleanup(function () {
        if (pieceAnimation) pieceAnimation.cancel()
      })

      function preparePieceAnimation(nextPly) {
        if (
          Math.abs(nextPly - ply) !== 1 ||
          matchMedia("(prefers-reduced-motion: reduce)").matches
        ) {
          return null
        }
        const move = nextPly > ply ? moves[nextPly - 1] : moves[ply - 1]
        if (!move) return null
        const from = nextPly > ply ? move.from : move.to
        const to = nextPly > ply ? move.to : move.from
        const piece = board.querySelector(
          '[data-square="' + from + '"] .pmx-chess-piece',
        )
        if (!piece) return null
        return { from: piece.getBoundingClientRect(), to }
      }

      function startPieceAnimation(prepared) {
        if (!prepared || typeof Element.prototype.animate !== "function") return
        const piece = board.querySelector(
          '[data-square="' + prepared.to + '"] .pmx-chess-piece',
        )
        if (!piece) return
        const target = piece.getBoundingClientRect()
        const x = prepared.from.left - target.left
        const y = prepared.from.top - target.top
        if (Math.abs(x) < 0.5 && Math.abs(y) < 0.5) return
        el.dataset.chessAnimating = "true"
        const animation = piece.animate(
          [
            {
              transform:
                "translate(" + x + "px, " + y + "px) translateY(-1.5%)",
            },
            { transform: "translate(0, 0) translateY(-1.5%)" },
          ],
          {
            duration: 360,
            easing: "cubic-bezier(0.4, 0, 0.2, 1)",
          },
        )
        pieceAnimation = animation
        const finish = function () {
          if (pieceAnimation === animation) pieceAnimation = null
          delete el.dataset.chessAnimating
        }
        animation.onfinish = finish
        animation.oncancel = finish
      }

      function renderBoard() {
        const position = positions[ply]
        const chess = new Chess(position.fen)
        const lastMove = ply > 0 ? moves[ply - 1] : null
        squares.forEach(function (entry, index) {
          const piece = chess.get(entry.square)
          const square = board.children[index] || document.createElement("div")
          square.className = "pmx-chess-square"
          square.dataset.square = entry.square
          square.dataset.squareColor = squareColor(entry.square)
          square.setAttribute("role", "gridcell")
          square.setAttribute("aria-rowindex", String(entry.row + 1))
          square.setAttribute("aria-colindex", String(entry.column + 1))
          if (entry.column === 0) square.dataset.rankLabel = entry.rank
          if (entry.row === 7) square.dataset.fileLabel = entry.file
          if (
            lastMove &&
            (lastMove.from === entry.square || lastMove.to === entry.square)
          ) {
            square.dataset.lastMove = "true"
          } else {
            delete square.dataset.lastMove
          }
          const currentImage = square.querySelector("img")
          if (piece) {
            const key = pieceKey(piece)
            const image = currentImage || document.createElement("img")
            image.className = "pmx-chess-piece"
            image.dataset.chessPieceImage = key
            const src = pieceUrl(key)
            if (image.src !== src) image.src = src
            image.alt = PIECES[key][1]
            image.draggable = false
            if (!currentImage) square.append(image)
            square.setAttribute(
              "aria-label",
              entry.square + ", " + PIECES[key][1],
            )
          } else {
            currentImage?.remove()
            square.setAttribute("aria-label", entry.square + ", empty")
          }
          if (!square.parentNode) board.append(square)
        })
        input?.paint()
      }

      function createMoveButton(movePly, label) {
        const button = document.createElement("button")
        button.type = "button"
        button.textContent = label
        button.dataset.chessGameMove = ""
        button.dataset.chessPly = String(movePly)
        if (!playable) button.dataset.pmxBeat = gameId + ":ply-" + movePly
        button.dataset.pmxBeatLabel = label
        button.setAttribute("aria-label", moveLabel(movePly, moves))
        button.disabled = playable
        button.dataset.pmxPlayTarget = playTarget
        if (movePly === 0) button.className = "pmx-chess-game-start"
        return button
      }

      function renderMoves() {
        const fragment = document.createDocumentFragment()
        for (const label of ["№", "White", "Black"]) {
          const heading = document.createElement("span")
          heading.className = "pmx-chess-score-heading"
          heading.textContent = label
          heading.setAttribute("aria-hidden", "true")
          fragment.append(heading)
        }
        const zero = document.createElement("span")
        zero.className = "pmx-chess-move-number"
        zero.textContent = "0."
        fragment.append(zero, createMoveButton(0, "Starting position"))

        moves.forEach(function (move, index) {
          const movePly = index + 1
          if (move.color === "w" || index === 0) {
            const number = document.createElement("span")
            number.className = "pmx-chess-move-number"
            number.textContent = move.before.split(" ")[5] + "."
            fragment.append(number)
            if (move.color === "b") fragment.append(document.createElement("span"))
          }
          fragment.append(createMoveButton(movePly, move.san))
        })
        movesList.replaceChildren(fragment)
      }

      function renderNote() {
        const note = notes
          .filter(function (candidate) {
            return candidate.ply <= ply
          })
          .pop()
        if (note === renderedNote) return
        renderedNote = note
        notePanel.hidden = !note
        notePanel.replaceChildren(...(note ? [note.content.cloneNode(true)] : []))
      }

      function revealCurrentMove(button) {
        const listRect = movesList.getBoundingClientRect()
        const buttonRect = button.getBoundingClientRect()
        const top = buttonRect.top - listRect.top + movesList.scrollTop
        const bottom = top + buttonRect.height
        if (top < movesList.scrollTop) movesList.scrollTop = top
        else if (bottom > movesList.scrollTop + movesList.clientHeight) {
          movesList.scrollTop = bottom - movesList.clientHeight
        }
      }

      function renderState() {
        renderBoard()
        renderNote()
        const label = moveLabel(ply, moves)
        if (plyLabel) plyLabel.textContent = label
        if (status) {
          const currentPosition = playable ? parser : new Chess(positions[ply].fen)
          const side = currentPosition.turn() === "w" ? "White" : "Black"
          status.textContent = currentPosition.isCheckmate()
            ? (currentPosition.turn() === "w" ? "Black" : "White") + " wins · Checkmate"
            : currentPosition.isStalemate() ? "Draw · Stalemate"
            : currentPosition.isDraw() ? "Draw"
            : side + " to move" + (currentPosition.isCheck() ? " · Check" : "")
        }
        const current = movesList.querySelector(
          '[data-chess-ply="' + ply + '"]',
        )
        Array.from(
          movesList.querySelectorAll("[data-chess-game-move]"),
        ).forEach(function (button) {
          button.setAttribute("aria-current", button === current ? "step" : "false")
        })
        if (current) revealCurrentMove(current)
        el.querySelectorAll("[data-chess-action]").forEach(function (button) {
          const first = ["first", "prev"].includes(button.dataset.chessAction)
          button.disabled = playable ? moves.length === 0 : first ? ply === 0 : ply === moves.length
        })
      }

      function applyPly(value) {
        const nextPly = Math.max(0, Math.min(moves.length, value))
        if (nextPly === ply) return
        settlePieceAnimation()
        const prepared = preparePieceAnimation(nextPly)
        ply = nextPly
        renderState()
        startPieceAnimation(prepared)
      }

      function requestPly(value) {
        const target = Math.max(0, Math.min(moves.length, value))
        const button = movesList.querySelector(
          '[data-chess-ply="' + target + '"]',
        )
        if (button) button.click()
      }

      function refreshPlay(history = true) {
        if (history) {
          moves = parser.history({ verbose: true })
          positions = [{ fen: startFen }, ...moves.map(move => ({ fen: move.after, move }))]
          ply = moves.length
          renderMoves()
        }
        renderState()
      }

      if (playable) {
        const { enablePlay } = await import(new URL("./assets/chess-play.js", location.href).href)
        if (!el.isConnected) return
        input = enablePlay({ board, chess: parser, on, cleanup, changed: refreshPlay })
        const controls = el.querySelector(".pmx-chess-game-controls")
        controls.querySelector('[data-chess-action="first"]').textContent = "Reset"
        controls.querySelector('[data-chess-action="first"]').setAttribute("aria-label", "Reset board")
        controls.querySelector('[data-chess-action="prev"]').textContent = "Undo"
        controls.querySelector('[data-chess-action="prev"]').setAttribute("aria-label", "Undo move")
        controls.querySelector('[data-chess-action="next"]').hidden = true
        controls.querySelector('[data-chess-action="last"]').hidden = true
      }
      renderMoves()
      renderState()

      on(el, "click", function (event) {
        const action = event.target.closest("[data-chess-action]")
        if (action && el.contains(action)) {
          if (playable) {
            if (action.dataset.chessAction === "first") parser.load(startFen)
            else if (action.dataset.chessAction === "prev") parser.undo()
            input.clear()
            refreshPlay()
            return
          }
          if (action.dataset.chessAction === "first") requestPly(0)
          if (action.dataset.chessAction === "prev") requestPly(ply - 1)
          if (action.dataset.chessAction === "next") requestPly(ply + 1)
          if (action.dataset.chessAction === "last") requestPly(moves.length)
          return
        }
        const move = event.target.closest("[data-chess-game-move]")
        if (!move || !movesList.contains(move)) return
        if (!playable) applyPly(Number.parseInt(move.dataset.chessPly || "0", 10) || 0)
      })

      on(el, "player:beat-enter", function (event) {
        const beat = event.target
        if (!beat.matches || !beat.matches("[data-chess-game-move]")) return
        applyPly(Number.parseInt(beat.dataset.chessPly || "0", 10) || 0)
      })

      el.dataset.chessReady = "true"
      document.dispatchEvent(new Event("pmx:beats"))
    })
    .catch(function (error) {
      fail("Could not load chess.js: " + error.message)
    })
}
```

In replay mode, each position is a Player Beat. Move buttons use
`data-pmx-beat` and share a `data-pmx-play-target` so the board and note stay
together in Focus and Slides. Play mode uses one Beat for the whole game.

The component owns `aria-current` on every move button: `step` for the current
position and `false` for the others. Player preserves these authored values,
so the current position remains accessible when playback stops.

The `pieces` and optional `chess-module` URLs resolve relative to the page
using the component. The bundled rules library is `chess.js@1.4.0`.


### Example: replay an opening

Put the PGN inside `<template data-chess-pgn>`. Give each game a unique `id`
for its Player links.

```md
<chess-game id="opening-example" pieces="./assets/pieces/wood">
  <template data-chess-pgn>
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *
  </template>
</chess-game>
```

<chess-game id="opening-example" pieces="./assets/pieces/wood">
  <template data-chess-pgn>
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *
  </template>
</chess-game>


### Example: play a game

Select a piece, then a highlighted square to move. On a keyboard, Tab to the
board and use arrow keys to explore; Enter or Space selects, and Escape cancels.
When a pawn reaches the last rank, choose its promotion piece.

**Undo** takes back one move. **Reset** or a page refresh starts over.
The optional `fen` attribute accepts a FEN (Forsyth–Edwards Notation) starting
position for a puzzle or endgame.

The [play input module](./assets/chess-play.js) handles selection and keyboard
input; chess.js validates moves and tracks the position history.

```md
<chess-game id="practice-example" mode="play" pieces="./assets/pieces/wood" />
```

<chess-game id="practice-example" mode="play" pieces="./assets/pieces/wood" />

---

## A complete annotated game

Import the components, add a PGN score, and place `chess-note` elements at
the positions you want to explain. Here is the complete Opera Game.

This file imports itself as `@preview` to render its own examples. On another
page, use an import like `@chess` below.

```md
[@chess]: ./chess.components.md

<chess-game
id="opera-study"
orientation="white"
pieces="./assets/pieces/wood"
>

  <template data-chess-pgn>
[Event "A Night at the Opera"]
[Site "Paris FRA"]
[Date "1858.??.??"]
[Round "?"]
[White "Paul Morphy"]
[Black "Duke Karl / Count Isouard"]
[Result "1-0"]
1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5
6. Bc4 Nf6 7. Qb3 Qe7 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5
11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7 14. Rd1 Qe6
15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0
  </template>

  <chess-note ply="0" title="Bring the pieces into play">
    Watch White's pieces enter the game while Black's king stays in the center.
    Morphy turns that lead in development into a direct attack.
  </chess-note>

  <chess-note ply="7" title="4. dxe5">
    White captures on e5, clearing the d-file of White's pawn. After Black
    exchanges the bishop for the knight on f3, White recaptures with the queen.
  </chess-note>

  <chess-note ply="13" title="7. Qb3">
    The queen attacks b7 and f7. Black replies with `Qe7`, placing the queen
    in front of the bishop on f8.
  </chess-note>

  <chess-note ply="19" title="10. Nxb5">
    Morphy sacrifices a knight to open a line toward the king. After `cxb5`,
    the bishop takes on b5 with check, forcing Black to respond.
  </chess-note>

  <chess-note ply="23" title="12. O-O-O">
    Castling brings the king to safety and the rook to d1. The rook now
    attacks down the open d-file.
  </chess-note>

  <chess-note ply="29" title="15. Bxd7+">
    The bishop captures the rook with check. After `Nxd7`, Morphy offers
    his queen on b8 to draw the knight away from d8.
  </chess-note>

  <chess-note ply="33" title="17. Rd8#">
    The rook delivers checkmate on d8, protected by the bishop on g5.
    Those two pieces finish the attack after the queen sacrifice.
  </chess-note>
</chess-game>
```

### Explore the game

[@link.play Follow the game](#opera-study:ply-0)

<chess-game
id="opera-study"
orientation="white"
pieces="./assets/pieces/wood"
>

  <template data-chess-pgn>
[Event "A Night at the Opera"]
[Site "Paris FRA"]
[Date "1858.??.??"]
[Round "?"]
[White "Paul Morphy"]
[Black "Duke Karl / Count Isouard"]
[Result "1-0"]
1. e4 e5 2. Nf3 d6 3. d4 Bg4 4. dxe5 Bxf3 5. Qxf3 dxe5
6. Bc4 Nf6 7. Qb3 Qe7 8. Nc3 c6 9. Bg5 b5 10. Nxb5 cxb5
11. Bxb5+ Nbd7 12. O-O-O Rd8 13. Rxd7 Rxd7 14. Rd1 Qe6
15. Bxd7+ Nxd7 16. Qb8+ Nxb8 17. Rd8# 1-0
  </template>

  <chess-note ply="0" title="Bring the pieces into play">
    Watch White's pieces enter the game while Black's king stays in the center.
    Morphy turns that lead in development into a direct attack.
  </chess-note>

  <chess-note ply="7" title="4. dxe5">
    White captures on e5, clearing the d-file of White's pawn. After Black
    exchanges the bishop for the knight on f3, White recaptures with the queen.
  </chess-note>

  <chess-note ply="13" title="7. Qb3">
    The queen attacks b7 and f7. Black replies with `Qe7`, placing the queen
    in front of the bishop on f8.
  </chess-note>

  <chess-note ply="19" title="10. Nxb5">
    Morphy sacrifices a knight to open a line toward the king. After `cxb5`,
    the bishop takes on b5 with check, forcing Black to respond.
  </chess-note>

  <chess-note ply="23" title="12. O-O-O">
    Castling brings the king to safety and the rook to d1. The rook now
    attacks down the open d-file.
  </chess-note>

  <chess-note ply="29" title="15. Bxd7+">
    The bishop captures the rook with check. After `Nxd7`, Morphy offers
    his queen on b8 to draw the knight away from d8.
  </chess-note>

  <chess-note ply="33" title="17. Rd8#">
    The rook delivers checkmate on d8, protected by the bishop on g5.
    Those two pieces finish the attack after the queen sacrifice.
  </chess-note>
</chess-game>
