pokertools-arena 0.4.4 → 0.4.8

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.4.** 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, and decision logs no longer repeat the typed-decision caption.
20
+ > **Current release: 0.4.8.** 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, and the header, table chrome and small-screen board cards are more compact.
21
21
 
22
22
  ---
23
23
 
package/dist/app.js CHANGED
@@ -115700,6 +115700,16 @@ function showActionToast(text, type = "") {
115700
115700
  function seatEl(playerId) {
115701
115701
  return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer);
115702
115702
  }
115703
+ function stopTableEffects() {
115704
+ clearTimeout(showActionToast.timer);
115705
+ els.actionToast?.classList.add("hidden");
115706
+ els.pokerTable?.classList.remove("action-message-visible");
115707
+ els.fxLayer?.replaceChildren();
115708
+ for (const el of $$(".seat.fold-flash, .seat.check-flash")) {
115709
+ el.classList.remove("fold-flash");
115710
+ el.classList.remove("check-flash");
115711
+ }
115712
+ }
115703
115713
  function animateNewHand(s) {
115704
115714
  if (!animationsAllowed()) return;
115705
115715
  requestAnimationFrame(() => {
@@ -115727,12 +115737,18 @@ function animateBoardCards(previousCount, currentCount) {
115727
115737
  }
115728
115738
  function processVisualEffects(s) {
115729
115739
  const events = s?.events || [];
115740
+ const previous = lastVisualState;
115741
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
115742
+ if (events.length) lastProcessedEventId = events.at(-1).id;
115743
+ lastVisualState = s;
115744
+ stopTableEffects();
115745
+ return;
115746
+ }
115730
115747
  if (!animationsAllowed()) {
115731
115748
  if (events.length) lastProcessedEventId = events.at(-1).id;
115732
115749
  lastVisualState = s;
115733
115750
  return;
115734
115751
  }
115735
- const previous = lastVisualState;
115736
115752
  if (s?.table && (!previous?.table || s.table.handNumber !== previous.table.handNumber)) animateNewHand(s);
115737
115753
  const prevBoard = previous?.table?.board?.length || 0, nextBoard = s?.table?.board?.length || 0;
115738
115754
  if (s?.table?.handNumber === previous?.table?.handNumber) animateBoardCards(prevBoard, nextBoard);
@@ -115758,12 +115774,14 @@ function processVisualEffects(s) {
115758
115774
  }
115759
115775
  showActionToast(`${displayModelName(e.configuredModel || e.resolvedModel || e.playerName)} \xB7 ${e.action?.description || type}`, type);
115760
115776
  }
115761
- if (e.type === "HAND_END" && previous?.table) {
115762
- const before = new Map((previous.table.players || []).filter(Boolean).map((p) => [p.id, Number(p.stack || 0)]));
115763
- 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])];
115764
115782
  const pot = $(".hud-pot", document);
115765
- winners.forEach((p, i) => setTimeout(() => flyChips(pot, seatEl(p.id), 5, true), i * 130));
115766
- 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");
115767
115785
  }
115768
115786
  if (e.type === "TOURNAMENT_END") playTableSound("winner");
115769
115787
  }
