pokertools-arena 0.4.5 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  | **Package** | `npx pokertools-arena` |
18
18
  | **Runtime** | Node.js ≥ 24 for tooling; any modern browser for the app |
19
19
 
20
- > **Current release: 0.4.5.** A maintenance release on top of the 0.4.0 methodology work: modal tooltips float above overflow, the launcher starts exactly once when its default port is busy, real-API diagnostics skip cleanly when repository secrets are absent, the Decision sanity suite attributes results to the correct model, seat editors show the model-derived name, close buttons use centered SVG icons, decision logs no longer repeat the typed-decision caption, and Stop immediately freezes the clock and table effects.
20
+ > **Current release: 0.4.9.** A maintenance release on top of the 0.4.0 methodology work: modal tooltips float above overflow, the launcher starts exactly once when its default port is busy, real-API diagnostics skip cleanly when repository secrets are absent, the Decision sanity suite attributes results to the correct model, seat editors show the model-derived name, close buttons use centered SVG icons, decision logs no longer repeat the typed-decision caption, Stop immediately freezes the clock and table effects, seat cards clear their last action at the start of each hand, pots fly back to winners at showdown, the header/table chrome and small-screen board cards are more compact, and the inspector is an accessible tablist with corrected poker statistics.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -115737,6 +115737,7 @@ function animateBoardCards(previousCount, currentCount) {
115737
115737
  }
115738
115738
  function processVisualEffects(s) {
115739
115739
  const events = s?.events || [];
115740
+ const previous = lastVisualState;
115740
115741
  if (["STOPPED", "ERROR"].includes(s?.status)) {
115741
115742
  if (events.length) lastProcessedEventId = events.at(-1).id;
115742
115743
  lastVisualState = s;
@@ -115748,7 +115749,6 @@ function processVisualEffects(s) {
115748
115749
  lastVisualState = s;
115749
115750
  return;
115750
115751
  }
115751
- const previous = lastVisualState;
115752
115752
  if (s?.table && (!previous?.table || s.table.handNumber !== previous.table.handNumber)) animateNewHand(s);
115753
115753
  const prevBoard = previous?.table?.board?.length || 0, nextBoard = s?.table?.board?.length || 0;
115754
115754
  if (s?.table?.handNumber === previous?.table?.handNumber) animateBoardCards(prevBoard, nextBoard);
@@ -115774,12 +115774,14 @@ function processVisualEffects(s) {
115774
115774
  }
115775
115775
  showActionToast(`${displayModelName(e.configuredModel || e.resolvedModel || e.playerName)} \xB7 ${e.action?.description || type}`, type);
115776
115776
  }
115777
- if (e.type === "HAND_END" && previous?.table) {
115778
- const before = new Map((previous.table.players || []).filter(Boolean).map((p) => [p.id, Number(p.stack || 0)]));
115779
- const winners = (s?.table?.players || []).filter((p) => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0)));
115777
+ if (e.type === "HAND_END") {
115778
+ const before = new Map((previous?.table?.players || []).filter(Boolean).map((p) => [p.id, Number(p.stack || 0)]));
115779
+ const payloadIds = (e.winners || []).map((w) => w?.playerId ?? w?.id ?? w).filter(Boolean);
115780
+ const fromStacks = (s?.table?.players || []).filter((p) => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0))).map((p) => p.id);
115781
+ const winnerIds = [.../* @__PURE__ */ new Set([...payloadIds, ...fromStacks])];
115780
115782
  const pot = $(".hud-pot", document);
115781
- winners.forEach((p, i) => setTimeout(() => flyChips(pot, seatEl(p.id), 5, true), i * 130));
115782
- if (winners.length) playTableSound("winner");
115783
+ winnerIds.forEach((playerId, i) => setTimeout(() => flyChips(pot, seatEl(playerId), 5, true), i * 130));
115784
+ if (winnerIds.length) playTableSound("winner");
115783
115785
  }
115784
115786
  if (e.type === "TOURNAMENT_END") playTableSound("winner");
115785
115787
  }
