Accessibility - RumenDamyanov/js-chess GitHub Wiki

Accessibility

Comprehensive guide to making the chess showcase accessible to all users, including those with disabilities.

Overview

This guide covers accessibility (a11y) implementation across all framework versions, ensuring:

  • Screen reader compatibility
  • Keyboard navigation
  • ARIA labels and landmarks
  • Color contrast compliance
  • Focus management
  • Cognitive accessibility
  • Motor accessibility

Core Accessibility Principles

WCAG 2.1 Compliance

The chess showcase follows WCAG 2.1 AA guidelines:

<!-- Semantic HTML structure -->
<main role="main" aria-label="Chess Game">
  <section aria-label="Chess Board">
    <div class="chess-board"
         role="grid"
         aria-label="8x8 chess board"
         tabindex="0">
      <!-- Board squares -->
    </div>
  </section>

  <aside aria-label="Game Information">
    <section aria-label="Move History">
      <!-- Move list -->
    </section>
  </aside>
</main>

Screen Reader Support

Provide comprehensive audio descriptions for chess positions:

<!-- Chess square with piece -->
<div class="chess-square"
     role="gridcell"
     aria-label="e4, white pawn"
     data-square="e4"
     tabindex="-1">
  <span class="chess-piece" aria-hidden="true"></span>
</div>

<!-- Empty square -->
<div class="chess-square"
     role="gridcell"
     aria-label="e5, empty square"
     data-square="e5"
     tabindex="-1">
</div>

Keyboard Navigation

Full keyboard support for all interactions:

/* Keyboard focus indicators */
.chess-square:focus {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
  z-index: 10;
}

.chess-piece:focus {
  outline: 2px solid var(--color-focus);
  outline-offset: 1px;
}

/* High contrast focus for accessibility */
@media (prefers-contrast: high) {
  .chess-square:focus {
    outline: 4px solid #000000;
    background-color: #ffff00;
  }
}

Screen Reader Implementation

Board State Announcements

// shared/accessibility/ScreenReaderAnnouncer.js
export class ScreenReaderAnnouncer {
  constructor() {
    this.announcer = this.createAnnouncer();
    this.gameState = null;
    this.lastMove = null;
  }

  createAnnouncer() {
    const announcer = document.createElement('div');
    announcer.setAttribute('aria-live', 'polite');
    announcer.setAttribute('aria-atomic', 'true');
    announcer.className = 'sr-only';
    announcer.style.cssText = `
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    `;
    document.body.appendChild(announcer);
    return announcer;
  }

  announceMove(move) {
    const announcement = this.formatMoveAnnouncement(move);
    this.announce(announcement);
    this.lastMove = move;
  }

  announceBoardState(gameState) {
    if (gameState.inCheck) {
      this.announce(`${gameState.currentPlayer} king is in check`);
    }

    if (gameState.gameStatus === 'checkmate') {
      this.announce(`Checkmate! ${gameState.winner} wins the game`);
    }

    if (gameState.gameStatus === 'stalemate') {
      this.announce('Stalemate! The game is a draw');
    }

    this.gameState = gameState;
  }

  announcePositionSummary() {
    if (!this.gameState) return;

    const summary = this.generatePositionSummary(this.gameState.board);
    this.announce(`Board position: ${summary}`);
  }

  formatMoveAnnouncement(move) {
    let announcement = '';

    // Piece type
    const pieceNames = {
      'P': 'pawn', 'R': 'rook', 'N': 'knight',
      'B': 'bishop', 'Q': 'queen', 'K': 'king'
    };

    const pieceName = pieceNames[move.piece.toUpperCase()] || 'piece';
    const color = move.piece === move.piece.toUpperCase() ? 'white' : 'black';

    // Special moves
    if (move.castling) {
      announcement = `${color} castles ${move.castling}`;
    } else {
      announcement = `${color} ${pieceName} moves from ${move.from} to ${move.to}`;

      if (move.captured) {
        const capturedPiece = pieceNames[move.captured.toUpperCase()] || 'piece';
        announcement += `, capturing ${capturedPiece}`;
      }

      if (move.promotion) {
        const promotedPiece = pieceNames[move.promotion] || 'piece';
        announcement += `, promoting to ${promotedPiece}`;
      }

      if (move.enPassant) {
        announcement += ', en passant capture';
      }
    }

    return announcement;
  }