@@ -116321,6 +116339,7 @@ var TournamentDirector = class {
116321
116339
  await this.waitIfPaused();
116322
116340
  this.pruneBustedSeats();
116323
116341
  this.handNumber++;
116342
+ for (const stat of Object.values(this.stats)) stat.lastAction = null;
116324
116343
  this.handStartStacks = Object.fromEntries((this.engine?.state?.players ?? []).filter(Boolean).map((p) => [p.id, playerStack(p)]));
116325
116344
  try {
116326
116345
  this.engine.deal();
@@ -117160,7 +117179,7 @@ function renderStatus(s) {
117160
117179
  els.pauseBtn.classList.toggle("hidden", !running);
117161
117180
  els.stopBtn.classList.toggle("hidden", !running);
117162
117181
  els.startTopBtn.classList.toggle("hidden", running);
117163
- els.seatsBtn.classList.toggle("hidden", running);
117182
+ els.seatsBtn.classList.toggle("hidden", running || !["STOPPED", "FINISHED", "ERROR"].includes(status));
117164
117183
  els.setupBtn.disabled = running;
117165
117184
  els.testsBtn.disabled = running;
117166
117185
  const pauseLabel = $(".action-label", els.pauseBtn);
@@ -117353,6 +117372,10 @@ function championBadgeHtml() {
117353
117372
  return '<span class="champion-badge" role="img" aria-label="Tournament champion"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6.6 2.6h10.8v4.7a5.4 5.4 0 0 1-10.8 0V2.6Z"/><path d="M10.7 12.4h2.6V15h-2.6z"/><path d="M7.2 15h9.6v2.1H7.2z"/><path d="M5.6 18.2h12.8v2.3H5.6z"/></svg></span>';
117354
117373
  }
117355
117374
  function renderDecision(s) {
117375
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
117376
+ stopClock();
117377
+ return;
117378
+ }
117356
117379
  const d = s?.currentDecision;
117357
117380
  const last = latestDecisionEvent(s);
117358
117381
  const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
@@ -118269,15 +118292,19 @@ els.pauseBtn.addEventListener("click", () => {
118269
118292
  if (!director) return;
118270
118293
  currentState?.status === "PAUSED" ? director.resume() : director.pause();
118271
118294
  });
118272
- els.stopBtn.addEventListener("click", () => director?.stop());
118295
+ els.stopBtn.addEventListener("click", () => {
118296
+ if (!director) return;
118297
+ stopClock();
118298
+ stopTableEffects();
118299
+ director.stop();
118300
+ });
118273
118301
  els.exportBtn.addEventListener("click", () => {
118274
118302
  if (!director?.events?.length) return;
118275
118303
  downloadText(`${director.config?.id || "pokertools-arena"}.jsonl`, director.exportJsonl());
118276
118304
  });
118277
118305
  els.seatsBtn.addEventListener("click", () => {
118278
118306
  if (director && ["RUNNING", "PAUSED"].includes(director.status)) return;
118279
- lobbyVisible = true;
118280
- render(currentState || { status: "IDLE", events: [] });
118307
+ void startConfiguredTournament();
118281
118308
  });
118282
118309
  els.seatsLayer.addEventListener("click", (event) => {
118283
118310
  const seat = event.target.closest("[data-lobby-seat]");
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>
@@ -137,7 +138,12 @@
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-log" class="tab-panel">
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"></div>
145
+ </section>
146
+ </div>
141
147
  <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>
142
148
  </aside>
143
149
  </main>
@@ -2344,6 +2344,67 @@ a.button{text-decoration:none}
2344
2344
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2345
2345
  Browser application source moved under src/; visual behavior is unchanged. */
2346
2346
 
2347
+ /* 0.4.8 — compact header, table-card parity, quieter chrome.
2348
+
2349
+ Header: brand left, a backgroundless GitHub mark, then actions right. The
2350
+ board uses the same rank-plus-center-suit card language as the hole cards on
2351
+ small screens instead of the crowded duplicated corners. */
2352
+ .topbar{flex-wrap:nowrap}
2353
+ .brandbar{flex:0 1 auto}
2354
+ .repo-link{display:inline-grid;place-items:center;width:26px;height:26px;margin-left:6px;border:0;border-radius:7px;background:none;color:var(--muted);flex:0 0 auto;transition:color .15s ease,opacity .15s ease}
2355
+ .repo-link .repo-icon{width:17px;height:17px;opacity:.9}
2356
+ .repo-link:hover{color:var(--text)}
2357
+ .repo-link:hover .repo-icon{opacity:1}
2358
+ .topbar-actions{margin-left:auto;flex:0 1 auto}
2359
+ /* Settings is an icon-only control: no label, no wasted width. */
2360
+ .icon-only-action{padding:0;width:30px;min-width:30px;justify-content:center}
2361
+ .icon-only-action .action-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
2362
+
2363
+ /* Sound and recording live with the table status they affect. */
2364
+ .arena-head{gap:10px}
2365
+ .arena-head-actions{display:flex;align-items:center;gap:4px;flex:0 0 auto;margin-left:auto}
2366
+ .arena-icon-button{display:inline-grid;place-items:center;width:24px;height:24px;min-width:24px;padding:0;border:1px solid var(--modal-border,rgba(232,238,243,.09));border-radius:7px;background:rgba(255,255,255,.02);color:#8d98a2;font-size:10px;cursor:pointer;transition:color .14s ease,border-color .14s ease,background .14s ease}
2367
+ .arena-icon-button:hover{color:#d8e0e5;border-color:rgba(146,201,164,.3)}
2368
+ .arena-icon-button.active{color:var(--accent);border-color:rgba(114,185,138,.35);background:rgba(114,185,138,.08)}
2369
+ .arena-icon-button.record-button.active{color:var(--danger);border-color:rgba(200,94,105,.4);background:rgba(200,94,105,.1)}
2370
+ .arena-icon-button:disabled{opacity:.42;cursor:not-allowed}
2371
+ .arena-head-actions .action-icon{font-size:10px;opacity:1}
2372
+
2373
+ /* Download lives inside the Log tab it produces. */
2374
+ .panel-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:11px}
2375
+ .panel-title-row .panel-title{margin-bottom:0}
2376
+ .panel-title-row .button{flex:0 0 auto}
2377
+
2378
+ /* The bank clock leads the right-hand clock block instead of trailing it. */
2379
+ .clock-wrap{align-items:flex-start;text-align:left}
2380
+ #bankClock{order:-1;color:var(--muted);font-size:9px;line-height:1.2}
2381
+
2382
+ /* Board cards: rank + big centre suit, no duplicated corners. This is the
2383
+ default at small widths and every compact/tight/micro density, so table and
2384
+ hole cards read the same way. */
2385
+ @media(max-width:720px){
2386
+ .board .card .card-corner.bottom,
2387
+ .poker-table[data-density="compact"] .board .card .card-corner.bottom,
2388
+ .poker-table[data-density="tight"] .board .card .card-corner.bottom{display:none!important}
2389
+ .board .card .card-corner i,
2390
+ .poker-table[data-density="compact"] .board .card .card-corner i,
2391
+ .poker-table[data-density="tight"] .board .card .card-corner i{display:none!important}
2392
+ .board .card .card-corner,
2393
+ .poker-table[data-density="compact"] .board .card .card-corner,
2394
+ .poker-table[data-density="tight"] .board .card .card-corner{left:3px!important;top:3px!important;line-height:1!important}
2395
+ .board .card .card-corner b,
2396
+ .poker-table[data-density="compact"] .board .card .card-corner b,
2397
+ .poker-table[data-density="tight"] .board .card .card-corner b{font-size:10px!important}
2398
+ .board .card .card-suit,
2399
+ .poker-table[data-density="compact"] .board .card .card-suit,
2400
+ .poker-table[data-density="tight"] .board .card .card-suit{font-size:17px!important}
2401
+ }
2402
+ .poker-table[data-density="tight"] .board .card .card-suit{font-size:16px!important}
2403
+ .poker-table[data-density="micro"] .board .card .card-corner i,
2404
+ .poker-table[data-density="micro"] .board .card .card-corner.bottom{display:none!important}
2405
+ .poker-table[data-density="micro"] .board .card .card-suit{font-size:13px!important}
2406
+
2407
+
2347
2408
  </style>
2348
2409
  </head>
2349
2410
  <body>
@@ -2355,17 +2416,14 @@ a.button{text-decoration:none}
2355
2416
  <div class="brand-title">pokertools-arena</div>
2356
2417
  </div>
2357
2418
  </div>
2419
+ <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>
2358
2420
  <div class="topbar-actions">
2359
2421
  <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>
2360
- <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>
2361
- <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>
2362
- <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>
2363
- <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>
2364
- <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>
2365
- <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>
2422
+ <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>
2366
2423
  <button id="pauseBtn" class="button ghost top-action hidden" type="button"><span class="action-icon">Ⅱ</span><span class="action-label">Pause</span></button>
2367
2424
  <button id="stopBtn" class="button danger top-action hidden" type="button"><span class="action-icon">■</span><span class="action-label">Stop</span></button>
2368
- <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>
2425
+ <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>
2426
+ <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>
2369
2427
  </div>
2370
2428
  </header>
2371
2429
 
@@ -2374,6 +2432,10 @@ a.button{text-decoration:none}
2374
2432
  <div class="arena-head">
2375
2433
  <div class="arena-status"><span id="statusDot" class="status-dot"></span><span id="statusLabel">IDLE</span></div>
2376
2434
  <div id="tournamentMeta" class="mono muted">Seat 2–10 models, then press Start</div>
2435
+ <div class="arena-head-actions">
2436
+ <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>
2437
+ <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>
2438
+ </div>
2377
2439
  </div>
2378
2440
 
2379
2441
  <div id="winnerBanner" class="winner-banner hidden" role="status" aria-live="polite"></div>
@@ -2428,7 +2490,12 @@ a.button{text-decoration:none}
2428
2490
  <section class="panel-block"><div class="panel-title">Recent decisions</div><div id="decisionFeed" class="decision-feed"></div></section>
2429
2491
  </div>
2430
2492
 
2431
- <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>
2493
+ <div id="tab-log" class="tab-panel">
2494
+ <section class="panel-block grow">
2495
+ <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>
2496
+ <div id="eventLog" class="event-log mono"></div>
2497
+ </section>
2498
+ </div>
2432
2499
  <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>
2433
2500
  </aside>
2434
2501
  </main>
@@ -118375,6 +118442,16 @@ function showActionToast(text, type = "") {
118375
118442
  function seatEl(playerId) {
118376
118443
  return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer);
118377
118444
  }
118445
+ function stopTableEffects() {
118446
+ clearTimeout(showActionToast.timer);
118447
+ els.actionToast?.classList.add("hidden");
118448
+ els.pokerTable?.classList.remove("action-message-visible");
118449
+ els.fxLayer?.replaceChildren();
118450
+ for (const el of $$(".seat.fold-flash, .seat.check-flash")) {
118451
+ el.classList.remove("fold-flash");
118452
+ el.classList.remove("check-flash");
118453
+ }
118454
+ }
118378
118455
  function animateNewHand(s) {
118379
118456
  if (!animationsAllowed()) return;
118380
118457
  requestAnimationFrame(() => {
@@ -118402,12 +118479,18 @@ function animateBoardCards(previousCount, currentCount) {
118402
118479
  }
118403
118480
  function processVisualEffects(s) {
118404
118481
  const events = s?.events || [];
118482
+ const previous = lastVisualState;
118483
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
118484
+ if (events.length) lastProcessedEventId = events.at(-1).id;
118485
+ lastVisualState = s;
118486
+ stopTableEffects();
118487
+ return;
118488
+ }
118405
118489
  if (!animationsAllowed()) {
118406
118490
  if (events.length) lastProcessedEventId = events.at(-1).id;
118407
118491
  lastVisualState = s;
118408
118492
  return;
118409
118493
  }
118410
- const previous = lastVisualState;
118411
118494
  if (s?.table && (!previous?.table || s.table.handNumber !== previous.table.handNumber)) animateNewHand(s);
118412
118495
  const prevBoard = previous?.table?.board?.length || 0, nextBoard = s?.table?.board?.length || 0;
118413
118496
  if (s?.table?.handNumber === previous?.table?.handNumber) animateBoardCards(prevBoard, nextBoard);
@@ -118433,12 +118516,14 @@ function processVisualEffects(s) {
118433
118516
  }
118434
118517
  showActionToast(`${displayModelName(e.configuredModel || e.resolvedModel || e.playerName)} \xB7 ${e.action?.description || type}`, type);
118435
118518
  }
118436
- if (e.type === "HAND_END" && previous?.table) {
118437
- const before = new Map((previous.table.players || []).filter(Boolean).map((p) => [p.id, Number(p.stack || 0)]));
118438
- const winners = (s?.table?.players || []).filter((p) => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0)));
118519
+ if (e.type === "HAND_END") {
118520
+ const before = new Map((previous?.table?.players || []).filter(Boolean).map((p) => [p.id, Number(p.stack || 0)]));
118521
+ const payloadIds = (e.winners || []).map((w) => w?.playerId ?? w?.id ?? w).filter(Boolean);
118522
+ const fromStacks = (s?.table?.players || []).filter((p) => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0))).map((p) => p.id);
118523
+ const winnerIds = [.../* @__PURE__ */ new Set([...payloadIds, ...fromStacks])];
118439
118524
  const pot = $(".hud-pot", document);
118440
- winners.forEach((p, i) => setTimeout(() => flyChips(pot, seatEl(p.id), 5, true), i * 130));
118441
- if (winners.length) playTableSound("winner");
118525
+ winnerIds.forEach((playerId, i) => setTimeout(() => flyChips(pot, seatEl(playerId), 5, true), i * 130));
118526
+ if (winnerIds.length) playTableSound("winner");
118442
118527
  }
118443
118528
  if (e.type === "TOURNAMENT_END") playTableSound("winner");
118444
118529
  }
@@ -118996,6 +119081,7 @@ var TournamentDirector = class {
118996
119081
  await this.waitIfPaused();
118997
119082
  this.pruneBustedSeats();
118998
119083
  this.handNumber++;
119084
+ for (const stat of Object.values(this.stats)) stat.lastAction = null;
118999
119085
  this.handStartStacks = Object.fromEntries((this.engine?.state?.players ?? []).filter(Boolean).map((p) => [p.id, playerStack(p)]));
119000
119086
  try {
119001
119087
  this.engine.deal();
@@ -119835,7 +119921,7 @@ function renderStatus(s) {
119835
119921
  els.pauseBtn.classList.toggle("hidden", !running);
119836
119922
  els.stopBtn.classList.toggle("hidden", !running);
119837
119923
  els.startTopBtn.classList.toggle("hidden", running);
119838
- els.seatsBtn.classList.toggle("hidden", running);
119924
+ els.seatsBtn.classList.toggle("hidden", running || !["STOPPED", "FINISHED", "ERROR"].includes(status));
119839
119925
  els.setupBtn.disabled = running;
119840
119926
  els.testsBtn.disabled = running;
119841
119927
  const pauseLabel = $(".action-label", els.pauseBtn);
@@ -120028,6 +120114,10 @@ function championBadgeHtml() {
120028
120114
  return '<span class="champion-badge" role="img" aria-label="Tournament champion"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6.6 2.6h10.8v4.7a5.4 5.4 0 0 1-10.8 0V2.6Z"/><path d="M10.7 12.4h2.6V15h-2.6z"/><path d="M7.2 15h9.6v2.1H7.2z"/><path d="M5.6 18.2h12.8v2.3H5.6z"/></svg></span>';
120029
120115
  }
120030
120116
  function renderDecision(s) {
120117
+ if (["STOPPED", "ERROR"].includes(s?.status)) {
120118
+ stopClock();
120119
+ return;
120120
+ }
120031
120121
  const d = s?.currentDecision;
120032
120122
  const last = latestDecisionEvent(s);
120033
120123
  const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
@@ -120944,15 +121034,19 @@ els.pauseBtn.addEventListener("click", () => {
120944
121034
  if (!director) return;
120945
121035
  currentState?.status === "PAUSED" ? director.resume() : director.pause();
120946
121036
  });
120947
- els.stopBtn.addEventListener("click", () => director?.stop());
121037
+ els.stopBtn.addEventListener("click", () => {
121038
+ if (!director) return;
121039
+ stopClock();
121040
+ stopTableEffects();
121041
+ director.stop();
121042
+ });
120948
121043
  els.exportBtn.addEventListener("click", () => {
120949
121044
  if (!director?.events?.length) return;
120950
121045
  downloadText(`${director.config?.id || "pokertools-arena"}.jsonl`, director.exportJsonl());
120951
121046
  });
120952
121047
  els.seatsBtn.addEventListener("click", () => {
120953
121048
  if (director && ["RUNNING", "PAUSED"].includes(director.status)) return;
120954
- lobbyVisible = true;
120955
- render(currentState || { status: "IDLE", events: [] });
121049
+ void startConfiguredTournament();
120956
121050
  });
120957
121051
  els.seatsLayer.addEventListener("click", (event) => {
120958
121052
  const seat = event.target.closest("[data-lobby-seat]");
package/dist/styles.css CHANGED
@@ -2287,3 +2287,64 @@ a.button{text-decoration:none}
2287
2287
 
2288
2288
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2289
2289
  Browser application source moved under src/; visual behavior is unchanged. */
2290
+
2291
+ /* 0.4.8 — compact header, table-card parity, quieter chrome.
2292
+
2293
+ Header: brand left, a backgroundless GitHub mark, then actions right. The
2294
+ board uses the same rank-plus-center-suit card language as the hole cards on
2295
+ small screens instead of the crowded duplicated corners. */
2296
+ .topbar{flex-wrap:nowrap}
2297
+ .brandbar{flex:0 1 auto}
2298
+ .repo-link{display:inline-grid;place-items:center;width:26px;height:26px;margin-left:6px;border:0;border-radius:7px;background:none;color:var(--muted);flex:0 0 auto;transition:color .15s ease,opacity .15s ease}
2299
+ .repo-link .repo-icon{width:17px;height:17px;opacity:.9}
2300
+ .repo-link:hover{color:var(--text)}
2301
+ .repo-link:hover .repo-icon{opacity:1}
2302
+ .topbar-actions{margin-left:auto;flex:0 1 auto}
2303
+ /* Settings is an icon-only control: no label, no wasted width. */
2304
+ .icon-only-action{padding:0;width:30px;min-width:30px;justify-content:center}
2305
+ .icon-only-action .action-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
2306
+
2307
+ /* Sound and recording live with the table status they affect. */
2308
+ .arena-head{gap:10px}
2309
+ .arena-head-actions{display:flex;align-items:center;gap:4px;flex:0 0 auto;margin-left:auto}
2310
+ .arena-icon-button{display:inline-grid;place-items:center;width:24px;height:24px;min-width:24px;padding:0;border:1px solid var(--modal-border,rgba(232,238,243,.09));border-radius:7px;background:rgba(255,255,255,.02);color:#8d98a2;font-size:10px;cursor:pointer;transition:color .14s ease,border-color .14s ease,background .14s ease}
2311
+ .arena-icon-button:hover{color:#d8e0e5;border-color:rgba(146,201,164,.3)}
2312
+ .arena-icon-button.active{color:var(--accent);border-color:rgba(114,185,138,.35);background:rgba(114,185,138,.08)}
2313
+ .arena-icon-button.record-button.active{color:var(--danger);border-color:rgba(200,94,105,.4);background:rgba(200,94,105,.1)}
2314
+ .arena-icon-button:disabled{opacity:.42;cursor:not-allowed}
2315
+ .arena-head-actions .action-icon{font-size:10px;opacity:1}
2316
+
2317
+ /* Download lives inside the Log tab it produces. */
2318
+ .panel-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:11px}
2319
+ .panel-title-row .panel-title{margin-bottom:0}
2320
+ .panel-title-row .button{flex:0 0 auto}
2321
+
2322
+ /* The bank clock leads the right-hand clock block instead of trailing it. */
2323
+ .clock-wrap{align-items:flex-start;text-align:left}
2324
+ #bankClock{order:-1;color:var(--muted);font-size:9px;line-height:1.2}
2325
+
2326
+ /* Board cards: rank + big centre suit, no duplicated corners. This is the
2327
+ default at small widths and every compact/tight/micro density, so table and
2328
+ hole cards read the same way. */
2329
+ @media(max-width:720px){
2330
+ .board .card .card-corner.bottom,
2331
+ .poker-table[data-density="compact"] .board .card .card-corner.bottom,
2332
+ .poker-table[data-density="tight"] .board .card .card-corner.bottom{display:none!important}
2333
+ .board .card .card-corner i,
2334
+ .poker-table[data-density="compact"] .board .card .card-corner i,
2335
+ .poker-table[data-density="tight"] .board .card .card-corner i{display:none!important}
2336
+ .board .card .card-corner,
2337
+ .poker-table[data-density="compact"] .board .card .card-corner,
2338
+ .poker-table[data-density="tight"] .board .card .card-corner{left:3px!important;top:3px!important;line-height:1!important}
2339
+ .board .card .card-corner b,
2340
+ .poker-table[data-density="compact"] .board .card .card-corner b,
2341
+ .poker-table[data-density="tight"] .board .card .card-corner b{font-size:10px!important}
2342
+ .board .card .card-suit,
2343
+ .poker-table[data-density="compact"] .board .card .card-suit,
2344
+ .poker-table[data-density="tight"] .board .card .card-suit{font-size:17px!important}
2345
+ }
2346
+ .poker-table[data-density="tight"] .board .card .card-suit{font-size:16px!important}
2347
+ .poker-table[data-density="micro"] .board .card .card-corner i,
2348
+ .poker-table[data-density="micro"] .board .card .card-corner.bottom{display:none!important}
2349
+ .poker-table[data-density="micro"] .board .card .card-suit{font-size:13px!important}
2350
+
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.4.4 — benchmark methodology & results
1
+ # pokertools-arena 0.4.8 — benchmark methodology & results
2
2
 
3
3
  Generated: 2026-09-19T18:19:58.352Z
4
4
  Methodology: paired fixed-state corpus; deterministic interleaving with recorded experiment seed.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.4.4",
3
+ "version": "0.4.8",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,46 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.4.8 — Maintenance
4
+
5
+ - **Compact header.** Left: the logo, `pokertools-arena`, and a backgroundless
6
+ GitHub mark. Right: Start / Restart / Pause / Stop, Tests, and an icon-only
7
+ Settings control. The `♟ Seats` button became **Restart**, shown only after a
8
+ run has started and stopped or finished.
9
+ - **Sound and recording** moved into the table header (`arena-head`) beside the
10
+ status they affect; **log download** moved into the `Log` tab that produces it.
11
+ - **Bank clock** now leads the right-hand clock block instead of trailing it.
12
+ - **Board cards on small screens** use the same rank-plus-center-suit language as
13
+ the hole cards: the duplicated bottom-right corner and inline suit glyph are
14
+ dropped at narrow widths and at compact/tight/micro densities.
15
+
16
+ ## 0.4.7 — Maintenance
17
+
18
+ - **Pot pays out to the winner.** The pot→winner chip animation never fired.
19
+ `processVisualEffects` read `lastVisualState` *after* `animateNewHand` and
20
+ `animateBoardCards` had already advanced it, so at a hand boundary the
21
+ `HAND_END` stack diff compared a state against itself and matched no winners.
22
+ The previous state is now snapshotted before any helper runs, and the payout
23
+ is driven by the `HAND_END` winner payload (with the stack diff as fallback),
24
+ so chips fly from the pot to every winner and the win sound plays.
25
+
26
+ ## 0.4.6 — Maintenance
27
+
28
+ - **Seat cards clear their last action each hand.** `stats.lastAction` was only
29
+ ever written, never reset, so every player card kept showing the previous
30
+ hand's action into the next deal. `startHand` now clears the last action for
31
+ every seat. Eliminations still display, because they are derived from the
32
+ elimination list at render time rather than stored as `lastAction`.
33
+
34
+ ## 0.4.5 — Maintenance
35
+
36
+ - **Stop now freezes the display.** Stopping a tournament clears the decision
37
+ clock and cancels every table effect still in flight (chip flies, action
38
+ toast, fold/check flashes and their pending timers). Previously the turn ring
39
+ and clock kept counting and queued animations finished after the run had
40
+ stopped, because the last broadcast still carried `currentDecision` and the
41
+ effects queue was never drained. `renderDecision` and `processVisualEffects`
42
+ now hard-stop on `STOPPED`/`ERROR`.
43
+
3
44
  ## 0.4.4 — Maintenance
4
45
 
5
46
  - **Cleaner decision logs.** Removed the repeated spectator caption ("This
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.4.4",
3
+ "version": "0.4.8",
4
4
  "description": "Browser-first AI poker benchmark powered by @pokertools/engine with generic OpenAI-compatible endpoints, optional OpenRouter/Jev Decisions routing, deterministic hierarchical decisions, paired fixed-state methodology, and reproducible reporting.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -142,6 +142,17 @@ function showActionToast(text, type = '') {
142
142
  }, 1550);
143
143
  }
144
144
  function seatEl(playerId) { return $(`.seat[data-player-id="${CSS.escape(String(playerId))}"]`, els.seatsLayer); }
145
+ // Cancel every effect the table may still have in flight: chip flies, toast,
146
+ // fold/check flashes and their pending timeouts. Called when a run stops so the
147
+ // display freezes instead of finishing queued animations.
148
+ function stopTableEffects() {
149
+ clearTimeout(showActionToast.timer);
150
+ els.actionToast?.classList.add('hidden');
151
+ els.pokerTable?.classList.remove('action-message-visible');
152
+ els.fxLayer?.replaceChildren();
153
+ for (const el of $$('.seat.fold-flash, .seat.check-flash'))
154
+ { el.classList.remove('fold-flash'); el.classList.remove('check-flash'); }
155
+ }
145
156
  function animateNewHand(s) {
146
157
  if (!animationsAllowed()) return;
147
158
  requestAnimationFrame(() => {
@@ -169,12 +180,25 @@ function animateBoardCards(previousCount, currentCount) {
169
180
  }
170
181
  function processVisualEffects(s) {
171
182
  const events = s?.events || [];
183
+ // Snapshot the previous state BEFORE any helper runs. animateNewHand and
184
+ // animateBoardCards advance lastVisualState internally, so reading it later
185
+ // made the HAND_END winner diff compare a state against itself and the
186
+ // pot→winner payout animation never fired.
187
+ const previous = lastVisualState;
188
+ // Once a run is stopped (or errored) no further effects may be scheduled.
189
+ // Without this, already-live chip flies, flashes and sounds continued after
190
+ // Stop, and an in-flight broadcast could replay the last decision's effects.
191
+ if (['STOPPED', 'ERROR'].includes(s?.status)) {
192
+ if (events.length) lastProcessedEventId = events.at(-1).id;
193
+ lastVisualState = s;
194
+ stopTableEffects();
195
+ return;
196
+ }
172
197
  if (!animationsAllowed()) {
173
198
  if (events.length) lastProcessedEventId = events.at(-1).id;
174
199
  lastVisualState = s;
175
200
  return;
176
201
  }
177
- const previous = lastVisualState;
178
202
  if (s?.table && (!previous?.table || s.table.handNumber !== previous.table.handNumber)) animateNewHand(s);
179
203
  const prevBoard = previous?.table?.board?.length || 0, nextBoard = s?.table?.board?.length || 0;
180
204
  if (s?.table?.handNumber === previous?.table?.handNumber) animateBoardCards(prevBoard, nextBoard);
@@ -190,11 +214,19 @@ function processVisualEffects(s) {
190
214
  if (type === ACTION.CHECK && seat) { seat.classList.add('check-flash'); setTimeout(() => seat.classList.remove('check-flash'), 500); playTableSound('check'); }
191
215
  showActionToast(`${displayModelName(e.configuredModel || e.resolvedModel || e.playerName)} · ${e.action?.description || type}`, type);
192
216
  }
193
- if (e.type === 'HAND_END' && previous?.table) {
194
- const before = new Map((previous.table.players || []).filter(Boolean).map(p => [p.id, Number(p.stack || 0)]));
195
- const winners = (s?.table?.players || []).filter(p => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0)));
196
- const pot = $('.hud-pot', document); winners.forEach((p, i) => setTimeout(() => flyChips(pot, seatEl(p.id), 5, true), i * 130));
197
- if (winners.length) playTableSound('winner');
217
+ if (e.type === 'HAND_END') {
218
+ // Prefer the event payload: `previous` can be missing on a resumed or
219
+ // re-rendered state, so the stack diff is only a fallback. Chips fly back
220
+ // from the pot to every winner at showdown, and the hand is announced.
221
+ const before = new Map((previous?.table?.players || []).filter(Boolean).map(p => [p.id, Number(p.stack || 0)]));
222
+ const payloadIds = (e.winners || []).map(w => w?.playerId ?? w?.id ?? w).filter(Boolean);
223
+ const fromStacks = (s?.table?.players || [])
224
+ .filter(p => p && Number(p.stack || 0) > (before.get(p.id) ?? Number(p.stack || 0)))
225
+ .map(p => p.id);
226
+ const winnerIds = [...new Set([...payloadIds, ...fromStacks])];
227
+ const pot = $('.hud-pot', document);
228
+ winnerIds.forEach((playerId, i) => setTimeout(() => flyChips(pot, seatEl(playerId), 5, true), i * 130));
229
+ if (winnerIds.length) playTableSound('winner');
198
230
  }
199
231
  if (e.type === 'TOURNAMENT_END') playTableSound('winner');
200
232
  }
@@ -622,6 +654,10 @@ class TournamentDirector {
622
654
  }
623
655
  async startHand() {
624
656
  await this.waitIfPaused(); this.pruneBustedSeats(); this.handNumber++;
657
+ // A new hand clears every seat's "last action" so a card never shows what a
658
+ // player did in the previous hand. Elimination stamps are presentation-only
659
+ // and are applied in renderTable, so they survive this reset.
660
+ for (const stat of Object.values(this.stats)) stat.lastAction = null;
625
661
  this.handStartStacks = Object.fromEntries((this.engine?.state?.players ?? []).filter(Boolean).map(p => [p.id, playerStack(p)]));
626
662
  try { this.engine.deal(); }
627
663
  catch {
@@ -1364,7 +1400,9 @@ function renderStatus(s) {
1364
1400
  els.pauseBtn.classList.toggle('hidden', !running);
1365
1401
  els.stopBtn.classList.toggle('hidden', !running);
1366
1402
  els.startTopBtn.classList.toggle('hidden', running);
1367
- els.seatsBtn.classList.toggle('hidden', running);
1403
+ // Restart is offered only after a run has actually started and stopped or
1404
+ // finished; before the first Start the primary action is Start itself.
1405
+ els.seatsBtn.classList.toggle('hidden', running || !['STOPPED', 'FINISHED', 'ERROR'].includes(status));
1368
1406
  els.setupBtn.disabled = running;
1369
1407
  els.testsBtn.disabled = running;
1370
1408
  const pauseLabel = $('.action-label', els.pauseBtn);
@@ -1569,6 +1607,9 @@ function championBadgeHtml() {
1569
1607
  + '</svg></span>';
1570
1608
  }
1571
1609
  function renderDecision(s) {
1610
+ // A stopped/errored run must not keep a live clock. stop() clears
1611
+ // currentDecision, but the last broadcast can still carry it, so guard here.
1612
+ if (['STOPPED', 'ERROR'].includes(s?.status)) { stopClock(); return; }
1572
1613
  const d = s?.currentDecision;
1573
1614
  const last = latestDecisionEvent(s);
1574
1615
  const shouldShowCard = Boolean(d || last || seatAssignments.filter(Boolean).length);
@@ -2326,12 +2367,17 @@ els.setupBtn.addEventListener('click', openSetup);
2326
2367
  els.closeSetup.addEventListener('click', () => els.setupDialog.close());
2327
2368
  els.addConnectionBtn.addEventListener('click', () => addConnectionRow({ name: `API ${els.connectionsEditor.children.length + 1}`, kind: 'openai', baseUrl: 'https://api.openai.com/v1' }));
2328
2369
  els.pauseBtn.addEventListener('click', () => { if (!director) return; currentState?.status === 'PAUSED' ? director.resume() : director.pause(); });
2329
- els.stopBtn.addEventListener('click', () => director?.stop());
2370
+ els.stopBtn.addEventListener('click', () => {
2371
+ if (!director) return;
2372
+ stopClock(); stopTableEffects();
2373
+ director.stop();
2374
+ });
2330
2375
  els.exportBtn.addEventListener('click', () => { if (!director?.events?.length) return; downloadText(`${director.config?.id || 'pokertools-arena'}.jsonl`, director.exportJsonl()); });
2331
2376
  els.seatsBtn.addEventListener('click', () => {
2377
+ // This button only appears once a run has stopped or finished, where it
2378
+ // restarts the tournament from the current seats.
2332
2379
  if (director && ['RUNNING', 'PAUSED'].includes(director.status)) return;
2333
- lobbyVisible = true;
2334
- render(currentState || { status: 'IDLE', events: [] });
2380
+ void startConfiguredTournament();
2335
2381
  });
2336
2382
  els.seatsLayer.addEventListener('click', event => {
2337
2383
  const seat = event.target.closest('[data-lobby-seat]');
package/src/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>
@@ -137,7 +138,12 @@
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-log" class="tab-panel">
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"></div>
145
+ </section>
146
+ </div>
141
147
  <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>
142
148
  </aside>
143
149
  </main>
package/src/styles.css CHANGED
@@ -2287,3 +2287,64 @@ a.button{text-decoration:none}
2287
2287
 
2288
2288
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2289
2289
  Browser application source moved under src/; visual behavior is unchanged. */
2290
+
2291
+ /* 0.4.8 — compact header, table-card parity, quieter chrome.
2292
+
2293
+ Header: brand left, a backgroundless GitHub mark, then actions right. The
2294
+ board uses the same rank-plus-center-suit card language as the hole cards on
2295
+ small screens instead of the crowded duplicated corners. */
2296
+ .topbar{flex-wrap:nowrap}
2297
+ .brandbar{flex:0 1 auto}
2298
+ .repo-link{display:inline-grid;place-items:center;width:26px;height:26px;margin-left:6px;border:0;border-radius:7px;background:none;color:var(--muted);flex:0 0 auto;transition:color .15s ease,opacity .15s ease}
2299
+ .repo-link .repo-icon{width:17px;height:17px;opacity:.9}
2300
+ .repo-link:hover{color:var(--text)}
2301
+ .repo-link:hover .repo-icon{opacity:1}
2302
+ .topbar-actions{margin-left:auto;flex:0 1 auto}
2303
+ /* Settings is an icon-only control: no label, no wasted width. */
2304
+ .icon-only-action{padding:0;width:30px;min-width:30px;justify-content:center}
2305
+ .icon-only-action .action-label{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
2306
+
2307
+ /* Sound and recording live with the table status they affect. */
2308
+ .arena-head{gap:10px}
2309
+ .arena-head-actions{display:flex;align-items:center;gap:4px;flex:0 0 auto;margin-left:auto}
2310
+ .arena-icon-button{display:inline-grid;place-items:center;width:24px;height:24px;min-width:24px;padding:0;border:1px solid var(--modal-border,rgba(232,238,243,.09));border-radius:7px;background:rgba(255,255,255,.02);color:#8d98a2;font-size:10px;cursor:pointer;transition:color .14s ease,border-color .14s ease,background .14s ease}
2311
+ .arena-icon-button:hover{color:#d8e0e5;border-color:rgba(146,201,164,.3)}
2312
+ .arena-icon-button.active{color:var(--accent);border-color:rgba(114,185,138,.35);background:rgba(114,185,138,.08)}
2313
+ .arena-icon-button.record-button.active{color:var(--danger);border-color:rgba(200,94,105,.4);background:rgba(200,94,105,.1)}
2314
+ .arena-icon-button:disabled{opacity:.42;cursor:not-allowed}
2315
+ .arena-head-actions .action-icon{font-size:10px;opacity:1}
2316
+
2317
+ /* Download lives inside the Log tab it produces. */
2318
+ .panel-title-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:11px}
2319
+ .panel-title-row .panel-title{margin-bottom:0}
2320
+ .panel-title-row .button{flex:0 0 auto}
2321
+
2322
+ /* The bank clock leads the right-hand clock block instead of trailing it. */
2323
+ .clock-wrap{align-items:flex-start;text-align:left}
2324
+ #bankClock{order:-1;color:var(--muted);font-size:9px;line-height:1.2}
2325
+
2326
+ /* Board cards: rank + big centre suit, no duplicated corners. This is the
2327
+ default at small widths and every compact/tight/micro density, so table and
2328
+ hole cards read the same way. */
2329
+ @media(max-width:720px){
2330
+ .board .card .card-corner.bottom,
2331
+ .poker-table[data-density="compact"] .board .card .card-corner.bottom,
2332
+ .poker-table[data-density="tight"] .board .card .card-corner.bottom{display:none!important}
2333
+ .board .card .card-corner i,
2334
+ .poker-table[data-density="compact"] .board .card .card-corner i,
2335
+ .poker-table[data-density="tight"] .board .card .card-corner i{display:none!important}
2336
+ .board .card .card-corner,
2337
+ .poker-table[data-density="compact"] .board .card .card-corner,
2338
+ .poker-table[data-density="tight"] .board .card .card-corner{left:3px!important;top:3px!important;line-height:1!important}
2339
+ .board .card .card-corner b,
2340
+ .poker-table[data-density="compact"] .board .card .card-corner b,
2341
+ .poker-table[data-density="tight"] .board .card .card-corner b{font-size:10px!important}
2342
+ .board .card .card-suit,
2343
+ .poker-table[data-density="compact"] .board .card .card-suit,
2344
+ .poker-table[data-density="tight"] .board .card .card-suit{font-size:17px!important}
2345
+ }
2346
+ .poker-table[data-density="tight"] .board .card .card-suit{font-size:16px!important}
2347
+ .poker-table[data-density="micro"] .board .card .card-corner i,
2348
+ .poker-table[data-density="micro"] .board .card .card-corner.bottom{display:none!important}
2349
+ .poker-table[data-density="micro"] .board .card .card-suit{font-size:13px!important}
2350
+