<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>