  generatePositionSummary(board) {
    const pieces = { white: [], black: [] };

    for (let row = 0; row < 8; row++) {
      for (let col = 0; col < 8; col++) {
        const piece = board[row][col];
        if (piece) {
          const color = piece === piece.toUpperCase() ? 'white' : 'black';
          const type = piece.toUpperCase();
          const square = String.fromCharCode(97 + col) + (8 - row);
          pieces[color].push(`${type} on ${square}`);
        }
      }
    }

    let summary = '';
    if (pieces.white.length > 0) {
      summary += `White pieces: ${pieces.white.join(', ')}. `;
    }
    if (pieces.black.length > 0) {
      summary += `Black pieces: ${pieces.black.join(', ')}.`;
    }

    return summary;
  }

  announce(message) {
    this.announcer.textContent = message;

    // Clear after announcement is read
    setTimeout(() => {
      this.announcer.textContent = '';
    }, 1000);
  }

  announceGameStart() {
    this.announce('New chess game started. White to move. Use arrow keys to navigate the board.');
  }

  announceSquareContents(square, piece) {
    let announcement = `Square ${square}`;

    if (piece) {
      const pieceNames = {
        'P': 'pawn', 'R': 'rook', 'N': 'knight',
        'B': 'bishop', 'Q': 'queen', 'K': 'king'
      };
      const pieceName = pieceNames[piece.toUpperCase()] || 'piece';
      const color = piece === piece.toUpperCase() ? 'white' : 'black';
      announcement += `, ${color} ${pieceName}`;
    } else {
      announcement += ', empty';
    }

    this.announce(announcement);
  }
}

Keyboard Navigation Manager

// shared/accessibility/KeyboardNavigationManager.js
export class KeyboardNavigationManager {
  constructor(boardElement, gameState, onSquareSelect, onMove) {
    this.board = boardElement;
    this.gameState = gameState;
    this.onSquareSelect = onSquareSelect;
    this.onMove = onMove;
    this.currentSquare = null;
    this.selectedSquare = null;
    this.focusedSquare = 'e1'; // Start at white king position

    this.setupKeyboardListeners();
    this.updateSquareTabIndexes();
  }

  setupKeyboardListeners() {
    this.board.addEventListener('keydown', this.handleKeyDown.bind(this));
    this.board.addEventListener('focus', this.handleFocus.bind(this));
  }

  handleKeyDown(event) {
    switch (event.key) {
      case 'ArrowUp':
        event.preventDefault();
        this.moveUp();
        break;
      case 'ArrowDown':
        event.preventDefault();
        this.moveDown();
        break;
      case 'ArrowLeft':
        event.preventDefault();
        this.moveLeft();
        break;
      case 'ArrowRight':
        event.preventDefault();
        this.moveRight();
        break;
      case 'Enter':
      case ' ':
        event.preventDefault();
        this.selectCurrentSquare();
        break;
      case 'Escape':
        event.preventDefault();
        this.clearSelection();
        break;
      case 'Home':
        event.preventDefault();
        this.goToSquare('a8');
        break;
      case 'End':
        event.preventDefault();
        this.goToSquare('h1');
        break;
      case 'Tab':
        if (!event.shiftKey) {
          this.handleTabNavigation(event);
        }
        break;
    }
  }

  moveUp() {
    const [file, rank] = this.parseSquare(this.focusedSquare);
    if (rank < 8) {
      this.goToSquare(file + (rank + 1));
    }
  }

  moveDown() {
    const [file, rank] = this.parseSquare(this.focusedSquare);
    if (rank > 1) {
      this.goToSquare(file + (rank - 1));
    }
  }

  moveLeft() {
    const [file, rank] = this.parseSquare(this.focusedSquare);
    const fileIndex = file.charCodeAt(0) - 97;
    if (fileIndex > 0) {
      const newFile = String.fromCharCode(96 + fileIndex);
      this.goToSquare(newFile + rank);
    }
  }

  moveRight() {
    const [file, rank] = this.parseSquare(this.focusedSquare);
    const fileIndex = file.charCodeAt(0) - 97;
    if (fileIndex < 7) {
      const newFile = String.fromCharCode(98 + fileIndex);
      this.goToSquare(newFile + rank);
    }
  }

