feat(nudge): name the sender; blink lobby cards; re-animate toasts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s

The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:

- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);

falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.

Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.

Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.

Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
This commit is contained in:
Ilia Denisov
2026-06-16 19:09:24 +02:00
parent e9dfa4ccb8
commit 12d128f1cc
26 changed files with 331 additions and 36 deletions
+8 -2
View File
@@ -34,6 +34,9 @@ import type { BoardLabelMode } from './boardlabels';
export interface Toast {
kind: 'error' | 'info';
text: string;
/** A monotonic id bumped on every showToast, so the Toast component re-keys and replays its
* entrance — the freshest toast cancels the previous one and starts its own appear cycle. */
seq: number;
}
export const app = $state<{
@@ -95,6 +98,7 @@ export const app = $state<{
let unsubscribeStream: (() => void) | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let toastSeq = 0;
// Background/foreground tracking, to silence the reconnect banner during a normal app
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
@@ -135,7 +139,7 @@ function goForeground(): void {
}
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
app.toast = { kind, text, seq: ++toastSeq };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (app.toast = null), 4000);
}
@@ -223,7 +227,9 @@ function openStream(): void {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
}
} else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info');
// Name the nudger (their per-game seat name, carried on the event), so the toast reads
// "<opponent>: …" like the your-turn toast; fall back to the plain phrase when absent.
showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
// Name the player who moved just before this one (the previous seat in turn order), so the
// toast reads the same in games with any number of players. Fall back to the bare label when