Compare commits
2 Commits
9697c24276
...
d5982f15f9
| Author | SHA1 | Date | |
|---|---|---|---|
| d5982f15f9 | |||
| 1ee963ce2e |
@@ -32,8 +32,10 @@ htmlcov/
|
|||||||
# IDE and editor files
|
# IDE and editor files
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.codex/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
.codex/
|
||||||
|
|
||||||
# debug resources
|
# debug resources
|
||||||
debug/
|
debug/
|
||||||
|
|||||||
@@ -9,11 +9,8 @@
|
|||||||
// buildFrames(). Each frame carries the full visual state
|
// buildFrames(). Each frame carries the full visual state
|
||||||
// (board cards, pot, players, active speaker, action kind),
|
// (board cards, pot, players, active speaker, action kind),
|
||||||
// making renderFrame() a pure function of state.frameIndex.
|
// making renderFrame() a pure function of state.frameIndex.
|
||||||
// * Avatars — pixel-art sprites styled in CSS via 8x8 box-shadow grids.
|
// * Terminal — renderFrame() writes a fixed-width command-line view into
|
||||||
// Per-player palette is derived deterministically from
|
// <pre>, keeping all fetch/upload/replay processing unchanged.
|
||||||
// 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.
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
@@ -28,10 +25,13 @@ const state = {
|
|||||||
source: "",
|
source: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "txh-replay-theme";
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
subtitle: document.querySelector("#subtitle"),
|
subtitle: document.querySelector("#subtitle"),
|
||||||
sourceBadge: document.querySelector("#sourceBadge"),
|
sourceBadge: document.querySelector("#sourceBadge"),
|
||||||
pollBadge: document.querySelector("#pollBadge"),
|
pollBadge: document.querySelector("#pollBadge"),
|
||||||
|
themeMode: document.querySelector("#themeMode"),
|
||||||
serverUrl: document.querySelector("#serverUrl"),
|
serverUrl: document.querySelector("#serverUrl"),
|
||||||
gameId: document.querySelector("#gameId"),
|
gameId: document.querySelector("#gameId"),
|
||||||
fetchBtn: document.querySelector("#fetchBtn"),
|
fetchBtn: document.querySelector("#fetchBtn"),
|
||||||
@@ -52,9 +52,8 @@ const el = {
|
|||||||
handBadge: document.querySelector("#handBadge"),
|
handBadge: document.querySelector("#handBadge"),
|
||||||
streetLabel: document.querySelector("#streetLabel"),
|
streetLabel: document.querySelector("#streetLabel"),
|
||||||
potLabel: document.querySelector("#potLabel"),
|
potLabel: document.querySelector("#potLabel"),
|
||||||
boardCards: document.querySelector("#boardCards"),
|
frameLabel: document.querySelector("#frameLabel"),
|
||||||
tableMessage: document.querySelector("#tableMessage"),
|
tableOutput: document.querySelector("#tableOutput"),
|
||||||
seatLayer: document.querySelector("#seatLayer"),
|
|
||||||
eventLog: document.querySelector("#eventLog"),
|
eventLog: document.querySelector("#eventLog"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -528,28 +527,103 @@ function renderFrame() {
|
|||||||
const game = state.game;
|
const game = state.game;
|
||||||
const hasData = Boolean(game && hand && frame);
|
const hasData = Boolean(game && hand && frame);
|
||||||
|
|
||||||
el.handBadge.textContent = hasData ? `Hand ${hand.hand_number}` : "Hand -";
|
el.handBadge.textContent = hasData ? `hand ${hand.hand_number}` : "hand -";
|
||||||
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "未加载";
|
el.streetLabel.textContent = hasData ? (STREET_LABELS[frame.street] || frame.street) : "idle";
|
||||||
el.potLabel.textContent = hasData ? `Pot ${money(frame.pot)}` : "Pot 0";
|
el.potLabel.textContent = hasData ? `pot ${money(frame.pot)}` : "pot 0";
|
||||||
el.tableMessage.textContent = hasData ? frame.message : "上传 JSON 或从游戏服务获取快照";
|
el.frameLabel.textContent = hasData ? `frame ${state.frameIndex + 1}/${state.frames.length}` : "frame 0/0";
|
||||||
el.tableMessage.style.display = hasData ? "" : "block";
|
|
||||||
el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
|
el.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
|
||||||
|
el.tableOutput.textContent = hasData
|
||||||
el.boardCards.replaceChildren();
|
? renderTerminalFrame(game, hand, frame)
|
||||||
for (const card of hasData ? frame.board : []) el.boardCards.appendChild(renderCard(card));
|
: [
|
||||||
|
"$ txh-replay --status idle",
|
||||||
el.seatLayer.replaceChildren();
|
"",
|
||||||
if (hasData) {
|
"source: none",
|
||||||
frame.players.forEach((player, index) => {
|
"event : waiting for game data",
|
||||||
const position = seatPosition(index, frame.players.length);
|
"",
|
||||||
el.seatLayer.appendChild(renderSeat(player, position, frame, hand));
|
"load: fetch from a game service or upload a replay json snapshot",
|
||||||
});
|
].join("\n");
|
||||||
}
|
|
||||||
|
|
||||||
renderLog();
|
renderLog();
|
||||||
syncControls();
|
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) {
|
function knownCardsForPlayer(player, hand, frame) {
|
||||||
const futureHoleCards = hand.hole_cards?.[player.player_id] || hand.private_hands?.[player.player_id];
|
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);
|
const canRevealShowdown = ["showdown", "awards"].includes(frame.street) || ["showdown", "award"].includes(frame.type);
|
||||||
@@ -566,18 +640,12 @@ function knownCardsForPlayer(player, hand, frame) {
|
|||||||
// the `.past` class.
|
// the `.past` class.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function renderLog() {
|
function renderLog() {
|
||||||
const hand = state.hands[state.currentHandIndex];
|
|
||||||
el.eventLog.replaceChildren();
|
el.eventLog.replaceChildren();
|
||||||
if (!hand) return;
|
if (!state.frames.length) 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" })),
|
|
||||||
];
|
|
||||||
let currentLi = null;
|
let currentLi = null;
|
||||||
events.forEach((event, index) => {
|
state.frames.forEach((frame, index) => {
|
||||||
const li = document.createElement("li");
|
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("past");
|
||||||
if (index === state.frameIndex) {
|
if (index === state.frameIndex) {
|
||||||
li.classList.add("current");
|
li.classList.add("current");
|
||||||
@@ -588,7 +656,8 @@ function renderLog() {
|
|||||||
// Keep the focused event visible without yanking the page when an event
|
// Keep the focused event visible without yanking the page when an event
|
||||||
// is already in view.
|
// is already in view.
|
||||||
if (currentLi) {
|
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.prevBtn.disabled = !loaded || !hasPreviousFrame;
|
||||||
el.nextBtn.disabled = !loaded || !hasNextFrame;
|
el.nextBtn.disabled = !loaded || !hasNextFrame;
|
||||||
el.resetBtn.disabled = !loaded;
|
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() {
|
function renderSummary() {
|
||||||
const game = state.game;
|
const game = state.game;
|
||||||
const hand = state.hands[state.currentHandIndex];
|
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.gameStatus.textContent = game?.status || "-";
|
||||||
el.playerCount.textContent = game?.players?.length ?? "-";
|
el.playerCount.textContent = game?.players?.length ?? "-";
|
||||||
el.handCount.textContent = game?.hands?.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() {
|
function populateHands() {
|
||||||
@@ -787,8 +858,13 @@ function setAutoPoll(enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(message) {
|
function showMessage(message) {
|
||||||
el.tableMessage.textContent = message;
|
el.tableOutput.textContent = [
|
||||||
el.sourceBadge.textContent = "Error";
|
"$ txh-replay --status error",
|
||||||
|
"",
|
||||||
|
`error: ${message}`,
|
||||||
|
].join("\n");
|
||||||
|
el.sourceBadge.textContent = "ERROR";
|
||||||
|
el.frameLabel.textContent = "frame 0/0";
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -800,9 +876,35 @@ function escapeHtml(value) {
|
|||||||
.replaceAll("'", "'");
|
.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.
|
// 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", () => {
|
el.fetchBtn.addEventListener("click", () => {
|
||||||
fetchFromServer().catch((error) => showMessage(error.message));
|
fetchFromServer().catch((error) => showMessage(error.message));
|
||||||
});
|
});
|
||||||
@@ -829,4 +931,6 @@ el.prevBtn.addEventListener("click", () => prevFrame());
|
|||||||
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
|
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
|
||||||
window.addEventListener("resize", () => renderFrame());
|
window.addEventListener("resize", () => renderFrame());
|
||||||
|
|
||||||
|
initTheme();
|
||||||
syncControls();
|
syncControls();
|
||||||
|
renderFrame();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="auto">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -8,121 +8,111 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="topbar">
|
<header class="terminal-header">
|
||||||
<div class="brand-lockup">
|
<div class="window-row" aria-hidden="true">
|
||||||
<div class="chip-mark" aria-hidden="true">TX</div>
|
<span class="window-dot red"></span>
|
||||||
<div class="brand-meta">
|
<span class="window-dot yellow"></span>
|
||||||
<h1>Texas Hold X Replay</h1>
|
<span class="window-dot green"></span>
|
||||||
<p id="subtitle">等待加载游戏数据</p>
|
<span class="window-title">txh-replay@web:~</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="status-strip" aria-live="polite">
|
<div class="title-row">
|
||||||
<span id="sourceBadge" class="badge badge-gold">No Data</span>
|
<div>
|
||||||
<span id="pollBadge" class="badge badge-blue">Auto Off</span>
|
<h1>Texas Hold X Replay</h1>
|
||||||
|
<p id="subtitle">awaiting game snapshot</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-strip" aria-live="polite">
|
||||||
|
<span id="sourceBadge" class="status-pill">NO DATA</span>
|
||||||
|
<span id="pollBadge" class="status-pill">AUTO OFF</span>
|
||||||
|
<label class="theme-control">
|
||||||
|
<span>theme</span>
|
||||||
|
<select id="themeMode" aria-label="Theme mode">
|
||||||
|
<option value="auto">auto</option>
|
||||||
|
<option value="dark">dark</option>
|
||||||
|
<option value="light">light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout-grid">
|
<main class="terminal-grid">
|
||||||
<!-- Stage zone: pure visualization (table, seats, animations).
|
<section class="terminal-panel source-panel" aria-label="数据源">
|
||||||
Placed first in DOM so mobile/tablet layouts keep it on top. -->
|
<div class="panel-title"><span>$</span> /source</div>
|
||||||
<section class="stage-zone" aria-label="牌桌动画回放">
|
<label>
|
||||||
<div class="stage-head">
|
<span>game service</span>
|
||||||
<div class="stage-head-left">
|
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
|
||||||
<span id="handBadge" class="badge badge-gold">Hand -</span>
|
</label>
|
||||||
<strong id="streetLabel">未加载</strong>
|
<label>
|
||||||
</div>
|
<span>game id</span>
|
||||||
<div class="stage-head-right">
|
<input id="gameId" type="text" value="game1" placeholder="game1" />
|
||||||
<span id="potLabel" class="badge badge-gold">Pot 0</span>
|
</label>
|
||||||
</div>
|
<div class="button-row">
|
||||||
|
<button id="fetchBtn" class="primary-btn" type="button">fetch</button>
|
||||||
|
<label class="file-btn">
|
||||||
|
upload json
|
||||||
|
<input id="fileInput" type="file" accept="application/json,.json" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="table" class="poker-table">
|
<div class="auto-grid">
|
||||||
<!-- felt-shell encapsulates the rounded green felt with overflow:hidden,
|
<label class="toggle-line">
|
||||||
so player speech bubbles drawn in seat-layer can overflow freely
|
<input id="autoPoll" type="checkbox" />
|
||||||
above and below the table without being clipped. -->
|
<span>auto poll</span>
|
||||||
<div class="felt-shell" aria-hidden="true">
|
</label>
|
||||||
<div class="felt-rail"></div>
|
<label>
|
||||||
<div class="felt-surface">
|
<span>seconds</span>
|
||||||
<div class="felt-grid"></div>
|
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
|
||||||
<div class="felt-glow"></div>
|
</label>
|
||||||
<div class="felt-mark">TX</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="community-area">
|
|
||||||
<div id="boardCards" class="card-row board"></div>
|
|
||||||
<div id="tableMessage" class="table-message">上传 JSON 或从游戏服务获取快照</div>
|
|
||||||
</div>
|
|
||||||
<div id="seatLayer" class="seat-layer"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Interaction zone: data source + replay controls + summary. -->
|
<section class="terminal-panel controls-panel" aria-label="回放控制">
|
||||||
<section class="control-panel" aria-label="数据与播放控制">
|
<div class="panel-title"><span>$</span> /replay</div>
|
||||||
<div class="panel-section">
|
<label>
|
||||||
<h2>数据源</h2>
|
<span>hand</span>
|
||||||
<label>
|
<select id="handSelect"></select>
|
||||||
<span>游戏服务</span>
|
</label>
|
||||||
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
|
<label>
|
||||||
</label>
|
<span>pace</span>
|
||||||
<label>
|
<input id="pace" type="range" min="0.75" max="1.8" step="0.05" value="1" />
|
||||||
<span>Game ID</span>
|
</label>
|
||||||
<input id="gameId" type="text" value="game1" placeholder="game1" />
|
<div class="transport-row">
|
||||||
</label>
|
<button id="prevBtn" type="button">prev</button>
|
||||||
<div class="button-row">
|
<button id="playBtn" class="primary-btn" type="button">play</button>
|
||||||
<button id="fetchBtn" class="primary-btn" type="button">获取</button>
|
<button id="nextBtn" type="button">next</button>
|
||||||
<label class="file-btn">
|
<button id="resetBtn" type="button">reset</button>
|
||||||
上传 JSON
|
|
||||||
<input id="fileInput" type="file" accept="application/json,.json" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="auto-grid">
|
|
||||||
<label class="toggle-line">
|
|
||||||
<input id="autoPoll" type="checkbox" />
|
|
||||||
<span>自动获取</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>间隔秒</span>
|
|
||||||
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="progress-shell" aria-hidden="true">
|
||||||
<div class="panel-section">
|
<div id="progressBar"></div>
|
||||||
<h2>回放</h2>
|
|
||||||
<label>
|
|
||||||
<span>手牌</span>
|
|
||||||
<select id="handSelect"></select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>节奏</span>
|
|
||||||
<input id="pace" type="range" min="0.75" max="1.8" step="0.05" value="1" />
|
|
||||||
</label>
|
|
||||||
<div class="transport-row">
|
|
||||||
<button id="prevBtn" type="button" title="上一帧">◀</button>
|
|
||||||
<button id="playBtn" class="primary-btn" type="button" title="播放/暂停">▶</button>
|
|
||||||
<button id="nextBtn" type="button" title="下一帧">▶</button>
|
|
||||||
<button id="resetBtn" type="button" title="重置">↺</button>
|
|
||||||
</div>
|
|
||||||
<div class="progress-wrap">
|
|
||||||
<div id="progressBar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-section dense">
|
|
||||||
<h2>牌局摘要</h2>
|
|
||||||
<dl class="stat-list">
|
|
||||||
<div><dt>状态</dt><dd id="gameStatus">-</dd></div>
|
|
||||||
<div><dt>玩家</dt><dd id="playerCount">-</dd></div>
|
|
||||||
<div><dt>总手数</dt><dd id="handCount">-</dd></div>
|
|
||||||
<div><dt>盲注</dt><dd id="blindLevel">-</dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="event-panel" aria-label="事件日志">
|
<section class="terminal-panel summary-panel" aria-label="牌局摘要">
|
||||||
<div class="panel-section">
|
<div class="panel-title"><span>$</span> /state</div>
|
||||||
<h2>事件</h2>
|
<dl class="stat-list">
|
||||||
<ol id="eventLog" class="event-log"></ol>
|
<div><dt>status</dt><dd id="gameStatus">-</dd></div>
|
||||||
|
<div><dt>players</dt><dd id="playerCount">-</dd></div>
|
||||||
|
<div><dt>hands</dt><dd id="handCount">-</dd></div>
|
||||||
|
<div><dt>blinds</dt><dd id="blindLevel">-</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="terminal-panel display-panel" aria-label="命令行牌局回放">
|
||||||
|
<div class="display-head">
|
||||||
|
<div>
|
||||||
|
<span id="handBadge" class="prompt-chip">hand -</span>
|
||||||
|
<span id="streetLabel" class="prompt-chip">idle</span>
|
||||||
|
<span id="potLabel" class="prompt-chip">pot 0</span>
|
||||||
|
</div>
|
||||||
|
<span id="frameLabel" class="frame-label">frame 0/0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="terminal-screen" tabindex="0">
|
||||||
|
<pre id="tableOutput" class="table-output">txh-replay> waiting for game data</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="terminal-panel log-panel" aria-label="事件日志">
|
||||||
|
<div class="panel-title"><span>$</span> /events</div>
|
||||||
|
<ol id="eventLog" class="event-log"></ol>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -733,3 +733,614 @@ h2 { margin-bottom: 12px; color: var(--gold); font-size: 15px; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
*, *::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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user