pokertools-arena 0.4.0 → 0.4.4

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.0.** This is the methodology and reproducibility release: a paired fixed-state corpus, separated family/sizing correctness, fragmentation-invariance metrics with confidence intervals, standardized counters, and a reorganized repository with one source of truth per report.
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.
21
21
 
22
22
  ---
23
23
 
@@ -135,7 +135,7 @@ function safePath(urlPath) {
135
135
  return resolved.startsWith(root) ? resolved : null;
136
136
  }
137
137
 
138
- const server = createServer(async (req, res) => {
138
+ async function handleRequest(req, res) {
139
139
  try {
140
140
  const urlPath = (req.url || '/').split('?')[0];
141
141
  if (urlPath === '/arena-env.js') {
@@ -160,7 +160,7 @@ const server = createServer(async (req, res) => {
160
160
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
161
161
  res.end('Not found');
162
162
  }
163
- });
163
+ }
164
164
 
165
165
  function openBrowser(url) {
166
166
  const platform = process.platform;
@@ -176,6 +176,11 @@ function openBrowser(url) {
176
176
  }
177
177
 
178
178
  function listen(port) {
179
+ // A fresh Server per attempt. Reusing one Server and calling listen() again
180
+ // from inside its EADDRINUSE handler makes Node emit 'listening' twice, which
181
+ // printed the banner twice and opened two browser windows when the port was
182
+ // already in use.
183
+ const server = createServer(handleRequest);
179
184
  server.once('error', error => {
180
185
  if (error.code === 'EADDRINUSE' && port < requestedPort + 20) return listen(port + 1);
181
186
  console.error(`pokertools-arena: ${error.message}`);
package/dist/app.js CHANGED
@@ -115138,7 +115138,6 @@ function decide(agent, connection, args) {
115138
115138
  }
115139
115139
  var FAMILY_OBJECTIVE = "Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.";
115140
115140
  var SIZE_OBJECTIVE = (family) => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? "raise size" : "bet size"} that best maximizes tournament chip EV. Do not change the action family.`;
115141
- var SPECTATOR_NOTE = "This model returns typed decisions rather than a text rationale.";
115142
115141
  function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
115143
115142
  return { type: "choice", instructions, criteria: criteria ?? familyCriteria(families) };
115144
115143
  }
@@ -115594,6 +115593,7 @@ var lastProcessedEventId = null;
115594
115593
  var MAX_LOBBY_SEATS = 10;
115595
115594
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
115596
115595
  var editingSeatIndex = null;
115596
+ var seatNameAuto = false;
115597
115597
  var lobbyVisible = true;
115598
115598
  var pendingAutostart = false;
115599
115599
  var arenaMaxDecisions = 0;
@@ -116863,19 +116863,28 @@ function updateSeatSummary() {
116863
116863
  const count = seatAssignments.filter(Boolean).length;
116864
116864
  if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
116865
116865
  }
116866
+ function syncSeatNameFromModel() {
116867
+ if (!seatNameAuto || editingSeatIndex == null) return;
116868
+ const model = els.seatModel.value.trim();
116869
+ els.seatName.value = model ? displayModelName(model) : `Player ${editingSeatIndex + 1}`;
116870
+ }
116866
116871
  function openSeatEditor(seatIndex) {
116867
116872
  if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
116868
116873
  editingSeatIndex = seatIndex;
116869
116874
  const locked = Boolean(director && ["RUNNING", "PAUSED"].includes(director.status));
116870
116875
  const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
116871
116876
  els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
116872
- els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
116877
+ const placeholderName = `Player ${seatIndex + 1}`;
116878
+ const storedName = String(draft.name || "").trim();
116879
+ seatNameAuto = !storedName || /^player\s+\d+$/i.test(storedName) || Boolean(draft.model) && storedName === displayModelName(draft.model);
116880
+ els.seatName.value = seatNameAuto && draft.model ? displayModelName(draft.model) : storedName || placeholderName;
116873
116881
  refreshSeatConnectionSelect(draft.connectionId || "");
116874
116882
  if (draft.connectionId && [...els.seatConnection.options].some((option) => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
116875
116883
  els.seatModel.value = draft.model || "";
116876
116884
  els.seatProtocol.value = draft.protocol || "tool";
116877
116885
  els.seatProvider.value = draft.provider || "";
116878
116886
  applySeatProtocolRules();
116887
+ syncSeatNameFromModel();
116879
116888
  void refreshSeatModelCatalog();
116880
116889
  els.seatError.classList.add("hidden");
116881
116890
  els.seatLockNotice.classList.toggle("hidden", !locked);
@@ -117317,8 +117326,6 @@ function decisionTelemetryHtml(event, { limit = 4 } = {}) {
117317
117326
  const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
117318
117327
  if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
117319
117328
  if (facts.length) parts.push(`<div class="decision-facts">${facts.join("")}</div>`);
117320
- const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === "openrouter-decisions" || meta.method === "jev-choice" || String(meta.method || "").includes("hierarchical");
117321
- if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
117322
117329
  return parts.join("");
117323
117330
  }
117324
117331
  function setDecisionContext({ hand = "\u2014", street = "\u2014", position = "\u2014", options = "\u2014", label = "Legal actions", hint = "Choose one", labels = null } = {}) {
@@ -117576,7 +117583,7 @@ function cloneJson(value) {
117576
117583
  }
117577
117584
  function sanityAgents() {
117578
117585
  const connections = readConnections();
117579
- return readSeatPlayers().map((player) => ({ ...player, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
117586
+ return readSeatPlayers().map((player) => ({ ...player, id: player.id || `player-${player.lobbySeat + 1}`, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
117580
117587
  }
117581
117588
  function sanityProtocolLabel(agent) {
117582
117589
  const protocol = effectiveProtocol(agent, agent.connection);
@@ -118288,9 +118295,16 @@ for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.repl
118288
118295
  }
118289
118296
  els.seatConnection.addEventListener("change", () => {
118290
118297
  applySeatProtocolRules();
118298
+ syncSeatNameFromModel();
118291
118299
  void refreshSeatModelCatalog();
118292
118300
  });
118293
- els.seatModel.addEventListener("input", applySeatProtocolRules);
118301
+ els.seatModel.addEventListener("input", () => {
118302
+ applySeatProtocolRules();
118303
+ syncSeatNameFromModel();
118304
+ });
118305
+ els.seatName.addEventListener("input", () => {
118306
+ seatNameAuto = false;
118307
+ });
118294
118308
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
118295
118309
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
118296
118310
  els.removeSeatBtn.addEventListener("click", () => {
package/dist/index.html CHANGED
@@ -165,7 +165,7 @@
165
165
  <h2>Settings</h2>
166
166
  <button class="info-button" type="button" aria-label="About settings" data-tip="API keys stay in this browser tab and are never written to saved configuration or tournament logs.">i</button>
167
167
  </div>
168
- <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings">×</button>
168
+ <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
169
169
  </div>
170
170
 
171
171
  <div class="dialog-body setup-body">
@@ -246,7 +246,7 @@
246
246
  <h2 id="seatDialogTitle">Seat 1</h2>
247
247
  <button class="info-button" type="button" aria-label="About seat context" data-tip="Each model receives only its own hole cards plus the same canonical public table state, recent public hands, opponent statistics, and legal actions.">i</button>
248
248
  </div>
249
- <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor">×</button>
249
+ <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
250
250
  </div>
251
251
  <div class="dialog-body seat-editor-body">
252
252
  <div id="seatLockNotice" class="seat-lock-notice hidden">Seat assignments are locked during a tournament.</div>
@@ -284,7 +284,7 @@
284
284
  <div class="dialog-title-row"><h2>Decision sanity suite</h2><button class="info-button" type="button" aria-label="About decision tests" data-tip="A small comprehension and reliability check. It is intentionally not a GTO solver, Elo score, or tournament-strength ranking.">i</button></div>
285
285
  <div class="dialog-subtitle">The same 10 fixed poker spots are sent independently to every seated model.</div>
286
286
  </div>
287
- <button id="closeTests" type="button" class="icon-button" aria-label="Close tests">×</button>
287
+ <button id="closeTests" type="button" class="icon-button" aria-label="Close tests"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
288
288
  </div>
289
289
  <div class="tests-body">
290
290
  <div class="tests-how" aria-label="How the suite works">
@@ -317,7 +317,7 @@
317
317
  <div class="dialog-title-row"><h2 id="replayTitle">Decision replay</h2><span id="replayBadge" class="replay-badge">HAND —</span></div>
318
318
  <div id="replaySubtitle" class="dialog-subtitle">Snapshot captured immediately before the model acted.</div>
319
319
  </div>
320
- <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay">×</button>
320
+ <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
321
321
  </div>
322
322
  <div class="dialog-body replay-body">
323
323
  <section class="replay-table-wrap">
@@ -361,7 +361,7 @@
361
361
  <span class="field-label-row">Preset <button class="info-button inline-info" type="button" aria-label="About connection presets" data-tip="OpenAI-compatible keeps Base URL fully editable. OpenRouter only fills its standard URL and enables OpenRouter-specific routing. TypeSafe uses the native System One endpoint.">i</button></span>
362
362
  <select data-field="kind"><option value="openai">OpenAI-compatible</option><option value="openrouter">OpenRouter</option><option value="typesafe">TypeSafe System One</option></select>
363
363
  </label>
364
- <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection">×</button>
364
+ <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
365
365
  </div>
366
366
  <div class="connection-main-grid">
367
367
  <label class="connection-url">Base URL<input data-field="baseUrl" spellcheck="false" placeholder="https://api.example.com/v1" /></label>
@@ -895,7 +895,11 @@ input::placeholder{color:#4e5862}
895
895
  .button,.icon-button,.field-icon-button,input,select{box-sizing:border-box}
896
896
  .button{min-height:var(--control-h);padding:0 12px;border-radius:var(--radius-sm);font-size:10px;font-weight:800;line-height:1;white-space:nowrap}
897
897
  .small-button{min-height:30px;height:30px;padding-inline:10px;font-size:9px}
898
- .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0}
898
+ .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0;line-height:1}
899
+ /* SVG glyphs center geometrically; a text "×" sits on its baseline and looks
900
+ vertically off-centre inside these small buttons. */
901
+ .icon-button svg{width:14px;height:14px;display:block;pointer-events:none}
902
+ .dialog-head .icon-button svg{width:13px;height:13px}
899
903
  input,select{width:100%;min-width:0;height:var(--control-h);padding:0 9px;border-radius:var(--radius-sm);font-size:11px;background:#0a0d10;border:1px solid var(--modal-border);color:var(--text)}
900
904
  input:focus,select:focus{outline:none;border-color:rgba(146,201,164,.34);box-shadow:0 0 0 3px rgba(146,201,164,.05)}
901
905
  label{min-width:0}
@@ -2321,10 +2325,7 @@ a.button{text-decoration:none}
2321
2325
  .card .card-corner.bottom{display:none!important}
2322
2326
  }
2323
2327
 
2324
- /* 0.2.23 — decision diagnostics + deterministic hand evaluation
2325
- Spectator-only annotations. The note explicitly separates typed model
2326
- answers from any fabricated rationale. */
2327
- .decision-facts-note{margin-top:6px;font-size:10px;line-height:1.4;color:var(--muted,#8aa098);border-left:2px solid rgba(184,236,111,.35);padding-left:8px}
2328
+ /* 0.2.23 — decision diagnostics + deterministic hand evaluation. */
2328
2329
  .replay-reason-text{margin-bottom:10px;line-height:1.45}
2329
2330
  .replay-summary span b{white-space:nowrap}
2330
2331
 
@@ -2339,7 +2340,6 @@ a.button{text-decoration:none}
2339
2340
  .stage-chip{white-space:nowrap}
2340
2341
  .telemetry-group{margin-top:6px}
2341
2342
  .telemetry-caption{display:block;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted,#8aa098);margin-bottom:3px}
2342
- .decision-facts-note{white-space:normal}
2343
2343
 
2344
2344
  /* 0.4.0 — methodology, reproducibility and repository-organization release.
2345
2345
  Browser application source moved under src/; visual behavior is unchanged. */
@@ -2456,7 +2456,7 @@ a.button{text-decoration:none}
2456
2456
  <h2>Settings</h2>
2457
2457
  <button class="info-button" type="button" aria-label="About settings" data-tip="API keys stay in this browser tab and are never written to saved configuration or tournament logs.">i</button>
2458
2458
  </div>
2459
- <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings">×</button>
2459
+ <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2460
2460
  </div>
2461
2461
 
2462
2462
  <div class="dialog-body setup-body">
@@ -2537,7 +2537,7 @@ a.button{text-decoration:none}
2537
2537
  <h2 id="seatDialogTitle">Seat 1</h2>
2538
2538
  <button class="info-button" type="button" aria-label="About seat context" data-tip="Each model receives only its own hole cards plus the same canonical public table state, recent public hands, opponent statistics, and legal actions.">i</button>
2539
2539
  </div>
2540
- <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor">×</button>
2540
+ <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2541
2541
  </div>
2542
2542
  <div class="dialog-body seat-editor-body">
2543
2543
  <div id="seatLockNotice" class="seat-lock-notice hidden">Seat assignments are locked during a tournament.</div>
@@ -2575,7 +2575,7 @@ a.button{text-decoration:none}
2575
2575
  <div class="dialog-title-row"><h2>Decision sanity suite</h2><button class="info-button" type="button" aria-label="About decision tests" data-tip="A small comprehension and reliability check. It is intentionally not a GTO solver, Elo score, or tournament-strength ranking.">i</button></div>
2576
2576
  <div class="dialog-subtitle">The same 10 fixed poker spots are sent independently to every seated model.</div>
2577
2577
  </div>
2578
- <button id="closeTests" type="button" class="icon-button" aria-label="Close tests">×</button>
2578
+ <button id="closeTests" type="button" class="icon-button" aria-label="Close tests"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2579
2579
  </div>
2580
2580
  <div class="tests-body">
2581
2581
  <div class="tests-how" aria-label="How the suite works">
@@ -2608,7 +2608,7 @@ a.button{text-decoration:none}
2608
2608
  <div class="dialog-title-row"><h2 id="replayTitle">Decision replay</h2><span id="replayBadge" class="replay-badge">HAND —</span></div>
2609
2609
  <div id="replaySubtitle" class="dialog-subtitle">Snapshot captured immediately before the model acted.</div>
2610
2610
  </div>
2611
- <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay">×</button>
2611
+ <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2612
2612
  </div>
2613
2613
  <div class="dialog-body replay-body">
2614
2614
  <section class="replay-table-wrap">
@@ -2652,7 +2652,7 @@ a.button{text-decoration:none}
2652
2652
  <span class="field-label-row">Preset <button class="info-button inline-info" type="button" aria-label="About connection presets" data-tip="OpenAI-compatible keeps Base URL fully editable. OpenRouter only fills its standard URL and enables OpenRouter-specific routing. TypeSafe uses the native System One endpoint.">i</button></span>
2653
2653
  <select data-field="kind"><option value="openai">OpenAI-compatible</option><option value="openrouter">OpenRouter</option><option value="typesafe">TypeSafe System One</option></select>
2654
2654
  </label>
2655
- <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection">×</button>
2655
+ <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
2656
2656
  </div>
2657
2657
  <div class="connection-main-grid">
2658
2658
  <label class="connection-url">Base URL<input data-field="baseUrl" spellcheck="false" placeholder="https://api.example.com/v1" /></label>
@@ -117813,7 +117813,6 @@ function decide(agent, connection, args) {
117813
117813
  }
117814
117814
  var FAMILY_OBJECTIVE = "Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.";
117815
117815
  var SIZE_OBJECTIVE = (family) => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? "raise size" : "bet size"} that best maximizes tournament chip EV. Do not change the action family.`;
117816
- var SPECTATOR_NOTE = "This model returns typed decisions rather than a text rationale.";
117817
117816
  function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
117818
117817
  return { type: "choice", instructions, criteria: criteria ?? familyCriteria(families) };
117819
117818
  }
@@ -118269,6 +118268,7 @@ var lastProcessedEventId = null;
118269
118268
  var MAX_LOBBY_SEATS = 10;
118270
118269
  var seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
118271
118270
  var editingSeatIndex = null;
118271
+ var seatNameAuto = false;
118272
118272
  var lobbyVisible = true;
118273
118273
  var pendingAutostart = false;
118274
118274
  var arenaMaxDecisions = 0;
@@ -119538,19 +119538,28 @@ function updateSeatSummary() {
119538
119538
  const count = seatAssignments.filter(Boolean).length;
119539
119539
  if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
119540
119540
  }
119541
+ function syncSeatNameFromModel() {
119542
+ if (!seatNameAuto || editingSeatIndex == null) return;
119543
+ const model = els.seatModel.value.trim();
119544
+ els.seatName.value = model ? displayModelName(model) : `Player ${editingSeatIndex + 1}`;
119545
+ }
119541
119546
  function openSeatEditor(seatIndex) {
119542
119547
  if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
119543
119548
  editingSeatIndex = seatIndex;
119544
119549
  const locked = Boolean(director && ["RUNNING", "PAUSED"].includes(director.status));
119545
119550
  const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
119546
119551
  els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
119547
- els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
119552
+ const placeholderName = `Player ${seatIndex + 1}`;
119553
+ const storedName = String(draft.name || "").trim();
119554
+ seatNameAuto = !storedName || /^player\s+\d+$/i.test(storedName) || Boolean(draft.model) && storedName === displayModelName(draft.model);
119555
+ els.seatName.value = seatNameAuto && draft.model ? displayModelName(draft.model) : storedName || placeholderName;
119548
119556
  refreshSeatConnectionSelect(draft.connectionId || "");
119549
119557
  if (draft.connectionId && [...els.seatConnection.options].some((option) => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
119550
119558
  els.seatModel.value = draft.model || "";
119551
119559
  els.seatProtocol.value = draft.protocol || "tool";
119552
119560
  els.seatProvider.value = draft.provider || "";
119553
119561
  applySeatProtocolRules();
119562
+ syncSeatNameFromModel();
119554
119563
  void refreshSeatModelCatalog();
119555
119564
  els.seatError.classList.add("hidden");
119556
119565
  els.seatLockNotice.classList.toggle("hidden", !locked);
@@ -119992,8 +120001,6 @@ function decisionTelemetryHtml(event, { limit = 4 } = {}) {
119992
120001
  const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
119993
120002
  if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
119994
120003
  if (facts.length) parts.push(`<div class="decision-facts">${facts.join("")}</div>`);
119995
- const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === "openrouter-decisions" || meta.method === "jev-choice" || String(meta.method || "").includes("hierarchical");
119996
- if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
119997
120004
  return parts.join("");
119998
120005
  }
119999
120006
  function setDecisionContext({ hand = "\u2014", street = "\u2014", position = "\u2014", options = "\u2014", label = "Legal actions", hint = "Choose one", labels = null } = {}) {
@@ -120251,7 +120258,7 @@ function cloneJson(value) {
120251
120258
  }
120252
120259
  function sanityAgents() {
120253
120260
  const connections = readConnections();
120254
- return readSeatPlayers().map((player) => ({ ...player, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
120261
+ return readSeatPlayers().map((player) => ({ ...player, id: player.id || `player-${player.lobbySeat + 1}`, connection: connections.find((c) => c.id === player.connectionId) })).filter((row) => row.connection);
120255
120262
  }
120256
120263
  function sanityProtocolLabel(agent) {
120257
120264
  const protocol = effectiveProtocol(agent, agent.connection);
@@ -120963,9 +120970,16 @@ for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.repl
120963
120970
  }
120964
120971
  els.seatConnection.addEventListener("change", () => {
120965
120972
  applySeatProtocolRules();
120973
+ syncSeatNameFromModel();
120966
120974
  void refreshSeatModelCatalog();
120967
120975
  });
120968
- els.seatModel.addEventListener("input", applySeatProtocolRules);
120976
+ els.seatModel.addEventListener("input", () => {
120977
+ applySeatProtocolRules();
120978
+ syncSeatNameFromModel();
120979
+ });
120980
+ els.seatName.addEventListener("input", () => {
120981
+ seatNameAuto = false;
120982
+ });
120969
120983
  els.seatProtocol.addEventListener("change", applySeatProtocolRules);
120970
120984
  els.refreshModelsBtn?.addEventListener("click", () => void refreshSeatModelCatalog({ force: true }));
120971
120985
  els.removeSeatBtn.addEventListener("click", () => {
package/dist/styles.css CHANGED
@@ -839,7 +839,11 @@ input::placeholder{color:#4e5862}
839
839
  .button,.icon-button,.field-icon-button,input,select{box-sizing:border-box}
840
840
  .button{min-height:var(--control-h);padding:0 12px;border-radius:var(--radius-sm);font-size:10px;font-weight:800;line-height:1;white-space:nowrap}
841
841
  .small-button{min-height:30px;height:30px;padding-inline:10px;font-size:9px}
842
- .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0}
842
+ .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0;line-height:1}
843
+ /* SVG glyphs center geometrically; a text "×" sits on its baseline and looks
844
+ vertically off-centre inside these small buttons. */
845
+ .icon-button svg{width:14px;height:14px;display:block;pointer-events:none}
846
+ .dialog-head .icon-button svg{width:13px;height:13px}
843
847
  input,select{width:100%;min-width:0;height:var(--control-h);padding:0 9px;border-radius:var(--radius-sm);font-size:11px;background:#0a0d10;border:1px solid var(--modal-border);color:var(--text)}
844
848
  input:focus,select:focus{outline:none;border-color:rgba(146,201,164,.34);box-shadow:0 0 0 3px rgba(146,201,164,.05)}
845
849
  label{min-width:0}
@@ -2265,10 +2269,7 @@ a.button{text-decoration:none}
2265
2269
  .card .card-corner.bottom{display:none!important}
2266
2270
  }
2267
2271
 
2268
- /* 0.2.23 — decision diagnostics + deterministic hand evaluation
2269
- Spectator-only annotations. The note explicitly separates typed model
2270
- answers from any fabricated rationale. */
2271
- .decision-facts-note{margin-top:6px;font-size:10px;line-height:1.4;color:var(--muted,#8aa098);border-left:2px solid rgba(184,236,111,.35);padding-left:8px}
2272
+ /* 0.2.23 — decision diagnostics + deterministic hand evaluation. */
2272
2273
  .replay-reason-text{margin-bottom:10px;line-height:1.45}
2273
2274
  .replay-summary span b{white-space:nowrap}
2274
2275
 
@@ -2283,7 +2284,6 @@ a.button{text-decoration:none}
2283
2284
  .stage-chip{white-space:nowrap}
2284
2285
  .telemetry-group{margin-top:6px}
2285
2286
  .telemetry-caption{display:block;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted,#8aa098);margin-bottom:3px}
2286
- .decision-facts-note{white-space:normal}
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. */
@@ -1,4 +1,4 @@
1
- # pokertools-arena 0.4.0 — benchmark methodology & results
1
+ # pokertools-arena 0.4.4 — 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.0",
3
+ "version": "0.4.4",
4
4
  "generatedAt": "2026-09-19T18:19:58.352Z",
5
5
  "experiments": [
6
6
  "paired"
@@ -1,5 +1,56 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.4.4 — Maintenance
4
+
5
+ - **Cleaner decision logs.** Removed the repeated spectator caption ("This
6
+ model returns typed decisions rather than a text rationale…") that appeared on
7
+ every typed decision in the live feed and the replay panel. Typed decisions
8
+ already show their selected family/size plus confidence telemetry, so logs are
9
+ concise and no longer restate that a rationale is absent. `SPECTATOR_NOTE` and
10
+ its CSS were removed; release checks guard against reintroduction.
11
+
12
+ ## 0.4.3 — Maintenance
13
+
14
+ - **Seat editor name.** Opening a seat restored from `.env` or saved
15
+ configuration now shows the model-derived name (for example `Gemma`) instead
16
+ of the generic `Player 1`, matching the table, lobby and stats surfaces. The
17
+ name follows the chosen model until it is edited by hand.
18
+ - **Close buttons.** Dialog close buttons and the remove-connection button use a
19
+ centered inline SVG cross instead of a baseline-aligned text `×`, so the glyph
20
+ is vertically centered.
21
+
22
+ ## 0.4.2 — Maintenance
23
+
24
+ - **Decision sanity suite model attribution.** Seat assignments restored from
25
+ `.env` or saved configuration had no `id`, so every sanity agent shared an
26
+ undefined id. Each model summary then counted every model's decisions (for
27
+ example `24/10` when only 10 spots exist) and every scenario column rendered
28
+ the last model's result. Sanity agents now derive the same stable
29
+ `player-<seat>` id the tournament uses, so each model is reported separately.
30
+
31
+ ## 0.4.1 — Maintenance
32
+
33
+ A maintenance release on top of the 0.4.0 methodology work.
34
+
35
+ ### Tooltips
36
+
37
+ - Modal tooltips are rendered by a single fixed-position element attached to the
38
+ topmost open `<dialog>`. They escape the modal's overflow clipping, flip above
39
+ the trigger when there is no room below, and stay inside the viewport, so every
40
+ information tooltip is fully visible.
41
+
42
+ ### Launcher
43
+
44
+ - The local launcher binds a fresh HTTP server per port attempt. When the default
45
+ port is already in use it now starts exactly once (one banner, one browser
46
+ window) on the next free port instead of starting twice.
47
+
48
+ ### CI
49
+
50
+ - Real-API diagnostics have a credential preflight. When the `OPENAI_*`
51
+ repository secrets are not configured the diagnostics job is skipped with a
52
+ notice instead of failing a published release.
53
+
3
54
  ## 0.4.0 — Methodology, reproducibility and repository organization
4
55
 
5
56
  This release does not redesign the 0.3.0 decision foundations. It makes the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pokertools-arena",
3
- "version": "0.4.0",
3
+ "version": "0.4.4",
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": {
@@ -23,7 +23,7 @@
23
23
  "check": "node --check src/app.js && node --check src/benchmark/scenarios.js && node --check build.mjs && node --check bin/pokertools-arena.mjs && node tests/release-check.mjs",
24
24
  "test": "npm run build && npm run check && npm run test:unit && npm run test:integration",
25
25
  "test:unit": "node tests/unit/decision-scenarios.mjs && node tests/unit/decision-diagnostics.mjs && node tests/unit/hierarchical-decision-tests.mjs && node tests/unit/methodology-tests.mjs && node tests/unit/size-bucket-tests.mjs && node tests/unit/paired-architecture-tests.mjs && node tests/unit/archive-content.mjs && node tests/unit/report-consistency.mjs",
26
- "test:integration": "node tests/integration/pokertools-integration.mjs && node tests/integration/env-bootstrap.mjs",
26
+ "test:integration": "node tests/integration/pokertools-integration.mjs && node tests/integration/env-bootstrap.mjs && node tests/integration/launcher-port-retry.mjs",
27
27
  "test:diagnostics": "node tests/real/real-decision-diagnostics.mjs",
28
28
  "test:diagnostics:dry": "node tests/real/real-decision-diagnostics.mjs --dry-run",
29
29
  "test:real": "node tests/real/real-decision-diagnostics.mjs && node tests/real/real-paired-corpus.mjs",
package/src/app.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  BENCHMARK_MODES, DECISION_ARCHITECTURES, DEFAULT_BENCHMARK_MODE, DEFAULT_DECISION_ARCHITECTURE,
15
15
  REPRESENTATION_MODES, DEFAULT_REPRESENTATION_MODE, DECISION_ARCHITECTURE_VERSION,
16
16
  legalActionFamilies, legalAggressiveSizes, buildHierarchicalDecision, familyCriteria, sizeCriteria,
17
- applyBenchmarkMode, renderDecisionState, aggregateActionProbabilitiesByFamily, probabilityStats, SIZE_LABELS, SPECTATOR_NOTE,
17
+ applyBenchmarkMode, renderDecisionState, aggregateActionProbabilitiesByFamily, probabilityStats, SIZE_LABELS,
18
18
  isAggressiveType, familyForActionType, FAMILY_LABELS, decisionClockPhase,
19
19
  } from './lib/decision-core.js';
20
20
 
@@ -47,6 +47,7 @@ let lastProcessedEventId = null;
47
47
  const MAX_LOBBY_SEATS = 10;
48
48
  let seatAssignments = Array(MAX_LOBBY_SEATS).fill(null);
49
49
  let editingSeatIndex = null;
50
+ let seatNameAuto = false;
50
51
  let lobbyVisible = true;
51
52
  let pendingAutostart = false;
52
53
  let arenaMaxDecisions = 0;
@@ -1065,19 +1066,31 @@ function updateSeatSummary() {
1065
1066
  const count = seatAssignments.filter(Boolean).length;
1066
1067
  if (els.seatSummary) els.seatSummary.textContent = `${count} / ${MAX_LOBBY_SEATS} seated`;
1067
1068
  }
1069
+ // The seat name follows the chosen model until the user edits it by hand, so
1070
+ // seats restored from .env or saved config show "Gemma" instead of the generic
1071
+ // "Player 1" the other surfaces already hide via visiblePlayerName().
1072
+ function syncSeatNameFromModel() {
1073
+ if (!seatNameAuto || editingSeatIndex == null) return;
1074
+ const model = els.seatModel.value.trim();
1075
+ els.seatName.value = model ? displayModelName(model) : `Player ${editingSeatIndex + 1}`;
1076
+ }
1068
1077
  function openSeatEditor(seatIndex) {
1069
1078
  if (!Number.isInteger(seatIndex) || seatIndex < 0 || seatIndex >= MAX_LOBBY_SEATS) return;
1070
1079
  editingSeatIndex = seatIndex;
1071
1080
  const locked = Boolean(director && ['RUNNING', 'PAUSED'].includes(director.status));
1072
1081
  const draft = seatAssignments[seatIndex] ? { ...seatAssignments[seatIndex] } : defaultSeatDraft(seatIndex);
1073
1082
  els.seatDialogTitle.textContent = `Seat ${seatIndex + 1}`;
1074
- els.seatName.value = draft.name || `Player ${seatIndex + 1}`;
1083
+ const placeholderName = `Player ${seatIndex + 1}`;
1084
+ const storedName = String(draft.name || '').trim();
1085
+ seatNameAuto = !storedName || /^player\s+\d+$/i.test(storedName) || (Boolean(draft.model) && storedName === displayModelName(draft.model));
1086
+ els.seatName.value = seatNameAuto && draft.model ? displayModelName(draft.model) : (storedName || placeholderName);
1075
1087
  refreshSeatConnectionSelect(draft.connectionId || '');
1076
1088
  if (draft.connectionId && [...els.seatConnection.options].some(option => option.value === draft.connectionId)) els.seatConnection.value = draft.connectionId;
1077
1089
  els.seatModel.value = draft.model || '';
1078
1090
  els.seatProtocol.value = draft.protocol || 'tool';
1079
1091
  els.seatProvider.value = draft.provider || '';
1080
1092
  applySeatProtocolRules();
1093
+ syncSeatNameFromModel();
1081
1094
  void refreshSeatModelCatalog();
1082
1095
  els.seatError.classList.add('hidden');
1083
1096
  els.seatLockNotice.classList.toggle('hidden', !locked);
@@ -1522,8 +1535,9 @@ function decisionTelemetryHtml(event, { limit = 4 } = {}) {
1522
1535
  const reasoningTokens = event?.usage?.completion_tokens_details?.reasoning_tokens;
1523
1536
  if (reasoningTokens) facts.push(`<span>Reasoning <b>${reasoningTokens} tokens</b></span>`);
1524
1537
  if (facts.length) parts.push(`<div class="decision-facts">${facts.join('')}</div>`);
1525
- const isTypedDecision = meta.family != null || meta.aggression != null || meta.bluffSpot != null || meta.method === 'openrouter-decisions' || meta.method === 'jev-choice' || String(meta.method || '').includes('hierarchical');
1526
- if (isTypedDecision) parts.push(`<div class="decision-facts-note">${escapeHtml(SPECTATOR_NOTE)} Bluff opportunity is a property of the spot, not the reason for the chosen action.</div>`);
1538
+ // Typed decisions (hierarchical / Jev / OpenRouter-decisions) have no prose
1539
+ // rationale; the telemetry above already shows family, size and confidence.
1540
+ // A repeated "no text rationale" caption was removed as log noise.
1527
1541
  return parts.join('');
1528
1542
  }
1529
1543
  function setDecisionContext({ hand = '—', street = '—', position = '—', options = '—', label = 'Legal actions', hint = 'Choose one', labels = null } = {}) {
@@ -1746,7 +1760,13 @@ function openSetup({ preserveError = false } = {}) { if (!preserveError) els.set
1746
1760
  function cloneJson(value) { return JSON.parse(JSON.stringify(value)); }
1747
1761
  function sanityAgents() {
1748
1762
  const connections = readConnections();
1749
- return readSeatPlayers().map(player => ({ ...player, connection: connections.find(c => c.id === player.connectionId) })).filter(row => row.connection);
1763
+ // Seat assignments restored from .env or saved config carry no id, so derive
1764
+ // the same stable id the tournament uses. Without it every agent.id is
1765
+ // undefined, so sanity results from all models collapse into one summary
1766
+ // (e.g. "24/10") and every grid column shows the last model's result.
1767
+ return readSeatPlayers()
1768
+ .map(player => ({ ...player, id: player.id || `player-${player.lobbySeat + 1}`, connection: connections.find(c => c.id === player.connectionId) }))
1769
+ .filter(row => row.connection);
1750
1770
  }
1751
1771
  function sanityProtocolLabel(agent) {
1752
1772
  const protocol = effectiveProtocol(agent, agent.connection);
@@ -2326,8 +2346,9 @@ for (const dialog of [els.setupDialog, els.seatDialog, els.testsDialog, els.repl
2326
2346
  dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
2327
2347
  dialog.addEventListener('close', hideTooltip);
2328
2348
  }
2329
- els.seatConnection.addEventListener('change', () => { applySeatProtocolRules(); void refreshSeatModelCatalog(); });
2330
- els.seatModel.addEventListener('input', applySeatProtocolRules);
2349
+ els.seatConnection.addEventListener('change', () => { applySeatProtocolRules(); syncSeatNameFromModel(); void refreshSeatModelCatalog(); });
2350
+ els.seatModel.addEventListener('input', () => { applySeatProtocolRules(); syncSeatNameFromModel(); });
2351
+ els.seatName.addEventListener('input', () => { seatNameAuto = false; });
2331
2352
  els.seatProtocol.addEventListener('change', applySeatProtocolRules);
2332
2353
  els.refreshModelsBtn?.addEventListener('click', () => void refreshSeatModelCatalog({ force: true }));
2333
2354
  els.removeSeatBtn.addEventListener('click', () => {
package/src/index.html CHANGED
@@ -165,7 +165,7 @@
165
165
  <h2>Settings</h2>
166
166
  <button class="info-button" type="button" aria-label="About settings" data-tip="API keys stay in this browser tab and are never written to saved configuration or tournament logs.">i</button>
167
167
  </div>
168
- <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings">×</button>
168
+ <button id="closeSetup" type="button" class="icon-button" aria-label="Close settings"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
169
169
  </div>
170
170
 
171
171
  <div class="dialog-body setup-body">
@@ -246,7 +246,7 @@
246
246
  <h2 id="seatDialogTitle">Seat 1</h2>
247
247
  <button class="info-button" type="button" aria-label="About seat context" data-tip="Each model receives only its own hole cards plus the same canonical public table state, recent public hands, opponent statistics, and legal actions.">i</button>
248
248
  </div>
249
- <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor">×</button>
249
+ <button id="closeSeat" type="button" class="icon-button" aria-label="Close seat editor"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
250
250
  </div>
251
251
  <div class="dialog-body seat-editor-body">
252
252
  <div id="seatLockNotice" class="seat-lock-notice hidden">Seat assignments are locked during a tournament.</div>
@@ -284,7 +284,7 @@
284
284
  <div class="dialog-title-row"><h2>Decision sanity suite</h2><button class="info-button" type="button" aria-label="About decision tests" data-tip="A small comprehension and reliability check. It is intentionally not a GTO solver, Elo score, or tournament-strength ranking.">i</button></div>
285
285
  <div class="dialog-subtitle">The same 10 fixed poker spots are sent independently to every seated model.</div>
286
286
  </div>
287
- <button id="closeTests" type="button" class="icon-button" aria-label="Close tests">×</button>
287
+ <button id="closeTests" type="button" class="icon-button" aria-label="Close tests"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
288
288
  </div>
289
289
  <div class="tests-body">
290
290
  <div class="tests-how" aria-label="How the suite works">
@@ -317,7 +317,7 @@
317
317
  <div class="dialog-title-row"><h2 id="replayTitle">Decision replay</h2><span id="replayBadge" class="replay-badge">HAND —</span></div>
318
318
  <div id="replaySubtitle" class="dialog-subtitle">Snapshot captured immediately before the model acted.</div>
319
319
  </div>
320
- <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay">×</button>
320
+ <button id="closeReplay" type="button" class="icon-button" aria-label="Close decision replay"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
321
321
  </div>
322
322
  <div class="dialog-body replay-body">
323
323
  <section class="replay-table-wrap">
@@ -361,7 +361,7 @@
361
361
  <span class="field-label-row">Preset <button class="info-button inline-info" type="button" aria-label="About connection presets" data-tip="OpenAI-compatible keeps Base URL fully editable. OpenRouter only fills its standard URL and enables OpenRouter-specific routing. TypeSafe uses the native System One endpoint.">i</button></span>
362
362
  <select data-field="kind"><option value="openai">OpenAI-compatible</option><option value="openrouter">OpenRouter</option><option value="typesafe">TypeSafe System One</option></select>
363
363
  </label>
364
- <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection">×</button>
364
+ <button type="button" class="remove-connection icon-button" title="Remove connection" aria-label="Remove connection"><svg class="icon-glyph" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
365
365
  </div>
366
366
  <div class="connection-main-grid">
367
367
  <label class="connection-url">Base URL<input data-field="baseUrl" spellcheck="false" placeholder="https://api.example.com/v1" /></label>
@@ -1000,8 +1000,6 @@ export function decide(agent, connection, args) {
1000
1000
  // ===========================================================================
1001
1001
  export const FAMILY_OBJECTIVE = 'Choose exactly one legal action family that best maximizes tournament chip EV from the supplied state. Do not choose an amount; a size is chosen in a separate step.';
1002
1002
  export const SIZE_OBJECTIVE = family => `The action family ${String(family).toUpperCase()} has already been selected. Choose exactly one legal ${family === ACTION_FAMILY.RAISE ? 'raise size' : 'bet size'} that best maximizes tournament chip EV. Do not change the action family.`;
1003
- export const SPECTATOR_NOTE = 'This model returns typed decisions rather than a text rationale.';
1004
-
1005
1003
  export function buildJevFamilyQuestions(families, { instructions = FAMILY_OBJECTIVE, criteria = null } = {}) {
1006
1004
  return { type: 'choice', instructions, criteria: criteria ?? familyCriteria(families) };
1007
1005
  }
package/src/styles.css CHANGED
@@ -839,7 +839,11 @@ input::placeholder{color:#4e5862}
839
839
  .button,.icon-button,.field-icon-button,input,select{box-sizing:border-box}
840
840
  .button{min-height:var(--control-h);padding:0 12px;border-radius:var(--radius-sm);font-size:10px;font-weight:800;line-height:1;white-space:nowrap}
841
841
  .small-button{min-height:30px;height:30px;padding-inline:10px;font-size:9px}
842
- .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0}
842
+ .icon-button{width:30px;height:30px;min-width:30px;border-radius:var(--radius-sm);display:grid;place-items:center;padding:0;line-height:1}
843
+ /* SVG glyphs center geometrically; a text "×" sits on its baseline and looks
844
+ vertically off-centre inside these small buttons. */
845
+ .icon-button svg{width:14px;height:14px;display:block;pointer-events:none}
846
+ .dialog-head .icon-button svg{width:13px;height:13px}
843
847
  input,select{width:100%;min-width:0;height:var(--control-h);padding:0 9px;border-radius:var(--radius-sm);font-size:11px;background:#0a0d10;border:1px solid var(--modal-border);color:var(--text)}
844
848
  input:focus,select:focus{outline:none;border-color:rgba(146,201,164,.34);box-shadow:0 0 0 3px rgba(146,201,164,.05)}
845
849
  label{min-width:0}
@@ -2265,10 +2269,7 @@ a.button{text-decoration:none}
2265
2269
  .card .card-corner.bottom{display:none!important}
2266
2270
  }
2267
2271
 
2268
- /* 0.2.23 — decision diagnostics + deterministic hand evaluation
2269
- Spectator-only annotations. The note explicitly separates typed model
2270
- answers from any fabricated rationale. */
2271
- .decision-facts-note{margin-top:6px;font-size:10px;line-height:1.4;color:var(--muted,#8aa098);border-left:2px solid rgba(184,236,111,.35);padding-left:8px}
2272
+ /* 0.2.23 — decision diagnostics + deterministic hand evaluation. */
2272
2273
  .replay-reason-text{margin-bottom:10px;line-height:1.45}
2273
2274
  .replay-summary span b{white-space:nowrap}
2274
2275
 
@@ -2283,7 +2284,6 @@ a.button{text-decoration:none}
2283
2284
  .stage-chip{white-space:nowrap}
2284
2285
  .telemetry-group{margin-top:6px}
2285
2286
  .telemetry-caption{display:block;font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted,#8aa098);margin-bottom:3px}
2286
- .decision-facts-note{white-space:normal}
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. */