feat(§15.3): implement screen reader transcript for replay viewer

- Add transcript panel with turn-by-turn summaries generated from replay events
- Each turn shows: player moves, combat, deaths, captures, energy collection, spawns, win probability
- Add 'T' key shortcut to toggle transcript panel
- Panel supports three view modes: All Turns, ±10 Turns from Current, Recent 20 Turns
- Click on transcript entry to jump to that turn
- Current turn is highlighted in transcript with smooth scroll
- Panel content is selectable/copyable for screen reader users
- Transcript generation logic already existed in replay-viewer.ts; this adds the UI
- Transcript button slides in from right side of screen

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 18:42:49 -04:00
parent 38f14e1997
commit 60b83a02d9
6 changed files with 1422 additions and 9 deletions

View file

@ -1 +1 @@
98a9f645c407a55ae1059cb07107cecbe4ecc0cf
38f14e1997145a3900b10124a18e31a812a65330

View file

@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
@ -200,11 +201,15 @@ func buildMockJSONResponse(code string) string {
// Integration test with mock server
func TestEnsemble_WithMockServer(t *testing.T) {
var mu sync.Mutex
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
callCount++
cc := callCount
mu.Unlock()
// Return a valid response with code block
code := fmt.Sprintf("package main\nfunc main() { /* code %c }", rune('A'+callCount))
code := fmt.Sprintf("package main\nfunc main() { /* code %c }", rune('A'+cc))
response := buildMockJSONResponse(code)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))

View file