@@ -115978,18 +115980,21 @@ function buildPublicPlayerStats(events, players, currentHand) {
115978
115980
  const completedHandNumbers = new Set((events ?? []).filter((e) => e.type === "HAND_END" && Number(e.handNumber) < Number(currentHand)).map((e) => Number(e.handNumber)));
115979
115981
  const decisions = (events ?? []).filter((e) => e.type === "DECISION" && completedHandNumbers.has(Number(e.handNumber)));
115980
115982
  const handEnds = (events ?? []).filter((e) => e.type === "HAND_END" && completedHandNumbers.has(Number(e.handNumber)));
115983
+ const handStarts = (events ?? []).filter((e) => e.type === "HAND_START" && completedHandNumbers.has(Number(e.handNumber)));
115981
115984
  return (players ?? []).map((player) => {
115982
115985
  const rows = decisions.filter((e) => e.playerId === player.id);
115983
115986
  const preflop = rows.filter((e) => e.street === "PREFLOP");
115984
- const preflopHands = new Set(preflop.map((e) => e.handNumber));
115987
+ const dealtHands = new Set(handStarts.filter((e) => Array.isArray(e.playerIds) && e.playerIds.includes(player.id)).map((e) => Number(e.handNumber)));
115988
+ if (!dealtHands.size) for (const e of rows) dealtHands.add(Number(e.handNumber));
115985
115989
  const vpipHands = new Set(preflop.filter((e) => ["CALL", "BET", "RAISE"].includes(e.action?.type)).map((e) => e.handNumber));
115986
115990
  const pfrHands = new Set(preflop.filter((e) => ["BET", "RAISE"].includes(e.action?.type)).map((e) => e.handNumber));
115987
115991
  const aggressive = rows.filter((e) => ["BET", "RAISE"].includes(e.action?.type)).length;
115988
115992
  const calls = rows.filter((e) => e.action?.type === "CALL").length;
115989
115993
  const checks = rows.filter((e) => e.action?.type === "CHECK").length;
115990
115994
  const folds = rows.filter((e) => e.action?.type === "FOLD").length;
115995
+ const foldOpportunities = rows.filter((e) => (e.legalActions ?? []).some((action2) => action2.type === "FOLD")).length;
115991
115996
  let wins = 0;
115992
- const observedHands = new Set(rows.map((e) => Number(e.handNumber)));
115997
+ const observedHands = new Set(dealtHands);
115993
115998
  for (const end of handEnds) {
115994
115999
  if ((end.winners ?? []).some((w) => (w.playerId ?? w.id ?? w) === player.id)) {
115995
116000
  wins++;
@@ -116001,12 +116006,13 @@ function buildPublicPlayerStats(events, players, currentHand) {
116001
116006
  playerId: player.id,
116002
116007
  playerName: player.name,
116003
116008
  sampleHands: observedHands.size,
116004
- preflopSamples: preflopHands.size,
116009
+ preflopSamples: dealtHands.size,
116005
116010
  decisions: rows.length,
116006
- vpipPct: pct(vpipHands.size, preflopHands.size),
116007
- pfrPct: pct(pfrHands.size, preflopHands.size),
116008
- aggressionPct: pct(aggressive, aggressive + calls + checks),
116009
- foldPct: pct(folds, strategicActions),
116011
+ vpipPct: pct(vpipHands.size, dealtHands.size),
116012
+ pfrPct: pct(pfrHands.size, dealtHands.size),
116013
+ // Standard aggression frequency (AFq), not the aggression-factor ratio.
116014
+ aggressionPct: pct(aggressive, aggressive + calls + folds),
116015
+ foldPct: pct(folds, foldOpportunities),
116010
116016
  callPct: pct(calls, strategicActions),
116011
116017
  checkPct: pct(checks, strategicActions),
116012
116018
  wins
@@ -116080,12 +116086,15 @@ function normalizeConfig(input = {}) {
116080
116086
  parseHeaders(c.headers);
116081
116087
  return { ...c, baseUrl: normalizeBaseUrl(c.baseUrl) };
116082
116088
  });
116089
+ const smallBlind = Math.max(1, Math.round(Number(input.smallBlind) || 25));
116090
+ const bigBlind = Math.max(2, Math.round(Number(input.bigBlind) || 50));
116091
+ if (bigBlind < smallBlind * 2) throw new Error("Big blind must be at least twice the small blind");
116083
116092
  return {
116084
116093
  id: id("tournament"),
116085
116094
  name: "pokertools-arena",
116086
116095
  startingStack: Math.max(100, Math.round(Number(input.startingStack) || 1e4)),
116087
- smallBlind: Math.max(1, Math.round(Number(input.smallBlind) || 25)),
116088
- bigBlind: Math.max(2, Math.round(Number(input.bigBlind) || 50)),
116096
+ smallBlind,
116097
+ bigBlind,
116089
116098
  ante: Math.max(0, Math.round(Number(input.ante) || 0)),
116090
116099
  handsPerLevel: Math.max(1, Math.round(Number(input.handsPerLevel) || 8)),
116091
116100
  blindMultiplier: clamp(Number(input.blindMultiplier) || 1.5, 1.1, 3),
@@ -116104,8 +116113,9 @@ function normalizeConfig(input = {}) {
116104
116113
  players: players.map((raw, index) => {
116105
116114
  const lobbySeat = clamp(Math.round(Number(raw.lobbySeat ?? index)), 0, MAX_LOBBY_SEATS - 1);
116106
116115
  const name = String(raw.name || `Player ${lobbySeat + 1}`).trim().slice(0, 40);
116107
- if (names.has(name)) throw new Error(`Duplicate player name: ${name}`);
116108
- names.add(name);
116116
+ const nameKey = name.toLocaleLowerCase();
116117
+ if (names.has(nameKey)) throw new Error(`Duplicate player name: ${name}`);
116118
+ names.add(nameKey);
116109
116119
  if (!connIds.has(raw.connectionId)) throw new Error(`Connection missing for ${name}`);
116110
116120
  const model = String(raw.model || "").trim();
116111
116121
  if (!model) throw new Error(`Model missing for ${name}`);
@@ -116337,6 +116347,7 @@ var TournamentDirector = class {
116337
116347
  await this.waitIfPaused();
116338
116348
  this.pruneBustedSeats();
116339
116349
  this.handNumber++;
116350
+ for (const stat of Object.values(this.stats)) stat.lastAction = null;
116340
116351
  this.handStartStacks = Object.fromEntries((this.engine?.state?.players ?? []).filter(Boolean).map((p) => [p.id, playerStack(p)]));
116341
116352
  try {
116342
116353
  this.engine.deal();
@@ -116349,7 +116360,14 @@ var TournamentDirector = class {
116349
116360
  }
116350
116361
  this.engine.deal();
116351
116362
  }
116352
- this.logEvent("HAND_START", { handNumber: this.handNumber, buttonSeat: this.engine.state.buttonSeat, smallBlind: this.engine.state.smallBlind, bigBlind: this.engine.state.bigBlind, ante: this.engine.state.ante });
116363
+ this.logEvent("HAND_START", {
116364
+ handNumber: this.handNumber,
116365
+ buttonSeat: this.engine.state.buttonSeat,
116366
+ smallBlind: this.engine.state.smallBlind,
116367
+ bigBlind: this.engine.state.bigBlind,
116368
+ ante: this.engine.state.ante,
116369
+ playerIds: (this.engine.state.players ?? []).filter((p) => p && playerStack(p) > 0).map((p) => p.id)
116370
+ });
116353
116371
  this.broadcast();
116354
116372
  }
116355
116373
  handComplete() {
@@ -116476,17 +116494,13 @@ var TournamentDirector = class {
116476
116494
  stats.retries += Number(result?.meta?.retryCount || 0);
116477
116495
  if (result?.meta?.protocolFallbackTriggered) stats.protocolFallbacks++;
116478
116496
  if (this.currentDecision?.id !== decisionId) throw new Error("Decision became stale");
116479
- const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
116480
- if (elapsedActive > baseMs) this.timeBanks[agent.id] = Math.max(0, bankBefore - (elapsedActive - baseMs));
116481
116497
  } catch (err) {
116482
116498
  elapsed = Math.max(0, Date.now() - startedAt - pausedTotal());
116483
116499
  error = err;
116484
116500
  for (const incident of err?.incidents || []) recordIncident(incident);
116485
116501
  errorCategory = elapsed >= totalMs - 30 || err?.name === "AbortError" ? "timeout" : decisionErrorCategory(err);
116486
- if (errorCategory === "timeout") {
116487
- stats.timeouts++;
116488
- this.timeBanks[agent.id] = 0;
116489
- } else if (errorCategory === "rate_limit") stats.rateLimits++;
116502
+ if (errorCategory === "timeout") stats.timeouts++;
116503
+ else if (errorCategory === "rate_limit") stats.rateLimits++;
116490
116504
  else if (errorCategory === "provider") stats.providerErrors++;
116491
116505
  else {
116492
116506
  stats.modelErrors++;
@@ -116495,6 +116509,8 @@ var TournamentDirector = class {
116495
116509
  }
116496
116510
  if (this.status === "STOPPED") throw new Error("Tournament stopped");
116497
116511
  await this.waitIfPaused();
116512
+ const elapsedActive = Math.max(0, Date.now() - startedAt - pausedTotal());
116513
+ this.timeBanks[agent.id] = Math.max(0, bankBefore - Math.max(0, elapsedActive - baseMs));
116498
116514
  let chosen = result?.action ?? null, forced = false;
116499
116515
  if (chosen && architecture === "hierarchical") {
116500
116516
  const engineAction = isAggressiveType(chosen.type) ? { type: chosen.type, playerId: agent.id, amount: Math.max(1, Math.round(asNumber(chosen.amount))) } : { type: chosen.type, playerId: agent.id };
@@ -117176,7 +117192,7 @@ function renderStatus(s) {
117176
117192
  els.pauseBtn.classList.toggle("hidden", !running);
117177
117193
  els.stopBtn.classList.toggle("hidden", !running);
117178
117194
  els.startTopBtn.classList.toggle("hidden", running);
117179
- els.seatsBtn.classList.toggle("hidden", running);
117195
+ els.seatsBtn.classList.toggle("hidden", running || !["STOPPED", "FINISHED", "ERROR"].includes(status));
117180
117196
  els.setupBtn.disabled = running;
117181
117197
  els.testsBtn.disabled = running;
117182
117198
  const pauseLabel = $(".action-label", els.pauseBtn);
@@ -117509,7 +117525,7 @@ function renderStats(s) {
117509
117525
  const st = s.stats?.[p.id] || {}, poker = publicById.get(p.id) || {}, tableP = s.table?.players?.find?.((x) => x?.id === p.id), avg = st.decisions ? Math.round(st.totalLatencyMs / st.decisions) : 0;
117510
117526
  const pc = (value) => `${Math.round(Number(value || 0) * 100)}%`;
117511
117527
  return `<div class="stat-card"><div class="stat-top"><div><div class="stat-name">${escapeHtml(visiblePlayerName(p.name, p.model))}</div><div class="stat-model mono">${escapeHtml(p.model)} \xB7 ${escapeHtml(effectiveProtocol(p, s.config.connections.find((c) => c.id === p.connectionId)))}</div></div><strong>${fmt(tableP?.stack || 0)}</strong></div>
117512
- <div class="poker-profile"><div><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div><b>${pc(poker.aggressionPct)}</b><span>AGG</span></div><div><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>SAMPLE</span></div></div>
117528
+ <div class="poker-profile"><div title="Voluntarily put chips in pot"><b>${pc(poker.vpipPct)}</b><span>VPIP</span></div><div title="Preflop raise"><b>${pc(poker.pfrPct)}</b><span>PFR</span></div><div title="Aggression frequency"><b>${pc(poker.aggressionPct)}</b><span>AFq</span></div><div title="Fold when folding was legal"><b>${pc(poker.foldPct)}</b><span>FOLD</span></div><div><b>${poker.sampleHands || 0}</b><span>HANDS</span></div></div>
117513
117529
  <div class="stat-values"><div class="metric"><b>${st.decisions || 0}</b><span>moves</span></div><div class="metric"><b>${avg}ms</b><span>avg</span></div><div class="metric"><b>${st.autoFallbacks || 0}</b><span>auto</span></div></div><div class="stat-reliability"><span><b>${st.modelErrors || 0}</b> model</span><span><b>${st.providerErrors || 0}</b> provider</span><span><b>${st.rateLimits || 0}</b> rate</span><span><b>${st.timeouts || 0}</b> timeout</span><span><b>${st.protocolFallbacks || 0}</b> protocol</span><span><b>${st.retries || 0}</b> retry</span></div></div>`;
117514
117530
  }).join("");
117515
117531
  }
@@ -117545,7 +117561,7 @@ function decisionRenderSignature(s) {
117545
117561
  return JSON.stringify([d ? [d.id, d.playerId, d.model, d.protocol, d.provider, d.startedAt, d.baseMs, d.timeBankMs, d.pausedMs || 0, Boolean(d.pausedAt), d.architecture || null, d.stage || null, d.legalActions] : null, last?.id || null, s?.status || "IDLE", seatAssignments.filter(Boolean).length]);
117546
117562
  }
117547
117563
  function feedRenderSignature(s) {
117548
- return (s?.events || []).filter((e) => e.type === "DECISION").slice(-12).map((e) => e.id).join("|");
117564
+ return (s?.events || []).filter((e) => e.type === "DECISION" || e.type === "SPECTATOR_EXPLANATION").slice(-24).map((e) => `${e.type}:${e.id}`).join("|");
117549
117565
  }
117550
117566
  function eventsRenderSignature(s) {
117551
117567
  const ev = s?.events || [];
@@ -117786,6 +117802,7 @@ function setRecordButton(active, label = null) {
117786
117802
  if (icon) icon.textContent = active ? "\u25A0" : "\u25CF";
117787
117803
  if (text) text.textContent = label || (active ? "Stop rec" : "Record");
117788
117804
  els.recordBtn.title = active ? "Stop table recording and save video" : "Record only the poker table";
117805
+ els.recordBtn.setAttribute("aria-label", els.recordBtn.title);
117789
117806
  }
117790
117807
  function saveRecordingBlob(blob) {
117791
117808
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
@@ -118261,6 +118278,8 @@ els.soundBtn.addEventListener("click", async () => {
118261
118278
  if (label) label.textContent = soundEnabled ? "Sound on" : "Sound";
118262
118279
  els.soundBtn.setAttribute("aria-pressed", String(soundEnabled));
118263
118280
  els.soundBtn.classList.toggle("active", soundEnabled);
118281
+ els.soundBtn.title = soundEnabled ? "Disable table sounds" : "Enable table sounds";
118282
+ els.soundBtn.setAttribute("aria-label", els.soundBtn.title);
118264
118283
  if (soundEnabled) {
118265
118284
  try {
118266
118285
  await getAudioContext()?.resume();
@@ -118301,8 +118320,7 @@ els.exportBtn.addEventListener("click", () => {
118301
118320
  });
118302
118321
  els.seatsBtn.addEventListener("click", () => {
118303
118322
  if (director && ["RUNNING", "PAUSED"].includes(director.status)) return;
118304
- lobbyVisible = true;
118305
- render(currentState || { status: "IDLE", events: [] });
118323
+ void startConfiguredTournament();
118306
118324
  });
118307
118325
  els.seatsLayer.addEventListener("click", (event) => {
118308
118326
  const seat = event.target.closest("[data-lobby-seat]");
@@ -118363,6 +118381,7 @@ els.setupForm.addEventListener("submit", (event) => {
118363
118381
  try {
118364
118382
  const raw = collectSetupRaw(true);
118365
118383
  for (const connection of raw.connections) parseHeaders(connection.headers);
118384
+ normalizeConfig(raw);
118366
118385
  saveSetupWithoutSecrets(raw);
118367
118386
  els.setupDialog.close();
118368
118387
  render(currentState || { status: "IDLE", events: [] });
@@ -118403,10 +118422,20 @@ async function startConfiguredTournament() {
118403
118422
  }
118404
118423
  }
118405
118424
  els.startTopBtn.addEventListener("click", startConfiguredTournament);
118406
- $$(".tab").forEach((tab) => tab.addEventListener("click", () => {
118425
+ function activateInspectorTab(tab, { focus = false } = {}) {
118407
118426
  activeInspectorTab = tab.dataset.tab || "live";
118408
- $$(".tab").forEach((t) => t.classList.toggle("active", t === tab));
118409
- $$(".tab-panel").forEach((p) => p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`));
118427
+ $$(".tab").forEach((t) => {
118428
+ const active = t === tab;
118429
+ t.classList.toggle("active", active);
118430
+ t.setAttribute("aria-selected", String(active));
118431
+ t.tabIndex = active ? 0 : -1;
118432
+ });
118433
+ $$(".tab-panel").forEach((p) => {
118434
+ const active = p.id === `tab-${tab.dataset.tab}`;
118435
+ p.classList.toggle("active", active);
118436
+ p.hidden = !active;
118437
+ });
118438
+ if (focus) tab.focus();
118410
118439
  if (!currentState) return;
118411
118440
  if (activeInspectorTab === "live") {
118412
118441
  renderMemo.feed = feedRenderSignature(currentState);
@@ -118418,7 +118447,18 @@ $$(".tab").forEach((tab) => tab.addEventListener("click", () => {
118418
118447
  renderMemo.stats = statsRenderSignature(currentState);
118419
118448
  renderStats(currentState);
118420
118449
  }
118421
- }));
118450
+ }
118451
+ $$(".tab").forEach((tab) => {
118452
+ tab.addEventListener("click", () => activateInspectorTab(tab));
118453
+ tab.addEventListener("keydown", (event) => {
118454
+ if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
118455
+ event.preventDefault();
118456
+ const tabs = $$(".tab");
118457
+ const current = tabs.indexOf(tab);
118458
+ const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
118459
+ activateInspectorTab(tabs[next], { focus: true });
118460
+ });
118461
+ });
118422
118462
  window.addEventListener("beforeunload", (event) => {
118423
118463
  if (tableRecording) tableRecording.stream?.getTracks?.().forEach((t) => t.stop());
118424
118464
  if (director && ["RUNNING", "PAUSED"].includes(director.status)) {
package/dist/index.html CHANGED
@@ -64,17 +64,14 @@
64
64
  <div class="brand-title">pokertools-arena</div>
65
65
  </div>
66
66
  </div>
67
+ <a id="repoLink" class="repo-link" href="https://github.com/pokertools-arena/pokertools-arena.github.io" target="_blank" rel="noopener noreferrer" title="View source on GitHub" aria-label="View source on GitHub"><svg class="repo-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg></a>
67
68
  <div class="topbar-actions">
68
69
  <button id="startTopBtn" class="button primary top-action" type="button" title="Start tournament"><span class="action-icon">▶</span><span class="action-label">Start</span></button>
69
- <button id="seatsBtn" class="button ghost top-action" type="button" title="Edit seats"><span class="action-icon">♟</span><span class="action-label">Seats</span></button>
70
- <button id="setupBtn" class="button ghost top-action" type="button" title="Connections and tournament settings"><span class="action-icon">⚙</span><span class="action-label">Settings</span></button>
71
- <button id="testsBtn" class="button ghost top-action" type="button" title="Run the 10-spot decision sanity suite"><span class="action-icon">◇</span><span class="action-label">Tests</span></button>
72
- <button id="soundBtn" class="button ghost top-action sound-button" type="button" aria-pressed="false" title="Enable table sounds"><span class="action-icon">♪</span><span class="action-label">Sound</span></button>
73
- <button id="recordBtn" class="button ghost top-action record-button" type="button" aria-pressed="false" title="Record only the poker table"><span class="action-icon">●</span><span class="action-label">Record</span></button>
74
- <button id="exportBtn" class="button ghost top-action" type="button" disabled title="Export tournament log"><span class="action-icon">↓</span><span class="action-label">Log</span></button>
70
+ <button id="seatsBtn" class="button ghost top-action hidden" type="button" title="Restart with a new tournament"><span class="action-icon">↻</span><span class="action-label">Restart</span></button>
75
71
  <button id="pauseBtn" class="button ghost top-action hidden" type="button"><span class="action-icon">Ⅱ</span><span class="action-label">Pause</span></button>
76
72
  <button id="stopBtn" class="button danger top-action hidden" type="button"><span class="action-icon">■</span><span class="action-label">Stop</span></button>
77
- <a id="repoLink" class="button ghost top-action repo-action" href="https://github.com/pokertools-arena/pokertools-arena.github.io" target="_blank" rel="noopener noreferrer" title="View source on GitHub" aria-label="View source on GitHub"><svg class="action-icon repo-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg><span class="action-label">GitHub</span></a>
73
+ <button id="testsBtn" class="button ghost top-action" type="button" title="Run the 10-spot decision sanity suite"><span class="action-icon">◇</span><span class="action-label">Tests</span></button>
74
+ <button id="setupBtn" class="button ghost top-action icon-only-action" type="button" title="Connections and tournament settings" aria-label="Settings"><span class="action-icon">⚙</span><span class="action-label">Settings</span></button>
78
75
  </div>
79
76
  </header>
80
77
 
@@ -83,6 +80,10 @@
83
80
  <div class="arena-head">
84
81
  <div class="arena-status"><span id="statusDot" class="status-dot"></span><span id="statusLabel">IDLE</span></div>
85
82
  <div id="tournamentMeta" class="mono muted">Seat 2–10 models, then press Start</div>
83
+ <div class="arena-head-actions">
84
+ <button id="soundBtn" class="arena-icon-button sound-button" type="button" aria-pressed="false" title="Enable table sounds" aria-label="Enable table sounds"><span class="action-icon">♪</span></button>
85
+ <button id="recordBtn" class="arena-icon-button record-button" type="button" aria-pressed="false" title="Record only the poker table" aria-label="Record only the poker table"><span class="action-icon">●</span></button>
86
+ </div>
86
87
  </div>
87
88
 
88
89
  <div id="winnerBanner" class="winner-banner hidden" role="status" aria-live="polite"></div>
@@ -109,13 +110,13 @@
109
110
  </section>
110
111
 
111
112
  <aside class="inspector">
112
- <div class="tabs">
113
- <button class="tab active" data-tab="live">Live</button>
114
- <button class="tab" data-tab="log">Log</button>
115
- <button class="tab" data-tab="stats">Stats</button>
113
+ <div class="tabs" role="tablist" aria-label="Tournament inspector">
114
+ <button id="tabButton-live" class="tab active" data-tab="live" type="button" role="tab" aria-selected="true" aria-controls="tab-live">Live</button>
115
+ <button id="tabButton-log" class="tab" data-tab="log" type="button" role="tab" aria-selected="false" aria-controls="tab-log" tabindex="-1">Log</button>
116
+ <button id="tabButton-stats" class="tab" data-tab="stats" type="button" role="tab" aria-selected="false" aria-controls="tab-stats" tabindex="-1">Stats</button>
116
117
  </div>
117
118
 
118
- <div id="tab-live" class="tab-panel active">
119
+ <div id="tab-live" class="tab-panel active" role="tabpanel" aria-labelledby="tabButton-live">
119
120
  <section class="panel-block decision-panel">
120
121
  <div id="decisionPanelTitle" class="panel-title">Current decision</div>
121
122
  <div id="decisionEmpty" class="empty-state">Configure seats on the table. During play, the active model, clock, and legal actions appear here.</div>
@@ -137,8 +138,13 @@
137
138
  <section class="panel-block"><div class="panel-title">Recent decisions</div><div id="decisionFeed" class="decision-feed"></div></section>
138
139
  </div>
139
140
 
140
- <div id="tab-log" class="tab-panel"><section class="panel-block grow"><div class="panel-title">Tournament events</div><div id="eventLog" class="event-log mono"></div></section></div>
141
- <div id="tab-stats" class="tab-panel"><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
141
+ <div id="tab-log" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-log" hidden>
142
+ <section class="panel-block grow">
143
+ <div class="panel-title-row"><div class="panel-title">Tournament events</div><button id="exportBtn" class="button ghost small-button" type="button" disabled title="Download the tournament log as JSONL"><span class="action-icon">↓</span><span class="action-label">Download</span></button></div>
144
+ <div id="eventLog" class="event-log mono" aria-label="Tournament event log"></div>
145
+ </section>
146
+ </div>
147
+ <div id="tab-stats" class="tab-panel" role="tabpanel" aria-labelledby="tabButton-stats" hidden><section class="panel-block grow"><div class="panel-title">Models</div><div id="statsGrid" class="stats-grid"></div></section></div>
142
148
  </aside>
143
149
  </main>
144
150