  goToSquare(square) {
    this.focusedSquare = square;
    const squareElement = this.getSquareElement(square);

    if (squareElement) {
      this.updateSquareTabIndexes();
      squareElement.focus();
      this.announceSquareContents(square);
    }
  }

  selectCurrentSquare() {
    if (this.selectedSquare === this.focusedSquare) {
      // Deselect if clicking same square
      this.clearSelection();
    } else if (this.selectedSquare) {
      // Make move
      this.onMove(this.selectedSquare, this.focusedSquare);
      this.clearSelection();
    } else {
      // Select square
      this.selectedSquare = this.focusedSquare;
      this.onSquareSelect(this.focusedSquare);
      this.updateSquareStates();
    }
  }

  clearSelection() {
    this.selectedSquare = null;
    this.onSquareSelect(null);
    this.updateSquareStates();
  }

  updateSquareTabIndexes() {
    const squares = this.board.querySelectorAll('.chess-square');
    squares.forEach(square => {
      const squareId = square.dataset.square;
      square.setAttribute('tabindex', squareId === this.focusedSquare ? '0' : '-1');
    });
  }

  updateSquareStates() {
    const squares = this.board.querySelectorAll('.chess-square');
    squares.forEach(square => {
      const squareId = square.dataset.square;

      if (squareId === this.selectedSquare) {
        square.setAttribute('aria-selected', 'true');
        square.classList.add('selected');
      } else {
        square.setAttribute('aria-selected', 'false');
        square.classList.remove('selected');
      }
    });
  }

  handleFocus() {
    // Announce board state when board gains focus
    this.announceSquareContents(this.focusedSquare);
  }

  handleTabNavigation(event) {
    // Custom tab navigation within the chess board
    const squares = this.board.querySelectorAll('.chess-square');
    const currentIndex = Array.from(squares).findIndex(
      square => square.dataset.square === this.focusedSquare
    );

    if (currentIndex < squares.length - 1) {
      event.preventDefault();
      const nextSquare = squares[currentIndex + 1];
      this.goToSquare(nextSquare.dataset.square);
    }
  }

  parseSquare(square) {
    return [square[0], parseInt(square[1])];
  }

  getSquareElement(square) {
    return this.board.querySelector(`[data-square="${square}"]`);
  }

  announceSquareContents(square) {
    const piece = this.gameState.board[this.getSquareCoords(square).row][this.getSquareCoords(square).col];

    // This would use the ScreenReaderAnnouncer
    if (window.screenReaderAnnouncer) {
      window.screenReaderAnnouncer.announceSquareContents(square, piece);
    }
  }

  getSquareCoords(square) {
    const file = square.charCodeAt(0) - 97;
    const rank = parseInt(square[1]) - 1;
    return { row: 8 - parseInt(square[1]), col: file };
  }
}

ARIA Implementation

Dynamic ARIA Labels

// shared/accessibility/ARIAManager.js
export class ARIAManager {
  constructor() {
    this.setupStaticARIA();
  }

  setupStaticARIA() {
    // Main landmarks
    const main = document.querySelector('main');
    if (main) {
      main.setAttribute('role', 'main');
      main.setAttribute('aria-label', 'Chess Game Application');
    }

    // Chess board
    const board = document.querySelector('.chess-board');
    if (board) {
      board.setAttribute('role', 'grid');
      board.setAttribute('aria-label', '8 by 8 chess board');
      board.setAttribute('aria-rowcount', '8');
      board.setAttribute('aria-colcount', '8');
    }

    // Game controls
    const controls = document.querySelector('.game-controls');
    if (controls) {
      controls.setAttribute('role', 'toolbar');
      controls.setAttribute('aria-label', 'Game controls');
    }
  }

