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
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).
694 lines
26 KiB
Svelte
694 lines
26 KiB
Svelte
<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>
|