1 Commits

Author SHA1 Message Date
mamamiyear 1ee963ce2e chore: add .codex to .gitignore 2026-05-17 11:23:21 +08:00
4 changed files with 142 additions and 847 deletions
+1 -1
View File
@@ -32,9 +32,9 @@ htmlcov/
# IDE and editor files
.idea/
.vscode/
.codex/
*.swp
*.swo
.codex/
# debug resources
debug/
+39 -143
View File
@@ -9,8 +9,11 @@
// buildFrames(). Each frame carries the full visual state
// (board cards, pot, players, active speaker, action kind),
// making renderFrame() a pure function of state.frameIndex.
// * Terminal — renderFrame() writes a fixed-width command-line view into
// <pre>, keeping all fetch/upload/replay processing unchanged.
// * 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.
// =============================================================================
const state = {
@@ -25,13 +28,10 @@ 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,8 +52,9 @@ const el = {
handBadge: document.querySelector("#handBadge"),
streetLabel: document.querySelector("#streetLabel"),
potLabel: document.querySelector("#potLabel"),
frameLabel: document.querySelector("#frameLabel"),
tableOutput: document.querySelector("#tableOutput"),
boardCards: document.querySelector("#boardCards"),
tableMessage: document.querySelector("#tableMessage"),
seatLayer: document.querySelector("#seatLayer"),
eventLog: document.querySelector("#eventLog"),
};
@@ -527,103 +528,28 @@ 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) : "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.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.progressBar.style.width = hasData ? `${((state.frameIndex + 1) / state.frames.length) * 100}%` : "0";
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");
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));
});
}
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);
@@ -640,12 +566,18 @@ function knownCardsForPlayer(player, hand, frame) {
// the `.past` class.
// ---------------------------------------------------------------------------
function renderLog() {
const hand = state.hands[state.currentHandIndex];
el.eventLog.replaceChildren();
if (!state.frames.length) return;
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" })),
];
let currentLi = null;
state.frames.forEach((frame, index) => {
events.forEach((event, index) => {
const li = document.createElement("li");
li.textContent = `${String(index + 1).padStart(2, "0")} ${frame.type.padEnd(8, " ")} ${frame.message}`;
li.textContent = `${index + 1}. ${event.text}`;
if (index < state.frameIndex) li.classList.add("past");
if (index === state.frameIndex) {
li.classList.add("current");
@@ -656,8 +588,7 @@ function renderLog() {
// Keep the focused event visible without yanking the page when an event
// is already in view.
if (currentLi) {
const behavior = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
currentLi.scrollIntoView({ block: "nearest", behavior });
currentLi.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
@@ -669,7 +600,7 @@ function syncControls() {
el.prevBtn.disabled = !loaded || !hasPreviousFrame;
el.nextBtn.disabled = !loaded || !hasNextFrame;
el.resetBtn.disabled = !loaded;
el.playBtn.textContent = state.playing ? "pause" : "play";
el.playBtn.textContent = state.playing ? "" : "";
}
// ---------------------------------------------------------------------------
@@ -744,13 +675,11 @@ function shouldStepIntoIncrement(wasAtTail, hadNewFramesOnCurrentHand, hasLaterH
function renderSummary() {
const game = state.game;
const hand = state.hands[state.currentHandIndex];
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;
const blinds = hand?.blinds || game;
el.gameStatus.textContent = game?.status || "-";
el.playerCount.textContent = game?.players?.length ?? "-";
el.handCount.textContent = game?.hands?.length ?? "-";
el.blindLevel.textContent = game ? `${money(smallBlind)} / ${money(bigBlind)}` : "-";
el.blindLevel.textContent = game ? `${money(blinds.small_blind)} / ${money(blinds.big_blind)}` : "-";
}
function populateHands() {
@@ -858,13 +787,8 @@ function setAutoPoll(enabled) {
}
function showMessage(message) {
el.tableOutput.textContent = [
"$ txh-replay --status error",
"",
`error: ${message}`,
].join("\n");
el.sourceBadge.textContent = "ERROR";
el.frameLabel.textContent = "frame 0/0";
el.tableMessage.textContent = message;
el.sourceBadge.textContent = "Error";
}
function escapeHtml(value) {
@@ -876,35 +800,9 @@ function escapeHtml(value) {
.replaceAll("'", "&#039;");
}
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));
});
@@ -931,6 +829,4 @@ el.prevBtn.addEventListener("click", () => prevFrame());
el.resetBtn.addEventListener("click", () => selectHand(state.currentHandIndex));
window.addEventListener("resize", () => renderFrame());
initTheme();
syncControls();
renderFrame();
+102 -92
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN" data-theme="auto">
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -8,111 +8,121 @@
</head>
<body>
<div class="app-shell">
<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="title-row">
<div>
<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">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>
<p id="subtitle">等待加载游戏数据</p>
</div>
</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>
</header>
<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>
<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>
</div>
<div class="auto-grid">
<label class="toggle-line">
<input id="autoPoll" type="checkbox" />
<span>auto poll</span>
<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>
</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>seconds</span>
<input id="pollSeconds" type="number" min="5" max="300" value="12" />
<span>Game ID</span>
<input id="gameId" type="text" value="game1" placeholder="game1" />
</label>
</div>
</section>
<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="progress-shell" aria-hidden="true">
<div id="progressBar"></div>
</div>
</section>
<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 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>
<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&gt; waiting for game data</pre>
<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>
</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 class="event-panel" aria-label="事件日志">
<div class="panel-section">
<h2>事件</h2>
<ol id="eventLog" class="event-log"></ol>
</div>
</aside>
</main>
</div>
-611
View File
@@ -733,614 +733,3 @@ 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;
}
}