  updateSquareARIA(square, piece, row, col, isSelected, isValidMove, isInCheck) {
    square.setAttribute('role', 'gridcell');
    square.setAttribute('aria-rowindex', 8 - row + 1);
    square.setAttribute('aria-colindex', col + 1);

    // Build descriptive label
    const file = String.fromCharCode(97 + col);
    const rank = 8 - row;
    const squareId = file + rank;

    let label = `${squareId}`;

    if (piece) {
      const pieceNames = {
        'P': 'pawn', 'R': 'rook', 'N': 'knight',
        'B': 'bishop', 'Q': 'queen', 'K': 'king'
      };
      const pieceName = pieceNames[piece.toUpperCase()] || 'piece';
      const color = piece === piece.toUpperCase() ? 'white' : 'black';
      label += `, ${color} ${pieceName}`;
    } else {
      label += ', empty square';
    }

    if (isSelected) {
      label += ', selected';
    }

    if (isValidMove) {
      label += ', valid move';
    }

    if (isInCheck) {
      label += ', in check';
    }

    square.setAttribute('aria-label', label);
    square.setAttribute('aria-selected', isSelected ? 'true' : 'false');

    // Additional states
    if (isValidMove) {
      square.setAttribute('aria-describedby', 'valid-move-description');
    }

    if (isInCheck) {
      square.setAttribute('aria-live', 'assertive');
    }
  }

  updateGameStatus(status, currentPlayer, winner) {
    const statusElement = document.getElementById('game-status') || this.createStatusElement();

    let statusText = '';

    switch (status) {
      case 'active':
        statusText = `${currentPlayer}'s turn to move`;
        break;
      case 'check':
        statusText = `${currentPlayer} is in check`;
        break;
      case 'checkmate':
        statusText = `Checkmate! ${winner} wins`;
        break;
      case 'stalemate':
        statusText = 'Stalemate - game is a draw';
        break;
      case 'draw':
        statusText = 'Game ended in a draw';
        break;
    }

    statusElement.textContent = statusText;
    statusElement.setAttribute('aria-live', status === 'check' ? 'assertive' : 'polite');
  }

  createStatusElement() {
    const status = document.createElement('div');
    status.id = 'game-status';
    status.className = 'sr-only';
    status.setAttribute('aria-live', 'polite');
    status.setAttribute('aria-atomic', 'true');
    document.body.appendChild(status);
    return status;
  }

  updateMoveHistory(moves, currentMoveIndex) {
    const historyContainer = document.querySelector('.move-history');
    if (!historyContainer) return;

    historyContainer.setAttribute('role', 'log');
    historyContainer.setAttribute('aria-label', 'Move history');
    historyContainer.setAttribute('aria-live', 'polite');

    const moveButtons = historyContainer.querySelectorAll('.move-button');
    moveButtons.forEach((button, index) => {
      const isCurrentMove = index === currentMoveIndex;
      button.setAttribute('aria-current', isCurrentMove ? 'true' : 'false');

      if (isCurrentMove) {
        button.setAttribute('aria-label', `Current move: ${button.textContent}`);
      }
    });
  }

  announceCapture(capturedPiece, captureSquare) {
    const announcement = document.createElement('div');
    announcement.className = 'sr-only';
    announcement.setAttribute('aria-live', 'assertive');
    announcement.textContent = `${capturedPiece} captured on ${captureSquare}`;
    document.body.appendChild(announcement);

    setTimeout(() => {
      document.body.removeChild(announcement);
    }, 2000);
  }
}

Framework-Specific Implementation

Angular Accessibility Directive

// angular-chess/src/app/directives/accessibility.directive.ts
import { Directive, ElementRef, Input, OnInit, OnDestroy } from '@angular/core';
import { KeyboardNavigationManager } from '../../../shared/accessibility/KeyboardNavigationManager';
import { ARIAManager } from '../../../shared/accessibility/ARIAManager';

@Directive({
  selector: '[appAccessibility]'
})
export class AccessibilityDirective implements OnInit, OnDestroy {
  @Input() gameState: any;
  @Input() onSquareSelect: (square: string) => void = () => {};
  @Input() onMove: (from: string, to: string) => void = () => {};

  private keyboardManager?: KeyboardNavigationManager;
  private ariaManager: ARIAManager;

  constructor(private el: ElementRef) {
    this.ariaManager = new ARIAManager();
  }

  ngOnInit() {
    if (this.el.nativeElement.classList.contains('chess-board')) {
      this.keyboardManager = new KeyboardNavigationManager(
        this.el.nativeElement,
        this.gameState,
        this.onSquareSelect,
        this.onMove
      );
    }

    this.setupAccessibility();
  }

