6636d7c309
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
A one-time coachmark overlay walks a new player through the lobby and their first game board: a light dimmed layer draws one tail-pointed hint bubble at a time, advancing on a tap anywhere and removing itself for good after the last hint. Two independent series (lobby: settings/stats/new game; game: header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag and marked done only after the last hint, so an interrupted run replays from the start. A deep-link into Settings -> Friends still triggers the lobby series on the first trip back to the lobby. Targets carry a data-coach attribute, so one positioning engine anchors the bubble in both portrait and landscape, re-measuring each frame until the geometry settles (route slide, hidden-banner reflow, fonts). The promo banner hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset visited" control replays the walk-through. Off by default in the mock build so the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e. Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts (unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding story, UI_DESIGN coachmark section.
602 lines
21 KiB
Svelte
602 lines
21 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 Modal from '../components/Modal.svelte';
|
||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } 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 { 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[]>([]);
|
||
// True when the player has reached the simultaneous quick-game cap: the "New Game" tab is
|
||
// disabled and a notice shows under the lists. It rides the games.list response (which the
|
||
// lobby refetches on entry and on every game event) and the cached snapshot, so it renders
|
||
// in its last known state instantly and refreshes in the background. Optimistic default
|
||
// (enabled) for the very first, uncached load; the backend gate is the authority.
|
||
let atGameLimit = $state(false);
|
||
|
||
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));
|
||
|
||
async function load() {
|
||
try {
|
||
const list = await gateway.gamesList();
|
||
games = list.games;
|
||
// Seed the per-game unread badges from the authoritative 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 games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
||
atGameLimit = list.atGameLimit;
|
||
if (!guest) {
|
||
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||
// 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, atGameLimit });
|
||
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
||
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
||
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
||
if (connection.online) void preloadGames(games);
|
||
} 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;
|
||
atGameLimit = cached.atGameLimit;
|
||
}
|
||
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();
|
||
});
|
||
|
||
const myId = $derived(app.session?.userId ?? '');
|
||
const groups = $derived(groupGames(games, myId, 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, myId);
|
||
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) => s.accountId !== myId)
|
||
.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);
|
||
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, finished: boolean): void {
|
||
if (!drag) return;
|
||
const dx = e.clientX - drag.x0;
|
||
const dy = e.clientY - drag.y0;
|
||
// Only a finished row reveals on a horizontal swipe; that swipe then suppresses the tap so it
|
||
// does not also open the game. Active rows ignore swipes and stay plain tap-to-open.
|
||
if (finished && 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;
|
||
}
|
||
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, atGameLimit });
|
||
try {
|
||
await gateway.hideGame(id);
|
||
} catch (e) {
|
||
games = prev;
|
||
setLobby({ games, invitations, incoming, atGameLimit });
|
||
handleError(e);
|
||
}
|
||
}
|
||
|
||
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}
|
||
<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 && revealedId === g.id}>
|
||
{#if group.finished}
|
||
<button class="del" onclick={() => hide(g.id)} disabled={!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)}
|
||
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, myId, 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. -->
|
||
{#key blinkNonce.get(g.id) ?? 0}
|
||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{resultBadge(g, myId).emoji}</span>
|
||
{/key}
|
||
</button>
|
||
{#if group.finished}
|
||
<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}
|
||
|
||
{#if atGameLimit}
|
||
<p class="limit">{t('lobby.limitReached')}</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}
|
||
|
||
{#snippet tabbar()}
|
||
<TabBar>
|
||
<button class="tab" disabled={atGameLimit} onclick={() => navigate('/new')}>
|
||
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||
</button>
|
||
<button class="tab" 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;
|
||
}
|
||
/* A plain, low-emphasis line under the lists when the simultaneous-game cap is reached. */
|
||
.limit {
|
||
color: var(--text-muted);
|
||
font-size: 0.9rem;
|
||
margin: 0;
|
||
text-align: center;
|
||
}
|
||
.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);
|
||
}
|
||
.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>
|