Add accessibility suite to replay viewer (Phase 10)

- Paul Tol color-blind safe palette (8 distinct colors)
- Player shapes: circle, square, triangle, diamond, pentagon, hexagon
- High contrast mode (brighter colors, darker walls)
- Reduced motion support (auto-detect prefers-reduced-motion)
- Accessibility controls UI in replay page
- Evolution fields added to BotProfile interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-29 04:03:19 -04:00
parent 88a5893f66
commit 1d02831f1c
3 changed files with 388 additions and 43 deletions

View file

@ -58,6 +58,11 @@ export interface BotProfile {
updated_at: string;
rating_history: RatingHistoryEntry[];
recent_matches: MatchSummary[];
// Evolution fields (optional - only present for evolved bots)
evolved?: boolean;
island?: string;
generation?: number;
parent_ids?: string[];
}
export interface BotDirectoryEntry {

View file

@ -123,6 +123,28 @@ function renderReplayPage(params: Record<string, string>): void {
</div>
</div>
<div class="panel">
<h2>Accessibility</h2>
<div class="accessibility-options">
<label class="checkbox-label">
<input type="checkbox" id="color-blind-toggle" checked>
Color-blind safe palette
</label>
<label class="checkbox-label">
<input type="checkbox" id="shapes-toggle" checked>
Shapes per player
</label>
<label class="checkbox-label">
<input type="checkbox" id="high-contrast-toggle">
High contrast mode
</label>
<label class="checkbox-label">
<input type="checkbox" id="reduced-motion-toggle">
Reduced motion
</label>
</div>
</div>
<div class="panel">
<h2>Match Info</h2>
<dl class="match-info">
@ -276,6 +298,32 @@ function renderReplayPage(params: Record<string, string>): void {
font-size: 14px;
}
.accessibility-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text-muted);
font-size: 0.875rem;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.checkbox-label:hover {
color: var(--text-primary);
}
.match-info dt {
color: var(--text-muted);
font-size: 0.75rem;
@ -479,6 +527,32 @@ function initReplayViewer(initialUrl?: string): void {
}
});
// Accessibility toggle handlers
const colorBlindToggle = document.getElementById('color-blind-toggle') as HTMLInputElement;
const shapesToggle = document.getElementById('shapes-toggle') as HTMLInputElement;
const highContrastToggle = document.getElementById('high-contrast-toggle') as HTMLInputElement;
const reducedMotionToggle = document.getElementById('reduced-motion-toggle') as HTMLInputElement;
function updateAccessibility(): void {
viewer.setAccessibility({
colorBlindSafe: colorBlindToggle.checked,
showShapes: shapesToggle.checked,
highContrast: highContrastToggle.checked,
reducedMotion: reducedMotionToggle.checked,
});
}
colorBlindToggle.addEventListener('change', updateAccessibility);
shapesToggle.addEventListener('change', updateAccessibility);
highContrastToggle.addEventListener('change', updateAccessibility);
reducedMotionToggle.addEventListener('change', updateAccessibility);
// Initialize accessibility from system preferences
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
reducedMotionToggle.checked = true;
updateAccessibility();
}
viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };

View file