  ngOnDestroy() {
    // Cleanup if needed
  }

  private setupAccessibility() {
    const element = this.el.nativeElement;

    // Add skip link for keyboard users
    this.addSkipLink();

    // Ensure proper heading structure
    this.enforceHeadingStructure();

    // Add high contrast mode detection
    this.setupHighContrastMode();
  }

  private addSkipLink() {
    const skipLink = document.createElement('a');
    skipLink.href = '#chess-board';
    skipLink.textContent = 'Skip to chess board';
    skipLink.className = 'skip-link';
    skipLink.style.cssText = `
      position: absolute;
      top: -40px;
      left: 6px;
      background: #000;
      color: #fff;
      padding: 8px;
      text-decoration: none;
      z-index: 1000;
    `;

    skipLink.addEventListener('focus', () => {
      skipLink.style.top = '6px';
    });

    skipLink.addEventListener('blur', () => {
      skipLink.style.top = '-40px';
    });

    document.body.insertBefore(skipLink, document.body.firstChild);
  }

  private enforceHeadingStructure() {
    // Ensure proper heading hierarchy
    const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
    let expectedLevel = 1;

    headings.forEach(heading => {
      const currentLevel = parseInt(heading.tagName[1]);
      if (currentLevel > expectedLevel + 1) {
        console.warn(`Heading level ${currentLevel} skips level ${expectedLevel + 1}`);
      }
      expectedLevel = currentLevel;
    });
  }

  private setupHighContrastMode() {
    const supportsHighContrast = window.matchMedia('(prefers-contrast: high)');

    const updateHighContrast = (matches: boolean) => {
      document.body.classList.toggle('high-contrast', matches);
    };

    updateHighContrast(supportsHighContrast.matches);
    supportsHighContrast.addEventListener('change', (e) => updateHighContrast(e.matches));
  }
}

React Accessibility Hooks

// react-chess/src/hooks/useAccessibility.js
import { useEffect, useRef, useCallback } from 'react';
import { KeyboardNavigationManager } from '../../shared/accessibility/KeyboardNavigationManager';
import { ScreenReaderAnnouncer } from '../../shared/accessibility/ScreenReaderAnnouncer';
import { ARIAManager } from '../../shared/accessibility/ARIAManager';

export function useAccessibility(gameState, onSquareSelect, onMove) {
  const boardRef = useRef(null);
  const keyboardManagerRef = useRef(null);
  const announcerRef = useRef(null);
  const ariaManagerRef = useRef(null);

  useEffect(() => {
    if (!announcerRef.current) {
      announcerRef.current = new ScreenReaderAnnouncer();
    }

    if (!ariaManagerRef.current) {
      ariaManagerRef.current = new ARIAManager();
    }

    return () => {
      // Cleanup if needed
    };
  }, []);

  useEffect(() => {
    if (boardRef.current && gameState) {
      if (!keyboardManagerRef.current) {
        keyboardManagerRef.current = new KeyboardNavigationManager(
          boardRef.current,
          gameState,
          onSquareSelect,
          onMove
        );
      }
    }
  }, [gameState, onSquareSelect, onMove]);

  const announceMove = useCallback((move) => {
    if (announcerRef.current) {
      announcerRef.current.announceMove(move);
    }
  }, []);

  const announceGameState = useCallback((state) => {
    if (announcerRef.current) {
      announcerRef.current.announceBoardState(state);
    }

    if (ariaManagerRef.current) {
      ariaManagerRef.current.updateGameStatus(
        state.gameStatus,
        state.currentPlayer,
        state.winner
      );
    }
  }, []);

  const announceCheck = useCallback((color) => {
    if (announcerRef.current) {
      announcerRef.current.announce(`${color} king is in check`);
    }
  }, []);

  const announceGameEnd = useCallback((result, winner) => {
    if (announcerRef.current) {
      let message = '';
      if (result === 'checkmate') {
        message = `Checkmate! ${winner} wins the game.`;
      } else if (result === 'stalemate') {
        message = 'Stalemate! The game is a draw.';
      } else if (result === 'draw') {
        message = 'The game ended in a draw.';
      }

      announcerRef.current.announce(message);
    }
  }, []);

  return {
    boardRef,
    announceMove,
    announceGameState,
    announceCheck,
    announceGameEnd
  };
}

