From d5982f15f9db4fe4dee2aa3ad701f92e039481dc Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Sat, 16 May 2026 22:35:35 +0800 Subject: [PATCH] refactor: replay ui to terminal style --- .gitignore | 1 + texas_holdem_replay/static/app.js | 182 ++++++-- texas_holdem_replay/static/index.html | 196 ++++----- texas_holdem_replay/static/styles.css | 611 ++++++++++++++++++++++++++ 4 files changed, 848 insertions(+), 142 deletions(-) diff --git a/.gitignore b/.gitignore index 92259c6..8238c26 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ htmlcov/ .codex/ *.swp *.swo +.codex/ # debug resources debug/ diff --git a/texas_holdem_replay/static/app.js b/texas_holdem_replay/static/app.js index a9c18d2..6ac672b 100644 --- a/texas_holdem_replay/static/app.js +++ b/texas_holdem_replay/static/app.js @@ -9,11 +9,8 @@ // buildFrames(). Each frame carries the full visual state // (board cards, pot, players, active speaker, action kind), // making renderFrame() a pure function of state.frameIndex. -// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids. -// Per-player palette is derived deterministically from -// player_id so the same player always looks identical. -// * Animation — seat sprites get a transient `act-${kind}` class that maps -// to an avatar @keyframes animation in styles.css. +// * Terminal — renderFrame() writes a fixed-width command-line view into +//
, keeping all fetch/upload/replay processing unchanged.
 // =============================================================================
 
 const state = {
@@ -28,10 +25,13 @@ const state = {
   source: "",
 };
 
+const THEME_STORAGE_KEY = "txh-replay-theme";
+
 const el = {
   subtitle: document.querySelector("#subtitle"),
   sourceBadge: document.querySelector("#sourceBadge"),
   pollBadge: document.querySelector("#pollBadge"),
+  themeMode: document.querySelector("#themeMode"),
   serverUrl: document.querySelector("#serverUrl"),
   gameId: document.querySelector("#gameId"),
   fetchBtn: document.querySelector("#fetchBtn"),
@@ -52,9 +52,8 @@ const el = {
   handBadge: document.querySelector("#handBadge"),
   streetLabel: document.querySelector("#streetLabel"),
   potLabel: document.querySelector("#potLabel"),
-  boardCards: document.querySelector("#boardCards"),
-  tableMessage: document.querySelector("#tableMessage"),
-  seatLayer: document.querySelector("#seatLayer"),
+  frameLabel: document.querySelector("#frameLabel"),
+  tableOutput: document.querySelector("#tableOutput"),
   eventLog: document.querySelector("#eventLog"),
 };
 
@@ -528,28 +527,103 @@ function renderFrame() {
   const game = state.game;
   const hasData = Boolean(game && hand && frame);
 
-  el.handBadge.textContent = hasData ? `Hand ${hand.hand_number}` : "Hand -";
-  el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "未加载";
-  el.potLabel.textContent = hasData ? `Pot ${money(frame.pot)}` : "Pot 0";
-  el.tableMessage.textContent = hasData ? frame.message : "上传 JSON 或从游戏服务获取快照";
-  el.tableMessage.style.display = hasData ? "" : "block";
+  el.handBadge.textContent = hasData ? `hand ${hand.hand_number}` : "hand -";
+  el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "idle";
+  el.potLabel.textContent = hasData ? `pot ${money(frame.pot)}` : "pot 0";
+  el.frameLabel.textContent = hasData ? `frame ${state.frameIndex + 1}/${state.frames.length}` : "frame 0/0";
   el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
-
-  el.boardCards.replaceChildren();
-  for (const card of hasData ? frame.board : []) el.boardCards.appendChild(renderCard(card));
-
-  el.seatLayer.replaceChildren();
-  if (hasData) {
-    frame.players.forEach((player, index) => {
-      const position = seatPosition(index, frame.players.length);
-      el.seatLayer.appendChild(renderSeat(player, position, frame, hand));
-    });
-  }
+  el.tableOutput.textContent = hasData
+    ? renderTerminalFrame(game, hand, frame)
+    : [
+        "$ txh-replay --status idle",
+        "",
+        "source: none",
+        "event : waiting for game data",
+        "",
+        "load: fetch from a game service or upload a replay json snapshot",
+      ].join("\n");
 
   renderLog();
   syncControls();
 }
 
+function renderTerminalFrame(game, hand, frame) {
+  const active = frame.activePlayerId || "-";
+  const command = [
+    "$ txh-replay",
+    `--game ${game.game_id || "-"}`,
+    `--hand ${hand.hand_number}`,
+    `--frame ${state.frameIndex + 1}/${state.frames.length}`,
+  ].join(" ");
+  const lines = [
+    command,
+    "",
+    `source: ${state.source || "-"}`,
+    `status: ${game.status || "-"} | street: ${STREET_LABELS[frame.street] || frame.street} | pot: ${money(frame.pot)}`,
+    `board : ${formatCards(frame.board, 5)}`,
+    `event : ${frame.message}`,
+    `actor : ${active}`,
+    "",
+    "players",
+    `${pad("seat", 4)} ${pad("flag", 4)} ${pad("player", 22)} ${pad("stack", 10, "right")} ${pad("street", 10, "right")} ${pad("total", 10, "right")} cards`,
+    `${"-".repeat(4)} ${"-".repeat(4)} ${"-".repeat(22)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(10)} ${"-".repeat(13)}`,
+  ];
+
+  for (const player of frame.players) {
+    const playerName = player.name && player.name !== player.player_id
+      ? `${player.name}/${player.player_id}`
+      : player.player_id;
+    lines.push([
+      pad(player.seat, 4),
+      pad(playerFlags(player, hand, frame), 4),
+      pad(playerName, 22),
+      pad(money(player.stack), 10, "right"),
+      pad(money(player.street_bet), 10, "right"),
+      pad(money(player.total_bet), 10, "right"),
+      formatCards(knownCardsForPlayer(player, hand, frame), 2),
+    ].join(" "));
+  }
+
+  lines.push(
+    "",
+    "flags: > active | D dealer | F folded | A all-in",
+  );
+  return lines.join("\n");
+}
+
+function playerFlags(player, hand, frame) {
+  const flags = [];
+  if (player.player_id === frame.activePlayerId) flags.push(">");
+  if (player.seat === hand.button_seat) flags.push("D");
+  if (player.folded) flags.push("F");
+  if (player.all_in) flags.push("A");
+  return flags.join("") || ".";
+}
+
+function formatCards(cards, minimum = 0) {
+  const output = (cards || []).map(cardText);
+  while (output.length < minimum) output.push("[--]");
+  return output.join(" ");
+}
+
+function cardText(raw) {
+  if (!raw || raw === "back") return "[??]";
+  const parts = cardParts(raw);
+  return `[${parts.rank}${parts.suit}]`;
+}
+
+function pad(value, width, align = "left") {
+  const text = truncate(value, width);
+  const gap = Math.max(0, width - text.length);
+  return align === "right" ? `${" ".repeat(gap)}${text}` : `${text}${" ".repeat(gap)}`;
+}
+
+function truncate(value, width) {
+  const text = String(value ?? "");
+  if (text.length <= width) return text;
+  return `${text.slice(0, Math.max(0, width - 1))}~`;
+}
+
 function knownCardsForPlayer(player, hand, frame) {
   const futureHoleCards = hand.hole_cards?.[player.player_id] || hand.private_hands?.[player.player_id];
   const canRevealShowdown = ["showdown", "awards"].includes(frame.street) || ["showdown", "award"].includes(frame.type);
@@ -566,18 +640,12 @@ function knownCardsForPlayer(player, hand, frame) {
 // the `.past` class.
 // ---------------------------------------------------------------------------
 function renderLog() {
-  const hand = state.hands[state.currentHandIndex];
   el.eventLog.replaceChildren();
-  if (!hand) return;
-  const events = [
-    { text: `第 ${hand.hand_number} 手牌开始`, kind: "setup" },
-    ...(hand.actions || []).map((action) => ({ text: actionText(action), kind: "action" })),
-    ...(hand.awards || []).map((award) => ({ text: awardText(award), kind: "award" })),
-  ];
+  if (!state.frames.length) return;
   let currentLi = null;
-  events.forEach((event, index) => {
+  state.frames.forEach((frame, index) => {
     const li = document.createElement("li");
-    li.textContent = `${index + 1}. ${event.text}`;
+    li.textContent = `${String(index + 1).padStart(2, "0")} ${frame.type.padEnd(8, " ")} ${frame.message}`;
     if (index < state.frameIndex) li.classList.add("past");
     if (index === state.frameIndex) {
       li.classList.add("current");
@@ -588,7 +656,8 @@ function renderLog() {
   // Keep the focused event visible without yanking the page when an event
   // is already in view.
   if (currentLi) {
-    currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" });
+    const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
+    currentLi.scrollIntoView({ block: "nearest", behavior });
   }
 }
 
@@ -600,7 +669,7 @@ function syncControls() {
   el.prevBtn.disabled = !loaded || !hasPreviousFrame;
   el.nextBtn.disabled = !loaded || !hasNextFrame;
   el.resetBtn.disabled = !loaded;
-  el.playBtn.textContent = state.playing ? "Ⅱ" : "▶";
+  el.playBtn.textContent = state.playing ? "pause" : "play";
 }
 
 // ---------------------------------------------------------------------------
@@ -675,11 +744,13 @@ function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterH
 function renderSummary() {
   const game = state.game;
   const hand = state.hands[state.currentHandIndex];
-  const blinds = hand?.blinds || game;
+  const blinds = hand?.blinds || game?.blinds?.current || game?.blinds || game || {};
+  const smallBlind = blinds.small_blind ?? blinds.small ?? blinds.sb;
+  const bigBlind = blinds.big_blind ?? blinds.big ?? blinds.bb;
   el.gameStatus.textContent = game?.status || "-";
   el.playerCount.textContent = game?.players?.length ?? "-";
   el.handCount.textContent = game?.hands?.length ?? "-";
-  el.blindLevel.textContent = game ? `${money(blinds.small_blind)} / ${money(blinds.big_blind)}` : "-";
+  el.blindLevel.textContent = game ? `${money(smallBlind)} / ${money(bigBlind)}` : "-";
 }
 
 function populateHands() {
@@ -787,8 +858,13 @@ function setAutoPoll(enabled) {
 }
 
 function showMessage(message) {
-  el.tableMessage.textContent = message;
-  el.sourceBadge.textContent = "Error";
+  el.tableOutput.textContent = [
+    "$ txh-replay --status error",
+    "",
+    `error: ${message}`,
+  ].join("\n");
+  el.sourceBadge.textContent = "ERROR";
+  el.frameLabel.textContent = "frame 0/0";
 }
 
 function escapeHtml(value) {
@@ -800,9 +876,35 @@ function escapeHtml(value) {
     .replaceAll("'", "'");
 }
 
+function applyTheme(mode) {
+  const theme = ["auto", "dark", "light"].includes(mode) ? mode : "auto";
+  document.documentElement.dataset.theme = theme;
+  if (el.themeMode) el.themeMode.value = theme;
+}
+
+function initTheme() {
+  let stored = "auto";
+  try {
+    stored = window.localStorage.getItem(THEME_STORAGE_KEY) || "auto";
+  } catch {
+    stored = "auto";
+  }
+  applyTheme(stored);
+}
+
 // ---------------------------------------------------------------------------
 // Wiring.
 // ---------------------------------------------------------------------------
+el.themeMode.addEventListener("change", () => {
+  const mode = el.themeMode.value;
+  applyTheme(mode);
+  try {
+    window.localStorage.setItem(THEME_STORAGE_KEY, mode);
+  } catch {
+    // Ignore storage failures; the current session still applies the theme.
+  }
+});
+
 el.fetchBtn.addEventListener("click", () => {
   fetchFromServer().catch((error) => showMessage(error.message));
 });
@@ -829,4 +931,6 @@ el.prevBtn.addEventListener("click", () => prevFrame());
 el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
 window.addEventListener("resize", () => renderFrame());
 
+initTheme();
 syncControls();
+renderFrame();
diff --git a/texas_holdem_replay/static/index.html b/texas_holdem_replay/static/index.html
index 4af31d1..f724f69 100644
--- a/texas_holdem_replay/static/index.html
+++ b/texas_holdem_replay/static/index.html
@@ -1,5 +1,5 @@
 
-
+
   
     
     
@@ -8,121 +8,111 @@
   
   
     
-
-
- -
-

Texas Hold X Replay

-

等待加载游戏数据

-
+
+ -
- No Data - Auto Off +
+
+

Texas Hold X Replay

+

awaiting game snapshot

+
+
+ NO DATA + AUTO OFF + +
-
- -
-
-
- Hand - - 未加载 -
-
- Pot 0 -
+
+
+
$ /source
+ + +
+ +
-
- - -
-
-
上传 JSON 或从游戏服务获取快照
-
-
+
+ +
- -
-
-

数据源

- - -
- - -
-
- - -
+
+
$ /replay
+ + +
+ + + +
- -
-

回放

- - -
- - - - -
-
-
-
-
- -
-

牌局摘要

-
-
状态
-
-
玩家
-
-
总手数
-
-
盲注
-
-
+
-
diff --git a/texas_holdem_replay/static/styles.css b/texas_holdem_replay/static/styles.css index 3a9b5fc..abff425 100644 --- a/texas_holdem_replay/static/styles.css +++ b/texas_holdem_replay/static/styles.css @@ -733,3 +733,614 @@ h2 { margin-bottom: 12px; color: var(--gold); font-size: 15px; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ========================================================================= + Terminal replay UI + ========================================================================= */ + +:root { + color-scheme: dark; + --bg: #020617; + --bg-grid: rgba(34, 197, 94, 0.07); + --panel: #07111f; + --panel-strong: #0d1726; + --terminal: #030712; + --terminal-line: #1f2a37; + --text: #f8fafc; + --muted: #94a3b8; + --muted-2: #64748b; + --accent: #22c55e; + --accent-strong: #86efac; + --accent-ink: #04130a; + --amber: #f59e0b; + --red-term: #ef4444; + --blue-term: #38bdf8; + --border: #263445; + --border-strong: #3d5268; + --shadow-term: rgba(0, 0, 0, 0.38); + --radius: 8px; + --mono: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + --bg: #f5f7fb; + --bg-grid: rgba(4, 120, 87, 0.08); + --panel: #ffffff; + --panel-strong: #f1f5f9; + --terminal: #fbfdff; + --terminal-line: #d8e0ea; + --text: #0f172a; + --muted: #475569; + --muted-2: #64748b; + --accent: #047857; + --accent-strong: #065f46; + --accent-ink: #ecfdf5; + --amber: #b45309; + --red-term: #b91c1c; + --blue-term: #0369a1; + --border: #cbd5e1; + --border-strong: #94a3b8; + --shadow-term: rgba(15, 23, 42, 0.12); + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #020617; + --bg-grid: rgba(34, 197, 94, 0.07); + --panel: #07111f; + --panel-strong: #0d1726; + --terminal: #030712; + --terminal-line: #1f2a37; + --text: #f8fafc; + --muted: #94a3b8; + --muted-2: #64748b; + --accent: #22c55e; + --accent-strong: #86efac; + --accent-ink: #04130a; + --amber: #f59e0b; + --red-term: #ef4444; + --blue-term: #38bdf8; + --border: #263445; + --border-strong: #3d5268; + --shadow-term: rgba(0, 0, 0, 0.38); +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #f5f7fb; + --bg-grid: rgba(4, 120, 87, 0.08); + --panel: #ffffff; + --panel-strong: #f1f5f9; + --terminal: #fbfdff; + --terminal-line: #d8e0ea; + --text: #0f172a; + --muted: #475569; + --muted-2: #64748b; + --accent: #047857; + --accent-strong: #065f46; + --accent-ink: #ecfdf5; + --amber: #b45309; + --red-term: #b91c1c; + --blue-term: #0369a1; + --border: #cbd5e1; + --border-strong: #94a3b8; + --shadow-term: rgba(15, 23, 42, 0.12); +} + +html { min-height: 100%; } + +body { + min-height: 100vh; + margin: 0; + color: var(--text); + font-family: var(--mono); + font-size: 14px; + letter-spacing: 0; + background: + linear-gradient(var(--bg-grid) 1px, transparent 1px) 0 0 / 28px 28px, + linear-gradient(90deg, var(--bg-grid) 1px, transparent 1px) 0 0 / 28px 28px, + var(--bg); + image-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: auto; +} + +button, +input, +select { + font: inherit; +} + +button, +.file-btn { + min-height: 38px; + border: 1px solid var(--border-strong); + border-radius: 6px; + color: var(--text); + background: var(--panel-strong); + box-shadow: none; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; +} + +button:hover, +.file-btn:hover { + border-color: var(--accent); + color: var(--accent-strong); + filter: none; +} + +button:active, +.file-btn:active { + transform: none; + box-shadow: inset 0 0 0 1px var(--accent); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.primary-btn { + color: var(--accent-ink); + background: var(--accent); + border-color: var(--accent); + font-weight: 700; +} + +.primary-btn:hover { + color: var(--accent-ink); + background: var(--accent-strong); +} + +input, +select { + width: 100%; + min-height: 38px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + background: var(--terminal); + padding: 8px 10px; + outline: none; +} + +input[type="range"] { + padding: 0; + accent-color: var(--accent); +} + +input[type="checkbox"] { + width: 16px; + min-height: 16px; + accent-color: var(--accent); +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +.file-btn:focus-within, +.terminal-screen:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +h1, +p, +dl { + margin: 0; +} + +h1 { + color: var(--text); + font-size: clamp(20px, 2vw, 28px); + font-weight: 700; + line-height: 1.1; +} + +.app-shell { + width: min(1680px, 100%); + margin: 0 auto; + padding: 18px; +} + +.terminal-header, +.terminal-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--panel) 92%, transparent); + box-shadow: 0 18px 48px var(--shadow-term); +} + +.terminal-header { + overflow: hidden; +} + +.window-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 0 14px; + border-bottom: 1px solid var(--border); + color: var(--muted); + background: var(--panel-strong); +} + +.window-dot { + width: 11px; + height: 11px; + border-radius: 50%; +} + +.window-dot.red { background: var(--red-term); } +.window-dot.yellow { background: var(--amber); } +.window-dot.green { background: var(--accent); } + +.window-title { + margin-left: 6px; + white-space: nowrap; +} + +.title-row { + display: flex; + justify-content: space-between; + align-items: end; + gap: 18px; + padding: 18px; +} + +#subtitle { + margin-top: 6px; + color: var(--muted); + font-size: 13px; +} + +.status-strip { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: end; + gap: 10px; +} + +.status-pill, +.prompt-chip, +.frame-label { + display: inline-flex; + align-items: center; + min-height: 28px; + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px; + color: var(--accent-strong); + background: color-mix(in srgb, var(--accent) 12%, transparent); + white-space: nowrap; +} + +.theme-control { + display: grid; + gap: 4px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + +.theme-control select { + min-height: 28px; + padding: 3px 28px 3px 8px; +} + +.terminal-grid { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) minmax(300px, 380px); + grid-template-areas: + "source display log" + "controls display log" + "summary display log"; + gap: 14px; + margin-top: 14px; + align-items: stretch; +} + +.source-panel { grid-area: source; } +.controls-panel { grid-area: controls; } +.summary-panel { grid-area: summary; } +.display-panel { grid-area: display; min-width: 0; } +.log-panel { grid-area: log; min-width: 0; } + +.terminal-panel { + padding: 14px; +} + +.panel-title { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 14px; + color: var(--text); + font-weight: 700; +} + +.panel-title span { + color: var(--accent); +} + +.terminal-panel label { + display: grid; + gap: 6px; + margin-bottom: 12px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.button-row, +.transport-row, +.auto-grid { + display: grid; + gap: 8px; +} + +.button-row { + grid-template-columns: 1fr 1fr; +} + +.transport-row { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.auto-grid { + grid-template-columns: minmax(0, 1fr) 92px; + align-items: end; + margin-top: 12px; +} + +.toggle-line { + display: flex !important; + flex-direction: row; + align-items: center; + min-height: 38px; +} + +.file-btn { + display: grid; + place-items: center; + position: relative; + overflow: hidden; + text-align: center; +} + +.file-btn input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.progress-shell { + height: 10px; + margin-top: 14px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--terminal); +} + +#progressBar { + width: 0; + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--blue-term), var(--amber)); + transition: width 180ms linear; +} + +.stat-list { + display: grid; + gap: 9px; +} + +.stat-list div { + display: grid; + grid-template-columns: minmax(80px, 1fr) minmax(0, 1.4fr); + gap: 12px; + padding-bottom: 9px; + border-bottom: 1px solid var(--border); +} + +.stat-list dt { + color: var(--muted); +} + +.stat-list dd { + margin: 0; + overflow: hidden; + color: var(--text); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.display-panel { + display: flex; + flex-direction: column; +} + +.display-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.display-head > div { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.frame-label { + color: var(--muted); + background: transparent; +} + +.terminal-screen { + flex: 1 1 auto; + min-height: clamp(460px, 68vh, 780px); + overflow: auto; + border: 1px solid var(--terminal-line); + border-radius: var(--radius); + background: + linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, transparent), transparent 120px), + var(--terminal); + scrollbar-width: thin; + scrollbar-color: var(--accent) var(--terminal); +} + +.table-output { + min-width: 860px; + margin: 0; + padding: 18px; + color: var(--text); + font-family: var(--mono); + font-size: clamp(12px, 0.86vw, 14px); + line-height: 1.55; + letter-spacing: 0; + white-space: pre; + tab-size: 2; +} + +.terminal-screen::-webkit-scrollbar, +.event-log::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.terminal-screen::-webkit-scrollbar-track, +.event-log::-webkit-scrollbar-track { + background: var(--terminal); +} + +.terminal-screen::-webkit-scrollbar-thumb, +.event-log::-webkit-scrollbar-thumb { + border: 2px solid var(--terminal); + border-radius: 999px; + background: var(--accent); +} + +.event-log { + display: grid; + gap: 8px; + max-height: clamp(460px, 78vh, 860px); + margin: 0; + padding: 0 2px 0 0; + overflow: auto; + list-style: none; + scrollbar-width: thin; + scrollbar-color: var(--accent) var(--terminal); +} + +.event-log li { + border: 1px solid var(--border); + border-left: 3px solid var(--border-strong); + border-radius: 6px; + padding: 8px 10px; + color: var(--muted); + background: var(--terminal); + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.event-log li.current { + border-left-color: var(--accent); + color: var(--text); + background: color-mix(in srgb, var(--accent) 10%, var(--terminal)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent); +} + +.event-log li.past { + opacity: 0.72; +} + +::selection { + color: var(--accent-ink); + background: var(--accent); +} + +@media (max-width: 1180px) { + .terminal-grid { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-areas: + "display display" + "source controls" + "summary log"; + } + + .event-log { + max-height: 340px; + } +} + +@media (max-width: 760px) { + .app-shell { + padding: 10px; + } + + .title-row { + display: grid; + align-items: start; + } + + .status-strip { + justify-content: flex-start; + } + + .terminal-grid { + grid-template-columns: 1fr; + grid-template-areas: + "display" + "source" + "controls" + "summary" + "log"; + } + + .terminal-panel { + padding: 12px; + } + + .button-row, + .auto-grid, + .transport-row { + grid-template-columns: 1fr; + } + + .display-head { + align-items: flex-start; + flex-direction: column; + } + + .terminal-screen { + min-height: 420px; + } + + .table-output { + min-width: 760px; + padding: 14px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +}