Files
scrabble-game/ui/src/screens/Lobby.svelte
T
Ilia Denisov fa4dc2412a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
feat(offline): implicit net-state model, two-tier version gate, unified lobby
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README).

Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later.

Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
2026-07-13 01:44:28 +02:00

694 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import UpdateNudge from '../components/UpdateNudge.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload';
import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
let games = $state<GameView[]>([]);
let invitations = $state<Invitation[]>([]);
let incoming = $state<AccountRef[]>([]);
const guest = $derived(app.profile?.isGuest ?? true);
// The lobby ⚙️ badge is the shared count: incoming friend requests plus an awaiting
// operator feedback reply (the Settings → Info badge folds in here).
const settingsBadge = $derived(app.notifications + (app.feedbackReplyUnread ? 1 : 0));
// A generation guard so only the LATEST load() writes the module lists. Three triggers (onMount, a
// game event, the offline-mode flip) can overlap, and a slow online gamesList() completing after a
// fast offline list() (or vice versa) would otherwise leave the other mode's games on screen — the
// "wrong game in the lobby after a toggle" bug. A superseded run bails at the checks below.
let loadSeq = 0;
async function load() {
const seq = ++loadSeq;
const offline = offlineMode.active;
// Device-local games (vs_ai / hotseat) merge into the unified lobby everywhere except the
// always-online Telegram/VK mini-apps, which are server-only (the shared-origin local store must
// not leak device-local games into a mini-app).
const serverOnly = insideTelegram() || insideVK();
const local = serverOnly ? [] : await localSource.list();
if (seq !== loadSeq) return;
if (offline) {
// Offline: device-local games are active; the last-known server games ride along greyed from the
// cache (un-openable). No network — and no invitations/incoming (a server concept, hidden until
// back online).
const cachedServer = (getLobby()?.games ?? []).filter((g) => !isLocalGameId(g.id));
games = [...local, ...cachedServer];
invitations = [];
incoming = [];
app.notifications = 0;
setLobby({ games, invitations, incoming });
app.lobbyReady = true;
return;
}
try {
const list = await gateway.gamesList();
if (seq !== loadSeq) return;
const server = list.games;
// Seed the per-game unread badges from the authoritative server list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of server) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
// The unified list: device-local games alongside the server ones (so a reconciled guest's local
// games stay visible online). Ids never collide.
games = [...local, ...server];
if (!guest) {
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
if (seq !== loadSeq) return;
invitations = inv;
incoming = inc;
// The ⚙️ badge counts incoming friend requests plus an awaiting feedback reply;
// invitations surface in their own lobby section above.
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing SERVER games so opening one from the lobby is instant (local
// games open from the device). The list just loaded, so the connection is up; non-blocking and
// cheap (already-cached ones are skipped).
if (connection.online) {
void preloadGames(server);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
kickDictPreload(app.profile, true);
}
} catch (e) {
handleError(e);
} finally {
// The first cold load has settled (success or error): release the loading splash. Set
// unconditionally on every refresh — it is an idempotent latch, reset only on logout.
app.lobbyReady = true;
}
}
onMount(() => {
// Render instantly from the cached lists (so the screen does not "draw in" during
// the back-slide), then refresh in the background.
const cached = getLobby();
if (cached) {
games = cached.games;
invitations = cached.invitations;
incoming = cached.incoming;
}
void load();
});
$effect(() => {
// Refetch on a real game event only — not the 10 s keep-alive heartbeat. Refetching on every
// heartbeat turned the lobby into a 10 s poll whose REST view of a just-committed opponent move
// could update (and blink) a card seconds before the matching your_turn event/toast arrived
// over the slower live stream; gating it keeps the card, its blink and the toast on one event.
if (app.lastEvent && app.lastEvent.kind !== 'heartbeat') void load();
});
// Reload when the deliberate offline mode flips (the toggle lives in Settings, so the lobby can
// stay mounted across the change): entering offline must immediately drop the online games and
// show only the device-local ones, and leaving it must refetch the online lobby. Guarded so the
// initial run — already covered by onMount — does not double-load.
let wasOffline = offlineMode.active;
$effect(() => {
const now = offlineMode.active;
if (now !== wasOffline) {
wasOffline = now;
void load();
}
});
// myId is the server user id, used for the (server-only) invitation "from me" checks. myIds is the
// full "you" set — the server user id plus the device-local guest id — used for game seat matching,
// since a reconciled guest's device-local games are seated under localGuestId while server games are
// seated under the user id (the ids never collide). guestId is read once (it mints on first launch).
const guestId = localGuestId();
const myId = $derived(app.session?.userId ?? '');
const myIds = $derived([app.session?.userId, guestId].filter((x): x is string => !!x));
// grey marks a server game shown only from the offline cache: dimmed and un-openable until online.
const grey = (g: GameView): boolean => offlineMode.active && !isLocalGameId(g.id);
const groups = $derived(groupGames(games, myIds, app.chatUnread));
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
// in-place — no blink). All blink state is keyed by game id, so overlapping events stay
// isolated: every card owns its own blink window and re-key counter, with no shared timer to
// race. The nonce re-keys the emoji so a repeat blink on the same card restarts the animation.
const blinkingIds = new SvelteSet<string>();
const blinkNonce = new SvelteMap<string, number>();
const blinkTimers = new Map<string, ReturnType<typeof setTimeout>>();
const prevPhase = new Map<string, LobbyPhase>();
function blink(id: string): void {
if (app.reduceMotion) return; // a fade-blink is exactly what reduce-motion asks us to drop
clearTimeout(blinkTimers.get(id));
blinkNonce.set(id, (blinkNonce.get(id) ?? 0) + 1);
blinkingIds.add(id);
blinkTimers.set(
id,
setTimeout(() => {
blinkingIds.delete(id);
blinkTimers.delete(id);
}, 2000), // two 1 s fade cycles
);
}
$effect(() => {
// Diff each game's bucket against the last seen one and blink on a transition into
// mine/finished. A first observation seeds the baseline without blinking (see shouldBlink),
// so a cold render — or a card arriving from another screen — never blinks.
const seen = new Set<string>();
for (const g of games) {
seen.add(g.id);
const phase = gamePhase(g, myIds);
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
prevPhase.set(g.id, phase);
}
for (const id of prevPhase.keys()) if (!seen.has(id)) prevPhase.delete(id);
});
onDestroy(() => {
for (const tmr of blinkTimers.values()) clearTimeout(tmr);
});
function opponents(g: GameView): string {
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
if (g.status === 'open') return t('game.searchingForOpponent');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖';
return g.seats
.filter((s) => !myIds.includes(s.accountId))
.map((s) => s.displayName)
.join(', ');
}
// Hiding a finished game. The delete action sits behind each finished row and is
// revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
// per-account and irreversible. Only one row is revealed at a time.
let revealedId = $state<string | null>(null);
// A hotseat game's deletion is gated by the host (master) PIN — both active (terminate the game)
// and finished; pinTarget holds the game id while its PIN pad is open.
let pinTarget = $state<string | null>(null);
let drag: { id: string; x0: number; y0: number } | null = null;
// A horizontal swipe must not also count as a tap that opens the game; armed on swipe,
// consumed by the next tap, and reset on the next pointerdown so a later tap is never eaten.
let swiped = false;
function onRowDown(e: PointerEvent, id: string): void {
swiped = false;
if (e.pointerType === 'mouse') return; // desktop reveals via the kebab, not a swipe
drag = { id, x0: e.clientX, y0: e.clientY };
}
function onRowUp(e: PointerEvent, revealable: boolean): void {
if (!drag) return;
const dx = e.clientX - drag.x0;
const dy = e.clientY - drag.y0;
// Only a revealable row (a finished game, or any hotseat game) reveals its delete on a horizontal
// swipe; that swipe then suppresses the tap so it does not also open the game. Other active rows
// ignore swipes and stay plain tap-to-open.
if (revealable && Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy) * 1.4) {
swiped = true;
revealedId = dx < 0 ? drag.id : null;
}
drag = null;
}
function openGame(g: GameView): void {
if (swiped) {
swiped = false;
return;
}
if (revealedId === g.id) {
revealedId = null;
return;
}
// A server game shown only from the offline cache is un-openable — a toast explains why.
if (grey(g)) {
showToast(t('net.offline'));
return;
}
navigate(`/game/${g.id}`);
}
function toggleReveal(id: string): void {
revealedId = revealedId === id ? null : id;
}
async function hide(id: string): Promise<void> {
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming });
try {
// A local (offline) game is deleted from the device store; an online game is hidden on the
// backend. Routing by id keeps the delete off the network for a local game (the transport
// kill switch would otherwise refuse it offline).
if (isLocalGameId(id)) await localSource.delete(id);
else await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming });
handleError(e);
}
}
// startDelete routes the row's delete: a hotseat game (active or finished) is gated behind the host
// master PIN (so the last mover cannot instantly wipe the game); any other finished game deletes at
// once.
function startDelete(g: GameView): void {
if (g.hotseat) pinTarget = g.id;
else void hide(g.id);
}
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
if (r.gameId) navigate(`/game/${r.gameId}`);
else await load();
} catch (e) {
handleError(e);
}
}
const declineInvite = (inv: Invitation) => act(() => gateway.invitationDecline(inv.id));
const cancelInvite = (inv: Invitation) => act(() => gateway.invitationCancel(inv.id));
async function act(fn: () => Promise<unknown>) {
try {
await fn();
await load();
} catch (e) {
handleError(e);
}
}
// The invitation pending a decline confirmation: the ❌ opens a modal (mirroring the
// in-game resign confirmation) rather than declining on the first tap.
let declineTarget = $state<Invitation | null>(null);
function confirmDecline() {
const inv = declineTarget;
declineTarget = null;
if (inv) declineInvite(inv);
}
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
<div class="lobby">
{#if invitations.length && !offlineMode.active}
<section>
<h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)}
<div class="invite">
<span class="emoji">💌</span>
<span class="info">
{#if inv.inviter.accountId === myId}
<span class="who">{t('invitations.with', { names: inv.invitees.map((i) => i.displayName).join(', ') })}</span>
<span class="sub">{t('invitations.waiting')}</span>
{:else}
<span class="who">{t('invitations.from', { name: inv.inviter.displayName })}</span>
<span class="vrow">
{#if VARIANT_FLAG[inv.variant]}
<span class="vflag">{VARIANT_FLAG[inv.variant]}</span>
{:else}
<img class="vflag-img" src="flag-ussr.svg" alt="" />
{/if}
<span class="sub">{t(VARIANT_RULES[inv.variant])}</span>
</span>
{/if}
{#if !inv.multipleWordsPerTurn}<span class="sub">{t('game.oneWordRule')}</span>{/if}
</span>
<span class="acts">
{#if inv.inviter.accountId === myId}
<button class="iconbtn" onclick={() => cancelInvite(inv)} aria-label={t('invitations.cancel')}></button>
{:else}
<button class="iconbtn" onclick={() => acceptInvite(inv)} aria-label={t('invitations.accept')}></button>
<button class="iconbtn" onclick={() => (declineTarget = inv)} aria-label={t('invitations.decline')}></button>
{/if}
</span>
</div>
{/each}
</section>
{/if}
{#each [{ h: 'lobby.yourTurn', list: groups.yourTurn, finished: false }, { h: 'lobby.theirTurn', list: groups.theirTurn, finished: false }, { h: 'lobby.finishedGames', list: groups.finished, finished: true }] as group (group.h)}
{#if group.list.length}
<section>
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id} class:greyed={grey(g)}>
{#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
<div class="row">
<button
class="open"
onpointerdown={(e) => onRowDown(e, g.id)}
onpointerup={(e) => onRowUp(e, group.finished || !!g.hotseat)}
onclick={() => openGame(g)}
>
<span class="info">
<span class="who">
<span class="who-name">{opponents(g) || '—'}</span>
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span>
<span class="sub scoreline"
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myIds, s)}{#if i > 0}<span class="sep">{' : '}</span
>{/if}<span
class="num"
class:win={standing === 'win'}
class:lose={standing === 'lose'}>{s.score}</span
>{/each}</span
>
</span>
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. A
hotseat game shows no lobby medal — its result lives on the in-game seat
plaques, and resultBadge is viewer-centric (no seat matches the account). A
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
{#key blinkNonce.get(g.id) ?? 0}
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myIds).emoji}</span>
{/key}
</button>
{#if group.finished || g.hotseat}
<button class="kebab" onclick={() => toggleReveal(g.id)} aria-label={t('lobby.hideGame')}></button>
{:else}
<!-- A visual duplicate of the row's tap target: opens the game on tap, but kept out
of the tab order / a11y tree since the .open button already does the same. -->
<button class="chev" onclick={() => openGame(g)} tabindex={-1} aria-hidden="true"></button>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}
{/each}
{#if !games.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
</div>
{#if declineTarget}
<Modal title={t('invitations.declineConfirm')} onclose={() => (declineTarget = null)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (declineTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmDecline} disabled={!connection.online}>{t('invitations.decline')}</button>
</div>
</Modal>
{/if}
<!-- Deleting a hotseat game (active or finished) is gated by the host master PIN. -->
{#if pinTarget}
<PinPad
mode="verify"
title={t('hotseat.hostPin')}
verify={(p) => localSource.verifyHostPin(pinTarget ?? '', p)}
onclose={() => (pinTarget = null)}
onresult={() => {
const id = pinTarget;
pinTarget = null;
if (id) void hide(id);
}}
/>
{/if}
{#snippet tabbar()}
<UpdateNudge />
<TabBar>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
<button class="tab" disabled={offlineMode.active} onclick={() => navigate('/stats')}>
<span class="sq" data-coach="lobby-stats">✏️</span><span class="lbl">{t('lobby.stats')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq" data-coach="lobby-settings">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>
</button>
</TabBar>
{/snippet}
</Screen>
<style>
.lobby {
padding: var(--pad);
display: flex;
flex-direction: column;
gap: 18px;
}
h2 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 8px;
}
.empty {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0;
}
.invite {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px 14px;
margin-bottom: 8px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
user-select: none;
}
/* The middle column grows; the envelope and the action column stay at their natural width. */
.invite .info {
flex: 1;
}
/* The variant row in an invitation: flag + the rules summary (mirrors NewGame). */
.vrow {
display: flex;
align-items: baseline;
gap: 6px;
}
.vflag {
font-size: 1.1rem;
line-height: 1;
}
.vflag-img {
width: 1.3rem;
height: auto;
border-radius: 2px;
align-self: center;
}
/* Borderless icon action (✅ / ❌), like the in-game .hicon buttons. */
.iconbtn {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.iconbtn:active {
background: var(--bg-elev);
}
/* Game rows are a compact, flat list: no per-card frame, a hairline divider between
consecutive rows. */
.list {
display: flex;
flex-direction: column;
}
/* Each finished row can slide left to reveal a delete action sitting behind it; the row's
own opaque background hides that action until revealed. */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* A server game shown only from the offline cache: dimmed and un-openable (openGame toasts instead). */
.rowwrap.greyed {
opacity: 0.5;
}
.del {
position: absolute;
inset: 0 0 0 auto;
width: 64px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--bg-elev);
color: var(--text);
font-size: 1.1rem;
}
.row {
position: relative;
display: flex;
align-items: center;
gap: 2px;
background: var(--bg);
transform: translateX(0);
transition: transform 0.18s ease;
}
.rowwrap.revealed .row {
transform: translateX(-64px);
}
.open {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
text-align: left;
padding: 5px 6px;
border: none;
background: none;
color: var(--text);
user-select: none;
touch-action: pan-y; /* keep vertical list scroll; we only read horizontal swipes */
}
/* A tap/click on a game row leaves no highlight: drop the WebKit tap-flash on
both tappable areas (the open body and the chevron / kebab) and the held
:active background, which added nothing and only spoiled the look. */
.open,
.chev,
.kebab {
-webkit-tap-highlight-color: transparent;
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 10px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.chev {
flex: 0 0 auto;
width: 30px;
padding: 10px 0;
border: none;
background: none;
text-align: center;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: 600;
}
.who-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A small dot beside the opponent name: this game has an unread chat entry. Red for an unread
message, a softer amber when only nudges are unread. */
.unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.unread-dot.nudge {
background: var(--warn);
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
/* The score line is bold and a touch smaller than the surrounding muted sub-text. */
.scoreline {
font-weight: 700;
font-size: 0.8rem;
}
/* In-progress score colours (see scoreStanding): the viewer's number greens when leading or
tied, reds when losing; an opponent's number greens only when it ties the viewer (an equal
score paints both green). A fresh 0:0 board and the separators keep the muted .sub colour. */
.num.win {
color: var(--ok);
}
.num.lose {
color: var(--danger);
}
.emoji {
font-size: 1.35rem;
line-height: 1;
flex: 0 0 auto;
}
/* A twice-over fade (two 1 s cycles) drawing the eye to a card that just became your turn or
finished. Gated in script by app.reduceMotion, so the class is never applied under
reduce-motion. */
.emoji.blink {
animation: lobby-blink 1s ease-in-out 2;
}
@keyframes lobby-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
/* The ✅ / ❌ actions stack vertically with a small gap — a min-width right column. */
.acts {
display: flex;
flex-direction: column;
gap: 10px;
flex: 0 0 auto;
justify-content: center;
}
/* Decline-confirmation modal (mirrors the in-game resign confirm). */
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
</style>