@ -140,8 +140,9 @@ func (c *LLMClient) GenerateNarrative(ctx context.Context, req NarrativeRequest)
func buildNarrativePrompt(req NarrativeRequest) string {
var sb strings.Builder
// §15.5 instruction: sports-journalism narrative with structured contextual match data
sb.WriteString("Write a 200-word sports-journalism narrative about this event in the AI Code Battle platform. ")
sb.WriteString("Be dramatic but factual. Reference specific matches, ratings, and rivalries. ")
sb.WriteString("Be dramatic but factual. Reference specific matches, ELO before/after deltas, rivalry context, and critical turning points. ")
sb.WriteString("Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n")
// Season and standings context
@ -170,7 +171,7 @@ func buildNarrativePrompt(req NarrativeRequest) string {
sb.WriteString(fmt.Sprintf("Lineage: generation %d, parents: %s\n", req.Generation, strings.Join(req.ParentIDs, ", ")))
}
if len(req.KeyMatches) > 0 {
sb.WriteString("Key matches (turning points in the climb):\n")
sb.WriteString("Critical moments (turning points in the climb):\n")
for _, m := range req.KeyMatches {
outcome := "Lost to"
if m.Won {
@ -249,7 +250,7 @@ func buildNarrativePrompt(req NarrativeRequest) string {
sb.WriteString(fmt.Sprintf("Head-to-head record this week: %d-%d %s vs %s (%d total matches)\n",
req.BotAWins, req.BotBWins, req.BotName, req.BotBName, req.TotalMatches))
if len(req.KeyMatches) > 0 {
sb.WriteString("Recent encounters (turning points):\n")
sb.WriteString("Recent encounters (critical moments):\n")
for _, m := range req.KeyMatches {
winner := req.BotBName
if m.Won {
@ -259,8 +260,8 @@ func buildNarrativePrompt(req NarrativeRequest) string {
if m.EndCondition != "" {
condStr = fmt.Sprintf(" [%s]", m.EndCondition)
}
sb.WriteString(fmt.Sprintf(" - %s won on \"%s\" (%d turns, score %s)%s. Match ID: %s\n",
winner, nonEmpty(m.MapName, "standard map"), m.TurnCount, m.Score, condStr, m.MatchID))
sb.WriteString(fmt.Sprintf(" - %s won on \"%s\" (%d turns, score %s, opponent ELO %d)%s. Match ID: %s\n",
winner, nonEmpty(m.MapName, "standard map"), m.TurnCount, m.Score, m.OpponentRating, condStr, m.MatchID))
}
}
if len(req.HeadToHead) > 0 {

File diff suppressed because it is too large Load diff

View file

@ -213,6 +213,145 @@
border-radius: 4px;
font-family: monospace;
}
/* Transcript panel styles (§15.3) */
.transcript-panel {
position: fixed;
top: 0;
right: -400px;
width: 400px;
height: 100vh;
background-color: #1e293b;
border-left: 1px solid #334155;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
transition: right 0.3s ease-in-out;
display: flex;
flex-direction: column;
z-index: 100;
}
.transcript-panel.open {
right: 0;
}
.transcript-header {
padding: 15px;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
.transcript-header h2 {
font-size: 1rem;
margin: 0;
color: #f8fafc;
}
.transcript-close {
background: none;
border: none;
color: #94a3b8;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.transcript-close:hover {
background-color: #334155;
color: #f8fafc;
}
.transcript-content {
flex: 1;
overflow-y: auto;
padding: 15px;
font-size: 13px;
line-height: 1.5;
}
.transcript-content select {
width: 100%;
margin-bottom: 15px;
background-color: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px;
border-radius: 6px;
font-size: 13px;
}
.transcript-entry {
padding: 10px;
margin-bottom: 10px;
background-color: #0f172a;
border-radius: 6px;
border-left: 3px solid #334155;
cursor: pointer;
user-select: text;
}
.transcript-entry:hover {
background-color: #1a2436;
}
.transcript-entry.current {
border-left-color: #3b82f6;
background-color: #1e3a5f;
}
.transcript-entry .turn-number {
font-weight: bold;
color: #94a3b8;
font-size: 11px;
text-transform: uppercase;
margin-bottom: 4px;
}
.transcript-entry .text {
color: #e2e8f0;
}
.transcript-toggle-btn {
position: fixed;
top: 50%;
right: 0;
transform: translateY(-50%);
background-color: #3b82f6;
color: white;
border: none;
padding: 10px 6px;
border-radius: 6px 0 0 6px;
cursor: pointer;
font-size: 12px;
writing-mode: vertical-rl;
text-orientation: mixed;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
z-index: 99;
transition: background-color 0.2s;
}
.transcript-toggle-btn:hover {
background-color: #2563eb;
}
.transcript-panel.open .transcript-toggle-btn {
display: none;
}
/* Responsive: on smaller screens, make panel wider */
@media (max-width: 768px) {
.transcript-panel {
width: 100%;
right: -100%;
}
}
</style>
</head>
<body>
@ -309,6 +448,29 @@
</div>
</div>
<!-- Transcript panel (§15.3) -->
<button id="transcript-toggle" class="transcript-toggle-btn" title="Toggle Transcript (T)">
Transcript
</button>
<div id="transcript-panel" class="transcript-panel">
<div class="transcript-header">
<h2>Turn-by-Turn Transcript</h2>
<button id="transcript-close" class="transcript-close" title="Close transcript">&times;</button>
</div>
<div class="transcript-content">
<select id="transcript-view-mode">
<option value="all">All Turns</option>
<option value="window">±10 Turns from Current</option>
<option value="recent">Recent 20 Turns</option>
</select>
<div id="transcript-entries">
<p style="color: #64748b; text-align: center; padding: 20px;">
Load a replay to view the transcript.
</p>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,5 +1,5 @@
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
import type { Replay, TranscriptEntry } from './types';
// DOM elements
const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
@ -26,6 +26,17 @@ const infoWinner = document.getElementById('info-winner') as HTMLElement;
const infoTurns = document.getElementById('info-turns') as HTMLElement;
const infoReason = document.getElementById('info-reason') as HTMLElement;
// Transcript panel elements (§15.3)
const transcriptPanel = document.getElementById('transcript-panel') as HTMLDivElement;
const transcriptToggleBtn = document.getElementById('transcript-toggle') as HTMLButtonElement;
const transcriptCloseBtn = document.getElementById('transcript-close') as HTMLButtonElement;
const transcriptEntriesDiv = document.getElementById('transcript-entries') as HTMLDivElement;
const transcriptViewMode = document.getElementById('transcript-view-mode') as HTMLSelectElement;
// Transcript state
let transcriptEntries: TranscriptEntry[] = [];
let transcriptViewModeValue: 'all' | 'window' | 'recent' = 'all';
// Initialize viewer
let viewer = new ReplayViewer(canvas, { cellSize: 16 });
@ -101,6 +112,7 @@ function loadReplay(replay: Replay): void {
updateUI();
updateEventLog();
renderTranscript(); // Generate and render transcript (§15.3)
}
// File input handler
@ -182,7 +194,7 @@ cellSizeSelect.addEventListener('change', () => {
const replay = viewer.getReplay();
if (replay) {
viewer = new ReplayViewer(canvas, { cellSize: size });
viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
viewer.onTurnChange = () => { updateUI(); updateEventLog(); updateTranscriptHighlight(); };
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
loadReplay(replay);
}
@ -192,12 +204,117 @@ cellSizeSelect.addEventListener('change', () => {
viewer.onTurnChange = () => {
updateUI();
updateEventLog();
updateTranscriptHighlight();
};
viewer.onPlayStateChange = (playing) => {
playBtn.textContent = playing ? 'Pause' : 'Play';
};
// ── Transcript Panel Functions (§15.3) ────────────────────────────────────────
function toggleTranscriptPanel(): void {
transcriptPanel.classList.toggle('open');
// Update button visibility based on panel state
transcriptToggleBtn.style.display = transcriptPanel.classList.contains('open') ? 'none' : 'block';
}
function closeTranscriptPanel(): void {
transcriptPanel.classList.remove('open');
transcriptToggleBtn.style.display = 'block';
}
function renderTranscript(): void {
if (!viewer.getReplay()) {
transcriptEntriesDiv.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">Load a replay to view the transcript.</p>';
return;
}
// Generate transcript from viewer
transcriptEntries = viewer.generateTranscript();
// Filter entries based on view mode
const filteredEntries = filterTranscriptEntries(transcriptEntries);
if (filteredEntries.length === 0) {
transcriptEntriesDiv.innerHTML = '<p style="color: #64748b; text-align: center; padding: 20px;">No transcript entries available.</p>';
return;
}
// Render entries
transcriptEntriesDiv.innerHTML = filteredEntries.map(entry => {
const isCurrent = entry.turn === viewer.getTurn();
return `
<div class="transcript-entry${isCurrent ? ' current' : ''}" data-turn="${entry.turn}">
<div class="turn-number">Turn ${entry.turn}</div>
<div class="text">${entry.text}</div>
</div>
`;
}).join('');
// Add click handlers for jump-to-turn
transcriptEntriesDiv.querySelectorAll('.transcript-entry').forEach(el => {
el.addEventListener('click', () => {
const turn = parseInt(el.getAttribute('data-turn') || '0', 10);
viewer.setTurn(turn);
updateUI();
updateEventLog();
updateTranscriptHighlight();
});
});
updateTranscriptHighlight();
}
function filterTranscriptEntries(entries: TranscriptEntry[]): TranscriptEntry[] {
const currentTurn = viewer.getTurn();
const totalTurns = viewer.getTotalTurns();
switch (transcriptViewModeValue) {
case 'window':
// Show ±10 turns from current turn
return entries.filter(e => e.turn >= currentTurn - 10 && e.turn <= currentTurn + 10);
case 'recent':
// Show last 20 turns
return entries.filter(e => e.turn >= totalTurns - 20);
case 'all':
default:
// Show all turns
return entries;
}
}
function updateTranscriptHighlight(): void {
// Update current turn highlighting in transcript
const currentTurn = viewer.getTurn();
transcriptEntriesDiv.querySelectorAll('.transcript-entry').forEach(el => {
const turn = parseInt(el.getAttribute('data-turn') || '-1', 10);
if (turn === currentTurn) {
el.classList.add('current');
// Scroll the current entry into view if panel is open
if (transcriptPanel.classList.contains('open')) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} else {
el.classList.remove('current');
}
});
}
// Transcript panel event listeners
transcriptToggleBtn.addEventListener('click', toggleTranscriptPanel);
transcriptCloseBtn.addEventListener('click', closeTranscriptPanel);
transcriptViewMode.addEventListener('change', () => {
const value = transcriptViewMode.value;
if (value === 'all' || value === 'window' || value === 'recent') {
transcriptViewModeValue = value;
renderTranscript();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (!viewer.getReplay()) return;
@ -231,6 +348,11 @@ document.addEventListener('keydown', (e) => {
updateUI();
updateEventLog();
break;
case 'KeyT':
// Toggle transcript panel (§15.3)
e.preventDefault();
toggleTranscriptPanel();
break;
}
});