Vue Accessibility Composable

// vue-chess/src/composables/useAccessibility.js
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { KeyboardNavigationManager } from '../../shared/accessibility/KeyboardNavigationManager';
import { ScreenReaderAnnouncer } from '../../shared/accessibility/ScreenReaderAnnouncer';
import { ARIAManager } from '../../shared/accessibility/ARIAManager';

export function useAccessibility(gameState, onSquareSelect, onMove) {
  const boardRef = ref(null);
  let keyboardManager = null;
  let announcer = null;
  let ariaManager = null;

  onMounted(() => {
    announcer = new ScreenReaderAnnouncer();
    ariaManager = new ARIAManager();

    if (boardRef.value) {
      keyboardManager = new KeyboardNavigationManager(
        boardRef.value,
        gameState.value,
        onSquareSelect,
        onMove
      );
    }
  });

  onUnmounted(() => {
    // Cleanup if needed
  });

  watch(gameState, (newState) => {
    if (announcer) {
      announcer.announceBoardState(newState);
    }

    if (ariaManager) {
      ariaManager.updateGameStatus(
        newState.gameStatus,
        newState.currentPlayer,
        newState.winner
      );
    }
  }, { deep: true });

  const announceMove = (move) => {
    if (announcer) {
      announcer.announceMove(move);
    }
  };

  const announceGameStart = () => {
    if (announcer) {
      announcer.announceGameStart();
    }
  };

  return {
    boardRef,
    announceMove,
    announceGameStart
  };
}

Color and Contrast

Color Accessibility

/* Ensure WCAG AA contrast ratios */
:root {
  /* High contrast color palette */
  --color-text-primary: #000000;
  --color-text-secondary: #333333;
  --color-background: #ffffff;
  --color-surface: #f8f9fa;

  /* Chess-specific colors with high contrast */
  --chess-light-square: #f0d9b5;
  --chess-dark-square: #b58863;
  --chess-selected: #646cff;
  --chess-valid-move: #22c55e;
  --chess-capture: #ef4444;
  --chess-check: #f59e0b;

  /* Focus indicator */
  --color-focus: #0066cc;
  --color-focus-visible: #0052a3;
}

/* High contrast mode */
@media (prefers-contrast: high) {
  :root {
    --color-text-primary: #000000;
    --color-background: #ffffff;
    --chess-light-square: #ffffff;
    --chess-dark-square: #000000;
    --chess-selected: #ff0000;
    --chess-valid-move: #00ff00;
    --chess-capture: #ff0000;
    --color-focus: #ff0000;
  }

  .chess-piece--white {
    color: #000000;
    text-shadow: 2px 2px 0 #ffffff;
  }

  .chess-piece--black {
    color: #ffffff;
    text-shadow: 2px 2px 0 #000000;
  }
}

/* Reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* Color blindness accommodations */
.colorblind-mode {
  --chess-valid-move: #0066cc;
  --chess-capture: #cc6600;
  --chess-check: #9900cc;
}

/* Pattern alternatives for color-only information */
.chess-square--valid-move::before {
  content: '';
  position: absolute;
  top: 2px;
  right: 2px;
  width: 8px;
  height: 8px;
  background: repeating-linear-gradient(
    45deg,
    var(--chess-valid-move),
    var(--chess-valid-move) 2px,
    transparent 2px,
    transparent 4px
  );
}

.chess-square--capture::before {
  content: '';
  position: absolute;
  inset: 4px;
  border: 3px dashed var(--chess-capture);
  border-radius: 50%;
}

Testing Accessibility

Automated Accessibility Testing

// shared/testing/AccessibilityTestUtils.js
import { axe } from 'jest-axe';

export class AccessibilityTestUtils {
  static async runAxeTests(element) {
    const results = await axe(element);
    return results;
  }

  static async testKeyboardNavigation(element) {
    const focusableElements = element.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    const results = {
      totalElements: focusableElements.length,
      hasTabIndex: 0,
      hasProperFocus: 0
    };

    focusableElements.forEach(el => {
      if (el.tabIndex >= 0) {
        results.hasTabIndex++;
      }

      // Test focus visibility
      el.focus();
      const computedStyle = window.getComputedStyle(el);
      if (computedStyle.outline !== 'none' || computedStyle.boxShadow !== 'none') {
        results.hasProperFocus++;
      }
    });

    return results;
  }

