Add features: escalating turn reminder, AFK AI takeover, public rooms, persistent rejoin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+186
-12
@@ -11,7 +11,17 @@ let authToken = localStorage.getItem('hearts_token') || null;
|
||||
let authUser = localStorage.getItem('hearts_user') || null;
|
||||
let lastState = null;
|
||||
let deferredInstallPrompt = null;
|
||||
let selectedScore = 100;
|
||||
|
||||
// ─── Turn escalation state ────────────────────────────────────
|
||||
let turnEscalationInterval = null;
|
||||
let turnEscalationLevel = 0;
|
||||
let wasMyTurn = false;
|
||||
|
||||
// ─── AFK / AI-takeover state ──────────────────────────────────
|
||||
let aiControlledSeats = new Set();
|
||||
let afkWarnSeat = -1;
|
||||
let selectedScore = 100;
|
||||
let selectedPublic = false;
|
||||
|
||||
// ─── Play mode & hand display mode ───────────────────────────
|
||||
function isTouchDevice() {
|
||||
@@ -160,6 +170,55 @@ const RANK_FILE = {
|
||||
'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING',
|
||||
};
|
||||
|
||||
// ─── Turn escalation ──────────────────────────────────────────
|
||||
function handleTurnReminder(isMyTurn) {
|
||||
clearInterval(turnEscalationInterval);
|
||||
turnEscalationLevel = 0;
|
||||
applyTurnEscalation(0);
|
||||
if (!isMyTurn) return;
|
||||
turnEscalationInterval = setInterval(() => {
|
||||
turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
|
||||
applyTurnEscalation(turnEscalationLevel);
|
||||
if (turnEscalationLevel >= 3) {
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const o = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
o.connect(g); g.connect(ctx.destination);
|
||||
o.frequency.value = 880;
|
||||
g.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
||||
o.start(); o.stop(ctx.currentTime + 0.4);
|
||||
} catch(e) {}
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function applyTurnEscalation(level) {
|
||||
const el = $('phase-msg');
|
||||
if (!el) return;
|
||||
el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
|
||||
if (level > 0) el.classList.add(`your-turn-lvl${level}`);
|
||||
}
|
||||
|
||||
// ─── AFK / AI-takeover UI ─────────────────────────────────────
|
||||
function updateAiControlBanner() {
|
||||
const banner = $('ai-control-banner');
|
||||
const msg = $('ai-control-msg');
|
||||
if (!banner || !msg) return;
|
||||
if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; }
|
||||
if (aiControlledSeats.has(mySeat)) {
|
||||
msg.textContent = 'AI is playing for you — play a card to resume control';
|
||||
banner.className = 'ai-control-banner ai-self';
|
||||
} else {
|
||||
const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', ');
|
||||
msg.textContent = `AI is playing for ${names}`;
|
||||
banner.className = 'ai-control-banner ai-other';
|
||||
}
|
||||
show('ai-control-banner');
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); }
|
||||
@@ -295,6 +354,14 @@ function initSocket() {
|
||||
socket.on('rejoinError', (msg) => {
|
||||
clearSession();
|
||||
$('lobby-error').textContent = msg || 'Could not rejoin';
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
|
||||
socket.on('hasActiveGame', ({ roomId, seat, token }) => {
|
||||
if (myRoomId) return; // already in a session
|
||||
myRoomId = roomId; mySeat = seat; myToken = token;
|
||||
saveSession(roomId, seat, token);
|
||||
socket.emit('rejoin', { roomId, seat, token });
|
||||
});
|
||||
|
||||
// Core game events — all handled by renderState
|
||||
@@ -308,11 +375,44 @@ function initSocket() {
|
||||
socket.on('joinError', msg => { $('lobby-error').textContent = msg; });
|
||||
socket.on('passError', msg => console.warn('passError:', msg));
|
||||
socket.on('playError', msg => console.warn('playError:', msg));
|
||||
|
||||
socket.on('afkWarning', ({ seat, name }) => {
|
||||
afkWarnSeat = seat;
|
||||
const isMe = seat === mySeat;
|
||||
$('afk-banner-msg').textContent = isMe
|
||||
? "You haven't played yet!"
|
||||
: `${name} hasn't played. Let AI take over?`;
|
||||
if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn');
|
||||
show('afk-banner');
|
||||
});
|
||||
|
||||
socket.on('afkResolved', () => {
|
||||
afkWarnSeat = -1;
|
||||
hide('afk-banner');
|
||||
});
|
||||
|
||||
socket.on('aiControl', ({ seat, active, name }) => {
|
||||
if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat);
|
||||
updateAiControlBanner();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── State rendering ──────────────────────────────────────────
|
||||
function syncAiState(state) {
|
||||
aiControlledSeats = new Set(state.aiControlledSeats || []);
|
||||
updateAiControlBanner();
|
||||
}
|
||||
|
||||
function handleState(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
|
||||
// Restore locally-selected pass cards that the server doesn't know about yet
|
||||
if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) {
|
||||
state.passSelected = passSelectedLocal;
|
||||
} else {
|
||||
passSelectedLocal = [];
|
||||
}
|
||||
|
||||
// Always dismiss result overlays when fresh state arrives
|
||||
hide('overlay-hand', 'overlay-gameover');
|
||||
@@ -335,6 +435,7 @@ function handleState(state) {
|
||||
|
||||
function handleTrickWon(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
// Flash the winning slot briefly
|
||||
@@ -346,6 +447,8 @@ function handleTrickWon(state) {
|
||||
|
||||
function handleHandOver(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
hide('afk-banner');
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
hide('overlay-pass');
|
||||
@@ -354,6 +457,8 @@ function handleHandOver(state) {
|
||||
|
||||
function handleGameOver(state) {
|
||||
lastState = state;
|
||||
syncAiState(state);
|
||||
hide('afk-banner');
|
||||
renderInfoBar(state);
|
||||
renderTable(state);
|
||||
hide('overlay-pass', 'overlay-hand');
|
||||
@@ -440,6 +545,9 @@ function renderWaitingRoom(state) {
|
||||
} else {
|
||||
hide('btn-fill-bots');
|
||||
}
|
||||
|
||||
const badge = $('waiting-public-badge');
|
||||
if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private';
|
||||
}
|
||||
|
||||
// ── Game table ────────────────────────────────────────────────
|
||||
@@ -616,6 +724,12 @@ function renderPhaseMsg(state) {
|
||||
const el = $('phase-msg');
|
||||
if (!el) return;
|
||||
|
||||
const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating;
|
||||
if (isMyTurn !== wasMyTurn) {
|
||||
wasMyTurn = isMyTurn;
|
||||
handleTurnReminder(isMyTurn);
|
||||
}
|
||||
|
||||
if (state.state === 'WAITING') {
|
||||
el.textContent = 'Waiting…';
|
||||
} else if (state.state === 'PASSING') {
|
||||
@@ -728,8 +842,9 @@ function togglePassSelect(code, state) {
|
||||
return; // already 3 selected
|
||||
}
|
||||
|
||||
// Optimistic update for immediate feedback
|
||||
// Optimistic update for immediate feedback; persist so server pushes don't wipe it
|
||||
state.passSelected = selected;
|
||||
passSelectedLocal = selected;
|
||||
renderPassOverlay(state);
|
||||
// Also update my-hand in game table
|
||||
renderMyHand(myHand, state);
|
||||
@@ -740,6 +855,7 @@ function confirmPass() {
|
||||
if (!state) return;
|
||||
const selected = state.passSelected || [];
|
||||
if (selected.length !== 3) return;
|
||||
passSelectedLocal = [];
|
||||
socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected });
|
||||
}
|
||||
|
||||
@@ -987,6 +1103,48 @@ function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── Lobby helpers ────────────────────────────────────────────
|
||||
function joinGame() {
|
||||
const name = $('input-name').value.trim();
|
||||
const roomId = $('input-code').value.trim().toUpperCase();
|
||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||
if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
myName = name;
|
||||
socket.emit('join', { name, roomId });
|
||||
}
|
||||
|
||||
async function loadPublicRooms() {
|
||||
const listEl = $('public-rooms-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
||||
try {
|
||||
const list = await apiFetch('/api/rooms');
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
listEl.innerHTML = '<p class="hint">No public rooms right now.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
list.forEach(room => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'public-room-item';
|
||||
row.innerHTML = `
|
||||
<div class="public-room-info">
|
||||
<span class="public-room-host">${escHtml(room.hostName)}'s room</span>
|
||||
<span class="public-room-meta">${room.playerCount}/4 · score limit ${room.winScore}</span>
|
||||
</div>
|
||||
<button class="btn-join-pub" data-room="${escHtml(room.id)}">Join</button>`;
|
||||
row.querySelector('.btn-join-pub').addEventListener('click', () => {
|
||||
$('input-code').value = room.id;
|
||||
joinGame();
|
||||
});
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
} catch {
|
||||
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PWA install ──────────────────────────────────────────────
|
||||
window.addEventListener('beforeinstallprompt', e => {
|
||||
e.preventDefault();
|
||||
@@ -1038,28 +1196,33 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
$(`tab-${btn.dataset.tab}`)?.classList.add('active');
|
||||
if (btn.dataset.tab === 'join') loadPublicRooms();
|
||||
});
|
||||
});
|
||||
|
||||
// Visibility toggle (public/private)
|
||||
document.querySelectorAll('.visibility-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
selectedPublic = btn.dataset.public === 'true';
|
||||
});
|
||||
});
|
||||
|
||||
// Public rooms refresh
|
||||
$('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
|
||||
|
||||
// Create game
|
||||
$('btn-create').addEventListener('click', () => {
|
||||
const name = $('input-name').value.trim();
|
||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
myName = name;
|
||||
socket.emit('create', { name, winScore: selectedScore });
|
||||
socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic });
|
||||
});
|
||||
|
||||
// Join game
|
||||
$('btn-join').addEventListener('click', () => {
|
||||
const name = $('input-name').value.trim();
|
||||
const roomId = $('input-code').value.trim().toUpperCase();
|
||||
if (!name) { $('lobby-error').textContent = 'Enter your name'; return; }
|
||||
if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; }
|
||||
$('lobby-error').textContent = '';
|
||||
myName = name;
|
||||
socket.emit('join', { name, roomId });
|
||||
});
|
||||
$('btn-join').addEventListener('click', joinGame);
|
||||
|
||||
// Spectate
|
||||
$('btn-spectate').addEventListener('click', () => {
|
||||
@@ -1090,6 +1253,13 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
// Pass overlay confirm
|
||||
$('btn-confirm-pass').addEventListener('click', confirmPass);
|
||||
|
||||
// AFK banner
|
||||
$('afk-vote-btn').addEventListener('click', () => {
|
||||
socket.emit('voteAITakeover', { roomId: myRoomId });
|
||||
hide('afk-banner');
|
||||
});
|
||||
$('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner'));
|
||||
|
||||
// Leave game
|
||||
$('btn-leave-game').addEventListener('click', () => {
|
||||
if (spectating) {
|
||||
@@ -1107,6 +1277,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
clearSession();
|
||||
socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
|
||||
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
||||
wasMyTurn = false; handleTurnReminder(false);
|
||||
aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner');
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
|
||||
@@ -1116,6 +1288,8 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
hide('overlay-gameover');
|
||||
clearSession();
|
||||
myRoomId = null; mySeat = -1; myToken = null; spectating = false;
|
||||
wasMyTurn = false; handleTurnReminder(false);
|
||||
aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner');
|
||||
showScreen('screen-lobby');
|
||||
});
|
||||
|
||||
|
||||
+29
-2
@@ -51,15 +51,29 @@
|
||||
<button class="score-btn" data-score="200">200</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Visibility</label>
|
||||
<div class="visibility-toggle">
|
||||
<button class="visibility-btn active" data-public="false">🔒 Private</button>
|
||||
<button class="visibility-btn" data-public="true">🌐 Public</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-create" class="btn-primary">Create Game</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-join" class="tab-panel">
|
||||
<div class="public-rooms-section">
|
||||
<div class="rooms-header">
|
||||
<span>Public Rooms</span>
|
||||
<button id="btn-refresh-rooms" class="btn-refresh">↺ Refresh</button>
|
||||
</div>
|
||||
<div id="public-rooms-list"><p class="hint">Switch to this tab to load rooms.</p></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Room Code</label>
|
||||
<label>Or enter a Room Code</label>
|
||||
<input id="input-code" type="text" maxlength="8" placeholder="e.g. AB12CD" autocomplete="off" style="text-transform:uppercase">
|
||||
</div>
|
||||
<button id="btn-join" class="btn-primary">Join Game</button>
|
||||
<button id="btn-join" class="btn-primary">Join by Code</button>
|
||||
<hr style="border-color:rgba(255,255,255,.1);margin:4px 0">
|
||||
<div class="field">
|
||||
<label>Watch as spectator</label>
|
||||
@@ -77,6 +91,7 @@
|
||||
<div class="waiting-box">
|
||||
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
|
||||
<h2>Waiting for Players</h2>
|
||||
<div id="waiting-public-badge" class="waiting-public-badge">🔒 Private</div>
|
||||
<div class="room-code-box">
|
||||
<span class="label">Room Code</span>
|
||||
<span id="display-room-code" class="room-code">——</span>
|
||||
@@ -126,6 +141,18 @@
|
||||
<!-- Spectator banner -->
|
||||
<div id="spectator-banner" class="spectator-banner hidden">👁 Spectating — watching only</div>
|
||||
|
||||
<!-- AFK warning banner -->
|
||||
<div id="afk-banner" class="afk-banner hidden">
|
||||
<span id="afk-banner-msg"></span>
|
||||
<button id="afk-vote-btn">Let AI Play</button>
|
||||
<button id="afk-dismiss-btn">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<!-- AI control banner -->
|
||||
<div id="ai-control-banner" class="ai-control-banner hidden">
|
||||
<span id="ai-control-msg"></span>
|
||||
</div>
|
||||
|
||||
<!-- Table grid -->
|
||||
<div id="table-grid">
|
||||
|
||||
|
||||
@@ -1019,3 +1019,165 @@ html, body {
|
||||
:root { --card-w: 52px; --card-h: 74px; }
|
||||
#my-area { padding-bottom: 4px; }
|
||||
}
|
||||
|
||||
/* ── Visibility toggle (Create tab) ──────────── */
|
||||
.visibility-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.visibility-btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
background: transparent;
|
||||
color: rgba(255,255,255,.6);
|
||||
font-size: .9rem;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
}
|
||||
.visibility-btn.active {
|
||||
background: rgba(100,180,255,.2);
|
||||
border-color: rgba(100,180,255,.5);
|
||||
color: rgba(200,230,255,.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Public rooms list (Join tab) ─────────────── */
|
||||
.public-rooms-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.rooms-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: .8rem;
|
||||
color: rgba(255,255,255,.55);
|
||||
}
|
||||
.btn-refresh {
|
||||
background: none;
|
||||
border: 1px solid rgba(255,255,255,.2);
|
||||
border-radius: 6px;
|
||||
color: rgba(255,255,255,.6);
|
||||
font-size: .75rem;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-refresh:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||
#public-rooms-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.public-room-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,.07);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.public-room-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.public-room-host {
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.public-room-meta { font-size: .72rem; color: rgba(255,255,255,.5); }
|
||||
.btn-join-pub {
|
||||
padding: 5px 12px;
|
||||
background: var(--gold);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #000;
|
||||
font-size: .8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-join-pub:hover { opacity: .85; }
|
||||
|
||||
/* ── Waiting room public badge ─────────────────── */
|
||||
.waiting-public-badge {
|
||||
font-size: .78rem;
|
||||
color: rgba(255,255,255,.65);
|
||||
background: rgba(255,255,255,.09);
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
border-radius: 10px;
|
||||
padding: 2px 10px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* ── Your-turn escalation ─────────────────────── */
|
||||
@keyframes your-turn-flash {
|
||||
0%,100% { opacity:1; } 50% { opacity:.55; }
|
||||
}
|
||||
.phase-msg.your-turn-lvl1 {
|
||||
font-size: .96rem; color: #ffc400;
|
||||
animation: your-turn-flash .6s ease-in-out infinite;
|
||||
}
|
||||
.phase-msg.your-turn-lvl2 {
|
||||
font-size: 1.18rem; color: #ffeb3b;
|
||||
text-shadow: 0 0 10px #ffeb3b88;
|
||||
animation: your-turn-flash .4s ease-in-out infinite;
|
||||
}
|
||||
.phase-msg.your-turn-lvl3 {
|
||||
font-size: 1.4rem; color: #fff;
|
||||
text-shadow: 0 0 22px #ffffffcc, 0 0 8px #ffcc00cc;
|
||||
animation: your-turn-flash .25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── AFK warning banner ───────────────────────── */
|
||||
.afk-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
background: rgba(180,60,30,.88);
|
||||
border-bottom: 1px solid rgba(255,120,60,.35);
|
||||
font-size: .82rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.afk-banner span { flex: 1; color: rgba(255,255,255,.9); }
|
||||
.afk-banner button {
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
background: rgba(255,255,255,.18);
|
||||
color: #fff;
|
||||
font-size: .76rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.afk-banner button:hover { background: rgba(255,255,255,.3); }
|
||||
|
||||
/* ── AI control banner ────────────────────────── */
|
||||
.ai-control-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 14px;
|
||||
font-size: .8rem;
|
||||
flex-shrink: 0;
|
||||
color: rgba(255,255,255,.9);
|
||||
}
|
||||
.ai-control-banner.ai-self { background: rgba(190,40,90,.75); border-bottom: 1px solid rgba(255,80,140,.25); }
|
||||
.ai-control-banner.ai-other { background: rgba(70,45,160,.75); border-bottom: 1px solid rgba(120,90,220,.25); }
|
||||
|
||||
Reference in New Issue
Block a user