Compare commits
1 Commits
main
...
9697c24276
| Author | SHA1 | Date | |
|---|---|---|---|
| 9697c24276 |
@@ -34,6 +34,7 @@ htmlcov/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.codex/
|
||||
|
||||
# debug resources
|
||||
debug/
|
||||
|
||||
@@ -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
|
||||
// <pre>, 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();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -8,121 +8,111 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand-lockup">
|
||||
<div class="chip-mark" aria-hidden="true">TX</div>
|
||||
<div class="brand-meta">
|
||||
<h1>Texas Hold X Replay</h1>
|
||||
<p id="subtitle">等待加载游戏数据</p>
|
||||
</div>
|
||||
<header class="terminal-header">
|
||||
<div class="window-row" aria-hidden="true">
|
||||
<span class="window-dot red"></span>
|
||||
<span class="window-dot yellow"></span>
|
||||
<span class="window-dot green"></span>
|
||||
<span class="window-title">txh-replay@web:~</span>
|
||||
</div>
|
||||
<div class="status-strip" aria-live="polite">
|
||||
<span id="sourceBadge" class="badge badge-gold">No Data</span>
|
||||
<span id="pollBadge" class="badge badge-blue">Auto Off</span>
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main class="layout-grid">
|
||||
<!-- Stage zone: pure visualization (table, seats, animations).
|
||||
Placed first in DOM so mobile/tablet layouts keep it on top. -->
|
||||
<section class="stage-zone" aria-label="牌桌动画回放">
|
||||
<div class="stage-head">
|
||||
<div class="stage-head-left">
|
||||
<span id="handBadge" class="badge badge-gold">Hand -</span>
|
||||
<strong id="streetLabel">未加载</strong>
|
||||
</div>
|
||||
<div class="stage-head-right">
|
||||
<span id="potLabel" class="badge badge-gold">Pot 0</span>
|
||||
</div>
|
||||
<main class="terminal-grid">
|
||||
<section class="terminal-panel source-panel" aria-label="数据源">
|
||||
<div class="panel-title"><span>$</span> /source</div>
|
||||
<label>
|
||||
<span>game service</span>
|
||||
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
|
||||
</label>
|
||||
<label>
|
||||
<span>game id</span>
|
||||
<input id="gameId" type="text" value="game1" placeholder="game1" />
|
||||
</label>
|
||||
<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 id="table" class="poker-table">
|
||||
<!-- felt-shell encapsulates the rounded green felt with overflow:hidden,
|
||||
so player speech bubbles drawn in seat-layer can overflow freely
|
||||
above and below the table without being clipped. -->
|
||||
<div class="felt-shell" aria-hidden="true">
|
||||
<div class="felt-rail"></div>
|
||||
<div class="felt-surface">
|
||||
<div class="felt-grid"></div>
|
||||
<div class="felt-glow"></div>
|
||||
<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 class="auto-grid">
|
||||
<label class="toggle-line">
|
||||
<input id="autoPoll" type="checkbox" />
|
||||
<span>auto poll</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>seconds</span>
|
||||
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Interaction zone: data source + replay controls + summary. -->
|
||||
<section class="control-panel" aria-label="数据与播放控制">
|
||||
<div class="panel-section">
|
||||
<h2>数据源</h2>
|
||||
<label>
|
||||
<span>游戏服务</span>
|
||||
<input id="serverUrl" type="url" value="http://127.0.0.1:8000" placeholder="http://127.0.0.1:8000" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Game ID</span>
|
||||
<input id="gameId" type="text" value="game1" placeholder="game1" />
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button id="fetchBtn" class="primary-btn" type="button">获取</button>
|
||||
<label class="file-btn">
|
||||
上传 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>
|
||||
<section class="terminal-panel controls-panel" aria-label="回放控制">
|
||||
<div class="panel-title"><span>$</span> /replay</div>
|
||||
<label>
|
||||
<span>hand</span>
|
||||
<select id="handSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>pace</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">prev</button>
|
||||
<button id="playBtn" class="primary-btn" type="button">play</button>
|
||||
<button id="nextBtn" type="button">next</button>
|
||||
<button id="resetBtn" type="button">reset</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<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 class="progress-shell" aria-hidden="true">
|
||||
<div id="progressBar"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="event-panel" aria-label="事件日志">
|
||||
<div class="panel-section">
|
||||
<h2>事件</h2>
|
||||
<ol id="eventLog" class="event-log"></ol>
|
||||
<section class="terminal-panel summary-panel" aria-label="牌局摘要">
|
||||
<div class="panel-title"><span>$</span> /state</div>
|
||||
<dl class="stat-list">
|
||||
<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 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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user