@ -1,7 +1,29 @@
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent } from './types';
// Player colors - accessible and distinct
const PLAYER_COLORS = [
// ── Accessibility: Paul Tol's color-blind safe palette ──────────────────────────
// These colors are designed to be distinguishable for all color vision deficiencies
// See: https://personal.sron.nl/~pault/
const TOL_PALETTE = [
'#332288', // Indigo (player 0)
'#88ccee', // Cyan (player 1)
'#44aa99', // Teal (player 2)
'#117733', // Green (player 3)
'#999933', // Olive (player 4)
'#ddcc77', // Sand (player 5)
];
// High contrast version for accessibility mode
const HIGH_CONTRAST_PALETTE = [
'#0000ff', // Blue (player 0)
'#ff0000', // Red (player 1)
'#00ff00', // Green (player 2)
'#ff00ff', // Magenta (player 3)
'#00ffff', // Cyan (player 4)
'#ffff00', // Yellow (player 5)
];
// Default palette (original - not color-blind safe, used for backwards compat)
const DEFAULT_PLAYER_COLORS = [
'#3b82f6', // Blue (player 0)
'#ef4444', // Red (player 1)
'#22c55e', // Green (player 2)
@ -10,19 +32,52 @@ const PLAYER_COLORS = [
'#06b6d4', // Cyan (player 5)
];
// Shape types for each player (0-5) - allows shape + color identification
type PlayerShape = 'circle' | 'square' | 'triangle' | 'diamond' | 'pentagon' | 'hexagon';
const PLAYER_SHAPES: PlayerShape[] = ['circle', 'square', 'triangle', 'diamond', 'pentagon', 'hexagon'];
const NEUTRAL_COLOR = '#6b7280'; // Gray
const WALL_COLOR = '#1f2937'; // Dark gray
const ENERGY_COLOR = '#fbbf24'; // Yellow
const BACKGROUND_COLOR = '#111827'; // Very dark gray
const GRID_COLOR = '#374151'; // Medium gray
// High contrast versions
const HIGH_CONTRAST_NEUTRAL = '#888888';
const HIGH_CONTRAST_WALL = '#444444';
const HIGH_CONTRAST_ENERGY = '#ffff00';
const HIGH_CONTRAST_BACKGROUND = '#000000';
const HIGH_CONTRAST_GRID = '#666666';
export interface ViewerOptions {
cellSize?: number;
showGrid?: boolean;
fogOfWarPlayer?: number | null; // null = disabled, number = player perspective
animationSpeed?: number; // ms per frame
// Accessibility options
colorBlindSafe?: boolean; // Use Tol palette (default: true)
highContrast?: boolean; // High contrast mode
showShapes?: boolean; // Draw different shapes per player (default: true)
reducedMotion?: boolean; // Skip animations (auto-detected from prefers-reduced-motion)
}
// Accessibility mode configuration
export interface AccessibilitySettings {
colorBlindSafe: boolean;
highContrast: boolean;
showShapes: boolean;
reducedMotion: boolean;
}
// Default accessibility settings
export const DEFAULT_ACCESSIBILITY: AccessibilitySettings = {
colorBlindSafe: true,
highContrast: false,
showShapes: true,
reducedMotion: typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
};
export class ReplayViewer {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
@ -35,6 +90,8 @@ export class ReplayViewer {
private showGrid: boolean;
private fogOfWarPlayer: number | null;
private animationSpeed: number;
private accessibility: AccessibilitySettings;
private screenReaderRegion: HTMLElement | null = null;
// Event callbacks
public onTurnChange?: (turn: number) => void;
@ -52,9 +109,39 @@ export class ReplayViewer {
this.fogOfWarPlayer = options.fogOfWarPlayer ?? null;
this.animationSpeed = options.animationSpeed ?? 100;
// Initialize accessibility settings
this.accessibility = {
colorBlindSafe: options.colorBlindSafe ?? DEFAULT_ACCESSIBILITY.colorBlindSafe,
highContrast: options.highContrast ?? DEFAULT_ACCESSIBILITY.highContrast,
showShapes: options.showShapes ?? DEFAULT_ACCESSIBILITY.showShapes,
reducedMotion: options.reducedMotion ??
(options.reducedMotion ?? DEFAULT_ACCESSIBILITY.reducedMotion),
};
// Create screen reader region for announcements
this.createScreenReaderRegion();
this.render = this.render.bind(this);
}
// Create or get the aria-live region for screen reader announcements
private createScreenReaderRegion(): void {
const existingRegion = document.getElementById('acb-screen-reader-region');
if (existingRegion) {
this.screenReaderRegion = existingRegion;
return;
}
const region = document.createElement('div');
region.id = 'acb-screen-reader-region';
region.setAttribute('role', 'status');
region.setAttribute('aria-live', 'polite');
region.setAttribute('aria-atomic', 'true');
region.style.cssText = 'position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;';
document.body.appendChild(region);
this.screenReaderRegion = region;
}
loadReplay(replay: Replay): void {
this.replay = replay;
this.currentTurn = 0;
@ -140,6 +227,164 @@ export class ReplayViewer {
return this.fogOfWarPlayer;
}
// ── Accessibility Controls ─────────────────────────────────────────────────────
setAccessibility(settings: Partial<AccessibilitySettings>): void {
this.accessibility = { ...this.accessibility, ...settings };
this.render();
}
getAccessibility(): AccessibilitySettings {
return { ...this.accessibility };
}
// Get the active color palette based on accessibility settings
private getPlayerColors(): string[] {
if (this.accessibility.highContrast) {
return HIGH_CONTRAST_PALETTE;
}
return this.accessibility.colorBlindSafe ? TOL_PALETTE : DEFAULT_PLAYER_COLORS;
}
// Get background color based on accessibility mode
private getBackgroundColor(): string {
return this.accessibility.highContrast ? HIGH_CONTRAST_BACKGROUND : BACKGROUND_COLOR;
}
// Get wall color based on accessibility mode
private getWallColor(): string {
return this.accessibility.highContrast ? HIGH_CONTRAST_WALL : WALL_COLOR;
}
// Get energy color based on accessibility mode
private getEnergyColor(): string {
return this.accessibility.highContrast ? HIGH_CONTRAST_ENERGY : ENERGY_COLOR;
}
// Get grid color based on accessibility mode
private getGridColor(): string {
return this.accessibility.highContrast ? HIGH_CONTRAST_GRID : GRID_COLOR;
}
// Announce events to screen readers
private announceToScreenReader(message: string): void {
if (this.screenReaderRegion) {
this.screenReaderRegion.textContent = message;
}
}
// Generate text description of turn events for screen readers
private generateTurnDescription(events: GameEvent[]): string {
if (events.length === 0) {
return `Turn ${this.currentTurn}: No events.`;
}
const descriptions = events.map(e => {
const details = e.details as Record<string, unknown>;
switch (e.type) {
case 'bot_died':
return `Bot ${(details as { bot_id: number }).bot_id} was destroyed`;
case 'bot_spawned':
return `New bot ${(details as { bot_id: number }).bot_id} spawned`;
case 'energy_collected':
return 'Energy collected';
case 'core_captured':
return `Core captured by player ${(details as { new_owner: number }).new_owner}`;
case 'core_destroyed':
return 'Core destroyed';
default:
return e.type.replace(/_/g, ' ');
}
});
return `Turn ${this.currentTurn}: ${descriptions.join(', ')}.`;
}
// Draw a player shape (circle, square, triangle, etc.)
private drawPlayerShape(x: number, y: number, radius: number, playerIdx: number, color: string): void {
const { ctx } = this;
const shape = PLAYER_SHAPES[playerIdx % PLAYER_SHAPES.length];
ctx.fillStyle = color;
ctx.strokeStyle = this.accessibility.highContrast ? '#ffffff' : '#ffffff';
ctx.lineWidth = this.accessibility.highContrast ? 2 : 1;
if (!this.accessibility.showShapes) {
// Default: draw circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
return;
}
switch (shape) {
case 'circle':
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
break;
case 'square':
ctx.beginPath();
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
ctx.fill();
ctx.stroke();
break;
case 'triangle':
ctx.beginPath();
ctx.moveTo(x, y - radius);
ctx.lineTo(x + radius * 0.866, y + radius * 0.5);
ctx.lineTo(x - radius * 0.866, y + radius * 0.5);
ctx.closePath();
ctx.fill();
ctx.stroke();
break;
case 'diamond':
ctx.beginPath();
ctx.moveTo(x, y - radius);
ctx.lineTo(x + radius * 0.707, y);
ctx.lineTo(x, y + radius);
ctx.lineTo(x - radius * 0.707, y);
ctx.closePath();
ctx.fill();
ctx.stroke();
break;
case 'pentagon':
this.drawPolygon(x, y, radius, 5);
ctx.fill();
ctx.stroke();
break;
case 'hexagon':
this.drawPolygon(x, y, radius, 6);
ctx.fill();
ctx.stroke();
break;
}
}
// Helper to draw regular polygons
private drawPolygon(cx: number, cy: number, radius: number, sides: number): void {
const { ctx } = this;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI / sides) - Math.PI / 2;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
}
private animate(timestamp: number): void {
if (!this.isPlaying || !this.replay) return;
@ -167,15 +412,21 @@ export class ReplayViewer {
const { ctx, cellSize, canvas } = this;
const { rows, cols } = this.replay.map;
const colors = this.getPlayerColors();
const bgColor = this.getBackgroundColor();
const gridColor = this.getGridColor();
const wallColor = this.getWallColor();
const energyColor = this.getEnergyColor();
const neutralColor = this.accessibility.highContrast ? HIGH_CONTRAST_NEUTRAL : NEUTRAL_COLOR;
// Clear canvas
ctx.fillStyle = BACKGROUND_COLOR;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid lines
if (this.showGrid) {
ctx.strokeStyle = GRID_COLOR;
ctx.lineWidth = 0.5;
ctx.strokeStyle = gridColor;
ctx.lineWidth = this.accessibility.highContrast ? 1 : 0.5;
for (let r = 0; r <= rows; r++) {
ctx.beginPath();
ctx.moveTo(0, r * cellSize);
@ -201,32 +452,38 @@ export class ReplayViewer {
// Draw walls (always visible)
for (const wall of this.replay.map.walls) {
this.drawCell(wall.row, wall.col, WALL_COLOR);
this.drawCell(wall.row, wall.col, wallColor);
}
// Draw cores
for (const core of turnData.cores) {
if (visible && !visible.has(this.posKey(core.position))) continue;
const color = core.active ? PLAYER_COLORS[core.owner] : NEUTRAL_COLOR;
const color = core.active ? colors[core.owner] : neutralColor;
this.drawCore(core.position.row, core.position.col, color, core.active);
}
// Draw energy
for (const energy of turnData.energy) {
if (visible && !visible.has(this.posKey(energy))) continue;
this.drawEnergy(energy.row, energy.col);
this.drawEnergy(energy.row, energy.col, energyColor);
}
// Draw bots
// Draw bots with accessible shapes
for (const bot of turnData.bots) {
if (!bot.alive) continue;
if (visible && !visible.has(this.posKey(bot.position))) continue;
const color = PLAYER_COLORS[bot.owner];
const color = colors[bot.owner];
this.drawBot(bot, color);
}
// Draw score overlay
this.drawScoreOverlay(turnData);
this.drawScoreOverlay(turnData, colors);
// Announce turn to screen reader if reduced motion is preferred
if (this.accessibility.reducedMotion) {
const events = turnData.events ?? [];
this.announceToScreenReader(this.generateTurnDescription(events));
}
}
private computeVisibility(turnData: ReplayTurn, player: number): Set<string> {
@ -281,8 +538,8 @@ export class ReplayViewer {
// Draw inactive marker
if (!active) {
ctx.strokeStyle = BACKGROUND_COLOR;
ctx.lineWidth = 2;
ctx.strokeStyle = this.getBackgroundColor();
ctx.lineWidth = this.accessibility.highContrast ? 3 : 2;
ctx.beginPath();
ctx.moveTo(x - radius / 2, y - radius / 2);
ctx.lineTo(x + radius / 2, y + radius / 2);
@ -290,64 +547,73 @@ export class ReplayViewer {
}
}
private drawEnergy(row: number, col: number): void {
private drawEnergy(row: number, col: number, color: string): void {
const { ctx, cellSize } = this;
const x = col * cellSize + cellSize / 2;
const y = row * cellSize + cellSize / 2;
const radius = (cellSize / 3);
ctx.fillStyle = ENERGY_COLOR;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
private drawBot(bot: ReplayBot, color: string): void {
const { ctx, cellSize } = this;
const x = bot.position.col * cellSize + cellSize / 2;
const y = bot.position.row * cellSize + cellSize / 2;
const radius = (cellSize / 2) - 1;
// Draw bot as filled circle
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
// Draw border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.stroke();
// Add star shape for accessibility
if (this.accessibility.showShapes) {
ctx.strokeStyle = this.accessibility.highContrast ? '#000000' : '#1f2937';
ctx.lineWidth = 1;
ctx.stroke();
}
}
private drawScoreOverlay(turnData: ReplayTurn): void {
private drawBot(bot: ReplayBot, color: string): void {
const { cellSize } = this;
const x = bot.position.col * cellSize + cellSize / 2;
const y = bot.position.row * cellSize + cellSize / 2;
const radius = (cellSize / 2) - 1;
// Draw bot with player-specific shape for accessibility
this.drawPlayerShape(x, y, radius, bot.owner, color);
}
private drawScoreOverlay(turnData: ReplayTurn, colors: string[]): void {
if (!this.replay) return;
const { ctx } = this;
const padding = 10;
const lineHeight = 20;
const lineHeight = 24; // Increased for shape indicators
// Draw semi-transparent background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, 150, padding * 2 + lineHeight * this.replay.players.length);
ctx.fillStyle = this.accessibility.highContrast ? 'rgba(0, 0, 0, 0.9)' : 'rgba(0, 0, 0, 0.7)';
const bgHeight = padding * 2 + lineHeight * this.replay.players.length;
ctx.fillRect(0, 0, 170, bgHeight);
// Draw scores for each player
ctx.font = '14px monospace';
ctx.font = this.accessibility.highContrast ? 'bold 14px monospace' : '14px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
this.replay.players.forEach((player, idx) => {
const score = turnData.scores[idx] ?? 0;
const energy = turnData.energy_held[idx] ?? 0;
const color = PLAYER_COLORS[idx];
const color = colors[idx];
const yOffset = padding + idx * lineHeight;
// Draw color indicator
ctx.fillStyle = color;
ctx.fillRect(padding, padding + idx * lineHeight, 12, 12);
// Draw shape indicator for accessibility
const indicatorSize = 14;
const indicatorX = padding + indicatorSize / 2;
const indicatorY = yOffset + indicatorSize / 2 + 3;
// Draw text
ctx.fillStyle = '#ffffff';
ctx.fillText(`${player.name}: ${score} (E:${energy})`, padding + 18, padding + idx * lineHeight);
if (this.accessibility.showShapes) {
this.drawPlayerShape(indicatorX, indicatorY, indicatorSize / 2 - 1, idx, color);
} else {
ctx.fillStyle = color;
ctx.fillRect(padding, yOffset + 3, indicatorSize, indicatorSize);
}
// Draw text with better contrast in high contrast mode
ctx.fillStyle = this.accessibility.highContrast ? '#ffffff' : '#e5e7eb';
ctx.fillText(`${player.name}: ${score} (E:${energy})`, padding + 22, yOffset + 4);
});
}