feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s

Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
Ilia Denisov
2026-06-12 16:00:22 +02:00
parent 10dc1f0d48
commit c305363ccd
42 changed files with 1248 additions and 768 deletions
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from './fixtures';
// The quick-match flow drops the player straight into a game that is still waiting for an
// opponent (status 'open'): the opponent card shows "searching for opponent" and resign is
// disabled until the mock attaches a robot shortly after, which restores the game UI. Driven
// entirely by the mock transport (no backend).
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
// Pick a variant and start; the player lands in the game at once (no "searching" screen).
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
// history panel) is disabled.
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
await page.locator('.scoreboard').click(); // open the history panel
await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
// Attach the opponent deterministically (the mock otherwise joins on a timer).
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
// The opponent card shows its name, the placeholder is gone, and resign is enabled again.
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
});
+6 -2
View File
@@ -8,6 +8,7 @@
myId,
busy,
myTurn = false,
waiting = false,
nudgeOnCooldown = false,
onsend,
onnudge,
@@ -20,6 +21,9 @@
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
myTurn?: boolean;
// waiting is true while an auto-match game still has no opponent: both send and nudge
// are disabled (there is no one to message or hurry yet).
waiting?: boolean;
nudgeOnCooldown?: boolean;
onsend: (text: string) => void;
onnudge: () => void;
@@ -56,11 +60,11 @@
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
{/if}
</div>
</div>
+6 -2
View File
@@ -17,7 +17,11 @@
let tick = $state(0);
const myId = $derived(app.session?.userId ?? '');
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const isMyTurn = $derived(
!!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat,
);
// While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open');
const nudgeCooldownSecs = 3600;
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
// backend stays authoritative, so a move-based reset is left to it.
@@ -87,4 +91,4 @@
}
</script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
+31 -9
View File
@@ -79,7 +79,7 @@
let recentFlash = $state(false);
function refreshRecent() {
const v = view;
if (!v || v.game.status !== 'active') {
if (!v || v.game.status === 'finished') {
recent = new Set();
recentFlash = false;
return;
@@ -98,8 +98,12 @@
});
const slots = $derived(rackView(placement));
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
// 'open' is an auto-match game still waiting for an opponent: the starter may move on their
// turn just like an active game, so "playable" covers both; only 'finished' is over.
const waitingForOpponent = $derived(!!view && view.game.status === 'open');
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status === 'finished');
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
// move while the game is active, but never the viewer themselves (their own pending cell
@@ -215,6 +219,13 @@
if (view && e.moveCount > view.game.moveCount) void load();
} else if (e.kind === 'game_over' && e.gameId === id) {
applyDelta(applyGameOver(cacheSnapshot(), e.game));
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
// The opponent took the empty seat: adopt the new participants and status in place,
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
if (view) {
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
setCachedGame(id, view, moves);
}
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
// A request the player sent was answered: re-derive the in-game "add friend" state.
void loadFriends();
@@ -748,10 +759,21 @@
}
}
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
// not yet a friend (an already-requested opponent still shows it, but disabled).
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
// opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean {
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
}
</script>
@@ -763,7 +785,7 @@
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="nm">{seatName(s)}</div>
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyOpen && canAddFriend(s.accountId)}
<span class="addfriend">
@@ -788,7 +810,7 @@
{#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
@@ -855,7 +877,7 @@
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else if placement.pending.length === 0}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])}</span>
{/if}
<span class="scores">
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
+14
View File
@@ -4,6 +4,7 @@ import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet';
import {
decodeDraftView,
decodeEvent,
decodeFriendList,
decodeGameList,
decodeInvitation,
@@ -264,6 +265,19 @@ describe('codec', () => {
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
expect(inv.variant).toBe('scrabble_en');
});
it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
const b = new Builder(64);
const gid = b.createString('g-open');
fb.MatchFoundEvent.startMatchFoundEvent(b);
fb.MatchFoundEvent.addGameId(b, gid);
b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
kind: 'opponent_joined',
gameId: 'g-open',
state: undefined,
});
});
});
// The live play loop exchanges alphabet indices, mapped through the per-variant
+6
View File
@@ -465,6 +465,12 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
const st = e.state();
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'opponent_joined': {
// opponent_joined reuses the match_found payload layout (game id + the recipient's state).
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
const st = e.state();
return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
}
case 'notify': {
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
const acc = e.account();
+5
View File
@@ -20,4 +20,9 @@ if (isMock && typeof window !== 'undefined') {
offline: reportOffline,
online: reportOnline,
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(window as unknown as { __mock?: { joinOpponent(): void } }).__mock = {
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
};
}
+3
View File
@@ -51,11 +51,14 @@ export const en = {
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
'new.moveLimit': 'Move time: {n} h 00 min',
'new.searchHint':
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
'game.bag': '{n} in the bag',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
'game.searchingForOpponent': 'Searching for opponent…',
'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move',
'game.reset': 'Reset',
+3
View File
@@ -52,11 +52,14 @@ export const ru: Record<MessageKey, string> = {
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
'new.searchHint':
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
'game.bag': '{n} в мешке',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
'game.searchingForOpponent': 'Поиск соперника...',
'game.waiting': 'Ожидаем {name}',
'game.makeMove': 'Сделать ход',
'game.reset': 'Сброс',
+35 -8
View File
@@ -88,6 +88,8 @@ export class MockGateway implements GatewayClient {
private readonly profile: Profile = { ...PROFILE };
private readonly subs = new Set<(e: PushEvent) => void>();
private pendingMatch: string | null = null;
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
private openGameId: string | null = null;
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
private outgoing: AccountRef[] = [];
@@ -143,14 +145,16 @@ export class MockGateway implements GatewayClient {
// --- lobby ---
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
// Simulate a 10s-style robot substitution, sped up: match found shortly.
// The player enters an open game immediately and waits inside it; a robot opponent takes
// the empty seat shortly (a sped-up version of the backend's 90180 s wait), pushing
// opponent_joined so the game UI restores from the "searching for opponent" state.
const id = crypto.randomUUID();
const g: MockGame = {
view: {
id,
variant,
dictVersion: 'v1',
status: 'active',
status: 'open',
players: 2,
toMove: 0,
turnTimeoutSecs: 86400,
@@ -160,7 +164,7 @@ export class MockGateway implements GatewayClient {
lastActivityUnix: Math.floor(Date.now() / 1000),
seats: [
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
],
},
moves: [],
@@ -170,9 +174,28 @@ export class MockGateway implements GatewayClient {
chat: [],
};
this.games.set(id, g);
this.pendingMatch = id;
setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400);
return { matched: false };
this.openGameId = id;
// The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
// through the __mock hook (see lib/gateway.ts).
setTimeout(() => this.fillOpponent(id), 3000);
return { matched: false, game: structuredClone(g.view) };
}
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
private fillOpponent(id: string): void {
const game = this.games.get(id);
if (!game || game.view.status !== 'open') return;
game.view.status = 'active';
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
}
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
// opponent to the most recently opened game on demand, making the waiting → joined transition
// deterministic.
joinPendingOpponent(): void {
if (this.openGameId) this.fillOpponent(this.openGameId);
}
async lobbyPoll(): Promise<MatchResult> {
@@ -190,8 +213,8 @@ export class MockGateway implements GatewayClient {
}
// --- game ---
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
const g = this.game(gameId);
// stateOf builds a player's StateView from a mock game (the viewer is always ME).
private stateOf(g: MockGame): StateView {
return {
game: structuredClone(g.view),
seat: this.mySeat(g),
@@ -201,6 +224,10 @@ export class MockGateway implements GatewayClient {
};
}
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
return this.stateOf(this.game(gameId));
}
async gameHistory(gameId: string): Promise<History> {
const g = this.game(gameId);
return { gameId, moves: structuredClone(g.moves) };
+6 -4
View File
@@ -5,8 +5,9 @@
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
/** Backend game status strings. */
export type GameStatus = 'active' | 'finished' | string;
/** Backend game status strings. 'open' is an auto-match game the player has entered
* but which is still waiting for an opponent (the opponent seat has no account). */
export type GameStatus = 'active' | 'finished' | 'open' | string;
/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;
@@ -237,8 +238,8 @@ export interface GameList {
/**
* A live event delivered over the Subscribe stream. The game events carry the move as a
* delta — move plus the post-move summary (and the bag size) — the client applies to its
* cached game without a refetch; match_found / game_started carry the recipient's initial
* StateView; notify carries the changed lobby payload. The enriched fields are optional
* cached game without a refetch; match_found / game_started / opponent_joined carry the
* recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
*/
export type PushEvent =
@@ -248,5 +249,6 @@ export type PushEvent =
| { kind: 'chat_message'; message: ChatMessage }
| { kind: 'nudge'; gameId: string; fromUserId: string }
| { kind: 'match_found'; gameId: string; state?: StateView }
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
| { kind: 'heartbeat' };
+2
View File
@@ -52,6 +52,8 @@
const groups = $derived(groupGames(games, myId));
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');
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
+18 -105
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
@@ -41,79 +41,25 @@
let mode = $state<'auto' | 'friends'>('auto');
// --- auto-match ---
let searching = $state(false);
// matched guards the teardown: once a match arrives (immediately, via the match_found push, or
// via the fallback poll) onDestroy must not dequeue the game we just got.
let matched = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
function stop() {
if (poll) {
clearInterval(poll);
poll = null;
}
}
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
// up the match_found push drives navigation. It polls lobby.poll every 2.5s.
function startPoll() {
if (poll) return;
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
matched = true;
searching = false;
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned.
function cancelSearch() {
stop();
searching = false;
void gateway.lobbyCancel().catch(() => {});
navigate('/');
}
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
// opponent, or another player's open game they just joined — so we navigate into it at once
// and the player waits inside. The opponent (a human or, after the wait, a robot) takes the
// empty seat later via the opponent_joined push; there is no separate "searching" screen.
let starting = $state(false);
async function find(v: Variant) {
searching = true;
matched = false;
if (starting) return;
starting = true;
try {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
if (r.matched && r.game) {
matched = true;
searching = false;
navigate(`/game/${r.game.id}`);
}
if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) {
searching = false;
handleError(e);
} finally {
starting = false;
}
// No immediate match: wait for the match_found push; the effect below polls only when the
// stream is down.
}
// Poll for the match only while searching and the stream is down (the push cannot reach us);
// stop once the stream is back or the search ends.
$effect(() => {
if (searching && !app.streamAlive) startPoll();
else stop();
});
// match_found is handled globally (app.svelte navigates); end the search here too so onDestroy
// does not cancel the match we just received.
$effect(() => {
if (app.lastEvent?.kind === 'match_found' && searching) {
matched = true;
searching = false;
}
});
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
@@ -161,23 +107,10 @@
}
}
onDestroy(() => {
stop();
// Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't
// linger. A received match (matched) must not be cancelled.
if (searching && !matched) void gateway.lobbyCancel().catch(() => {});
});
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={cancelSearch}>{t('common.cancel')}</button>
</div>
{:else}
{#if !guest}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
@@ -214,9 +147,10 @@
</label>
{/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<button
class="invite"
disabled={!selectedAuto || !connection.online}
disabled={!selectedAuto || !connection.online || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{:else if friends.length === 0}
@@ -266,7 +200,6 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
</div>
</Screen>
@@ -460,31 +393,11 @@
.invite:disabled {
opacity: 0.5;
}
.searching {
display: grid;
place-items: center;
gap: 14px;
padding: 48px 0;
.searchhint {
margin: 0;
text-align: center;
color: var(--text-muted);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cancel {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 0.85rem;
line-height: 1.4;
}
</style>