  static testScreenReaderContent(element) {
    const ariaLabels = element.querySelectorAll('[aria-label]');
    const altTexts = element.querySelectorAll('img[alt]');
    const headings = element.querySelectorAll('h1, h2, h3, h4, h5, h6');

    return {
      ariaLabelsCount: ariaLabels.length,
      altTextsCount: altTexts.length,
      headingsCount: headings.length,
      hasMainLandmark: !!element.querySelector('[role="main"], main'),
      hasSkipLink: !!element.querySelector('.skip-link')
    };
  }

  static checkColorContrast(backgroundColor, textColor) {
    // Simple contrast ratio calculation
    const getLuminance = (color) => {
      const rgb = parseInt(color.slice(1), 16);
      const r = (rgb >> 16) & 0xff;
      const g = (rgb >> 8) & 0xff;
      const b = (rgb >> 0) & 0xff;

      const [rs, gs, bs] = [r, g, b].map(c => {
        c = c / 255;
        return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
      });

      return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
    };

    const l1 = getLuminance(backgroundColor);
    const l2 = getLuminance(textColor);
    const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);

    return {
      ratio,
      passesAA: ratio >= 4.5,
      passesAAA: ratio >= 7
    };
  }
}

Manual Testing Checklist

## Accessibility Testing Checklist

### Keyboard Navigation
- [ ] All interactive elements are keyboard accessible
- [ ] Tab order is logical and intuitive
- [ ] Focus indicators are clearly visible
- [ ] No keyboard traps exist
- [ ] Escape key works to cancel actions

### Screen Reader Testing
- [ ] All content is read by screen readers
- [ ] Board state is announced clearly
- [ ] Moves are announced with sufficient detail
- [ ] Game status changes are announced
- [ ] Navigation instructions are provided

### Visual Accessibility
- [ ] Color contrast meets WCAG AA standards
- [ ] Information isn't conveyed by color alone
- [ ] Text is readable at 200% zoom
- [ ] Layout works in high contrast mode
- [ ] Content is readable with CSS disabled

### Motor Accessibility
- [ ] Touch targets are at least 44x44 pixels
- [ ] Click/tap areas don't overlap
- [ ] Drag and drop has keyboard alternatives
- [ ] No actions require precise timing
- [ ] Alternatives exist for complex gestures

### Cognitive Accessibility
- [ ] Instructions are clear and simple
- [ ] Important information is repeated
- [ ] Consistent navigation and layout
- [ ] Error messages are descriptive
- [ ] Users can pause/stop animations

User Preferences

Accessibility Settings

// shared/accessibility/AccessibilitySettings.js
export class AccessibilitySettings {
  constructor() {
    this.settings = this.loadSettings();
    this.applySettings();
  }

  loadSettings() {
    const defaults = {
      reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
      highContrast: window.matchMedia('(prefers-contrast: high)').matches,
      screenReaderMode: false,
      soundEnabled: true,
      largeText: false,
      colorBlindMode: false,
      keyboardOnlyMode: false
    };

    const stored = localStorage.getItem('chess-accessibility-settings');
    return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
  }

  saveSettings() {
    localStorage.setItem('chess-accessibility-settings', JSON.stringify(this.settings));
    this.applySettings();
  }

  applySettings() {
    document.body.classList.toggle('reduce-motion', this.settings.reduceMotion);
    document.body.classList.toggle('high-contrast', this.settings.highContrast);
    document.body.classList.toggle('screen-reader-mode', this.settings.screenReaderMode);
    document.body.classList.toggle('large-text', this.settings.largeText);
    document.body.classList.toggle('colorblind-mode', this.settings.colorBlindMode);
    document.body.classList.toggle('keyboard-only', this.settings.keyboardOnlyMode);
  }

  updateSetting(key, value) {
    this.settings[key] = value;
    this.saveSettings();
  }

  getSetting(key) {
    return this.settings[key];
  }

  resetToDefaults() {
    this.settings = this.loadSettings();
    localStorage.removeItem('chess-accessibility-settings');
    this.applySettings();
  }
}

Next Steps

⚠️ **GitHub.com Fallback** ⚠️