Compare commits

..

6 Commits

Author SHA1 Message Date
developer ac383880b7 Merge pull request 'fix(ui): hold info toast ~1s before it rises and fades' (#181) from feature/toast-hold-before-fade into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m7s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m44s
2026-07-05 12:24:08 +00:00
developer 16349f5ad9 Merge pull request 'fix(ui): poll for out-of-band email confirmation' (#180) from feature/email-confirm-poll-fallback into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m45s
2026-07-05 12:20:38 +00:00
developer 101a3f118c Merge pull request 'feat(ui): hide current-host sign-in row in profile' (#179) from feature/hide-current-host-signin-row into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m4s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m33s
2026-07-05 12:16:22 +00:00
Ilia Denisov 400b6ac2a5 fix(ui): hold info toast ~1s before it rises and fades
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
The info toast began drifting up and fading the instant it finished
appearing (the CSS keyframes went straight from the 12% appeared-stop to
the 100% risen-and-faded stop), so a glanced message was already leaving.

Insert a ~1s hold at full opacity/rest before the rise-and-fade: extend the
animation 2s → 3s with keyframe stops at 8% (appeared, ~240ms) and 41% (end
of the ~1s hold), keeping the original rise-and-fade pace for the tail. The
reduced-motion variant gets the same appear/hold/fade timing (fade only, no
travel). The showToast dismissal timer is bumped 2000 → 3000ms to stay in
lockstep with the animation (the error toast's 4s dwell is unchanged).
2026-07-05 14:10:34 +02:00
Ilia Denisov fb7490f1df fix(ui): poll for out-of-band email confirmation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
An email link/code can be confirmed out of band: the recipient taps the
one-tap link in the email, which confirms in another browser/session. The
backend already publishes a `notify` `profile` re-fetch signal for this
(handlers_auth.go handleEmailConfirmLink), and the client re-fetches on it.

But the live stream is single-shot with no replay: a Mini App backgrounded
while the user is in their mail app tapping the link drops the stream and
misses the event (the gateway hub has no subscriber to deliver to), and the
reconnect on foreground does not re-sync — so the open code form stayed
until a manual reload. On Telegram Desktop the app is never backgrounded,
so the push works and there was no bug.

Add a client-side fallback: while an add-email confirmation is pending
(a code was sent, no email yet), poll `profile.get` on a 4s interval and on
foreground regain until the address lands; the effect stops as soon as the
email appears. The live push still updates instantly when foregrounded —
this only covers the backgrounded-miss gap.

Tests: a mock e2e attaches the email WITHOUT emitting a live event (new
window.__mock.clearEmail / confirmEmailOutOfBand seams), so it exercises the
poll, not the push, and asserts the code form collapses into the email row.
Docs: ARCHITECTURE.md §10 notes the single-shot gap + the poll fallback.
2026-07-05 13:58:39 +02:00
Ilia Denisov c1805e5b7c feat(ui): hide current-host sign-in row in profile
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m6s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Inside a Telegram/VK Mini App the host provider is auto-linked. Once the
player also linked an email, `canUnlink` turned true and the "Unlink"
control appeared on the host platform's own row — letting them unlink the
very platform they are signed in through, which is meaningless.

Gate the Telegram row on `!insideTelegram()` and the VK row on
`!insideVK()`, reusing the runtime host detectors that already gate the
"link" buttons. Symmetric: inside TG only the TG row is hidden (the VK row
still shows, since VK is not the current host), and vice versa. The web and
native builds are unchanged (both detectors are false there); the backend
is untouched — this is a UI display gate, and `linkUnlink` still refuses to
remove the last identity.

Docs: FUNCTIONAL.md (+_ru mirror).
2026-07-05 13:10:40 +02:00
9 changed files with 106 additions and 21 deletions
+4 -1
View File
@@ -983,7 +983,10 @@ and the open client re-fetches `profile.get` to show or hide the banner in place
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
own account changed out of band (an email confirmed through the one-tap deeplink opened in another own account changed out of band (an email confirmed through the one-tap deeplink opened in another
browser), so an open in-app session reflects it at once. browser), so an open in-app session reflects it at once. The live stream is single-shot with no
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
link — misses the event; while an add-email confirmation is pending the client therefore also
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into > A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap > one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
+3 -1
View File
@@ -109,7 +109,9 @@ two accounts share a game still in progress.
The profile lists the account's **sign-in methods**. On the web a player can add The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
back); inside a Mini App the host platform is already linked. Linking a provider that back); inside a Mini App the host platform is already linked, and its own sign-in-method
row is hidden — the platform the player is currently signed in through cannot be unlinked
from there, only the other provider's row is shown. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is remaining sign-in method, which is refused so the account stays reachable. **Email is
+3 -1
View File
@@ -113,7 +113,9 @@ Telegram держит **единого бота**: все игроки поль
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
показывается только плашка другого провайдера. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
+30
View File
@@ -327,6 +327,36 @@ test('change email: a free address replaces the current one', async ({ page }) =
await expect(page.getByText('you@example.com')).toHaveCount(0); await expect(page.getByText('you@example.com')).toHaveCount(0);
}); });
// The add-email confirmation can complete out of band: the recipient taps the one-tap link in the
// email, which confirms in another browser/session. A backgrounded Mini App misses the live
// 'profile' event (the stream is single-shot, no replay), so the open code form falls back to
// polling the profile until the address lands. The mock attaches the email WITHOUT emitting an
// event, so only the poll can surface it.
test('add email: an out-of-band confirmation surfaces on the open form via polling', async ({ page }) => {
await loginLobby(page);
await openProfile(page);
// Drop the seeded email so the sign-in section offers the add-email flow.
await page.evaluate(() => (window as unknown as { __mock: { clearEmail(): void } }).__mock.clearEmail());
const emailInput = page.locator('.accounts input[type="email"]');
await expect(emailInput).toBeVisible();
await emailInput.fill('linked@example.com');
await page.getByRole('button', { name: 'Send code' }).click();
await expect(page.locator('.accounts .codein')).toBeVisible();
// Confirmed elsewhere via the one-tap link, with no live event delivered: only the poll surfaces it.
await page.evaluate(() =>
(
window as unknown as { __mock: { confirmEmailOutOfBand(email: string): void } }
).__mock.confirmEmailOutOfBand('linked@example.com'),
);
// The confirmed address surfaces as the email row, and the code form is gone.
await expect(page.locator('.acctrow').filter({ hasText: 'linked@example.com' })).toBeVisible({ timeout: 10000 });
await expect(page.locator('.accounts .codein')).toHaveCount(0);
});
test('link then unlink Telegram from the sign-in methods', async ({ page }) => { test('link then unlink Telegram from the sign-in methods', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await openProfile(page); await openProfile(page);
+13 -10
View File
@@ -3,8 +3,9 @@
import { app, dismissToast } from '../lib/app.svelte'; import { app, dismissToast } from '../lib/app.svelte';
const dur = $derived(app.reduceMotion ? 0 : 260); const dur = $derived(app.reduceMotion ? 0 : 260);
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes // An info bubble owns its whole 3s life through a CSS animation — appear, a ~1s hold at rest,
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour. // then rise-and-fade — so it takes no Svelte enter/leave transition; an error keeps the fly-in /
// fade-out dwell behaviour.
const isInfo = $derived(app.toast?.kind !== 'error'); const isInfo = $derived(app.toast?.kind !== 'error');
</script> </script>
@@ -50,17 +51,19 @@
border-color: var(--danger); border-color: var(--danger);
color: var(--danger); color: var(--danger);
} }
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out, /* An info bubble fades in, holds for ~1s at rest, then drifts up by roughly the tab-bar height
all within 2s; the X centring is preserved across the rise. */ while fading out, all within 3s; the X centring is preserved across the rise. The 8%/41%
stops are the appear (~240ms) and the end of the ~1s hold. */
.toast.rise { .toast.rise {
animation: toast-rise 2s ease-out forwards; animation: toast-rise 3s ease-out forwards;
} }
@keyframes toast-rise { @keyframes toast-rise {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateX(-50%) translateY(8px); transform: translateX(-50%) translateY(8px);
} }
12% { 8%,
41% {
opacity: 1; opacity: 1;
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
} }
@@ -69,16 +72,16 @@
transform: translateX(-50%) translateY(-56px); transform: translateX(-50%) translateY(-56px);
} }
} }
/* Reduced motion: the same 2s life, fade only — no upward travel. */ /* Reduced motion: the same 3s life (appear, ~1s hold, fade), fade only — no upward travel. */
.toast.rise-reduced { .toast.rise-reduced {
animation: toast-rise-reduced 2s ease-out forwards; animation: toast-rise-reduced 3s ease-out forwards;
} }
@keyframes toast-rise-reduced { @keyframes toast-rise-reduced {
0% { 0% {
opacity: 0; opacity: 0;
} }
12%, 8%,
70% { 41% {
opacity: 1; opacity: 1;
} }
100% { 100% {
+3 -3
View File
@@ -214,9 +214,9 @@ function goForeground(): void {
export function showToast(text: string, kind: Toast['kind'] = 'info'): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text, seq: ++toastSeq }; app.toast = { kind, text, seq: ++toastSeq };
if (toastTimer) clearTimeout(toastTimer); if (toastTimer) clearTimeout(toastTimer);
// An info bubble lives exactly as long as its rise-and-fade animation (2s); an error // An info bubble lives exactly as long as its appear + ~1s hold + rise-and-fade animation (3s);
// dwells longer (4s) so it is not missed. // an error dwells longer (4s) so it is not missed.
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000); toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 3000);
} }
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */ /** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
+4
View File
@@ -39,6 +39,8 @@ if (isMock && typeof window !== 'undefined') {
joinOpponentSilently(): void; joinOpponentSilently(): void;
adminReply(): void; adminReply(): void;
setGameLimit(v: boolean): void; setGameLimit(v: boolean): void;
clearEmail(): void;
confirmEmailOutOfBand(email: string): void;
}; };
} }
).__mock = { ).__mock = {
@@ -46,5 +48,7 @@ if (isMock && typeof window !== 'undefined') {
joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(), joinOpponentSilently: () => (gateway as MockGateway).joinPendingOpponentSilently(),
adminReply: () => (gateway as MockGateway).mockAdminReply(), adminReply: () => (gateway as MockGateway).mockAdminReply(),
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v), setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
}; };
} }
+11
View File
@@ -659,6 +659,17 @@ export class MockGateway implements GatewayClient {
this.profile.email = email; this.profile.email = email;
return emptyLinked(); return emptyLinked();
} }
// e2e hooks (window.__mock, see lib/gateway.ts): mockClearEmail drops the seeded email so the
// sign-in section offers the add-email flow; mockConfirmEmailOutOfBand attaches the address
// WITHOUT a live event — standing in for the one-tap confirm link opened in another browser,
// so the open code form must reflect it by polling the profile, not by a push.
mockClearEmail(): void {
this.profile.email = '';
this.emit({ kind: 'notify', sub: 'profile' });
}
mockConfirmEmailOutOfBand(email: string): void {
this.profile.email = email;
}
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> { async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
this.profile.isGuest = false; this.profile.isGuest = false;
this.profile.email = email; this.profile.email = email;
+35 -5
View File
@@ -7,13 +7,15 @@
confirmDeleteAccount, confirmDeleteAccount,
handleError, handleError,
logout, logout,
refreshProfile,
requestDeleteAccount, requestDeleteAccount,
showToast, showToast,
} from '../lib/app.svelte'; } from '../lib/app.svelte';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram'; import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid'; import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { import {
@@ -58,6 +60,11 @@
let deletePhrase = $state(''); let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable(); const telegramLinkable = loginWidgetAvailable();
const vkLinkable = vkWebLinkAvailable(); const vkLinkable = vkWebLinkAvailable();
// Inside a host Mini App the current platform's own identity must not be managed from the
// profile — unlinking the very platform you are signed in through is meaningless — so that
// provider's linked-account row is hidden here (the web / native builds still show both).
const hostTelegram = insideTelegram();
const hostVK = insideVK();
function defaultTz(): string { function defaultTz(): string {
const b = browserOffset(); const b = browserOffset();
@@ -106,6 +113,27 @@
); );
const canUnlink = $derived(linkedCount >= 2); const canUnlink = $derived(linkedCount >= 2);
// While an add-email confirmation is pending, the address may be confirmed out of band — the
// one-tap link in the email confirms in another browser/session. A backgrounded Mini App drops
// the single-shot live stream and misses the 'profile' event (there is no replay), so poll the
// profile as a fallback until the address lands; the live push still updates it instantly while
// the app stays foregrounded. Scoped to the add-email wait (a code sent, no email yet).
const awaitingEmailConfirm = $derived(emailSent && !app.profile?.email);
$effect(() => {
if (!awaitingEmailConfirm) return;
const poll = (): void => void refreshProfile();
const id = setInterval(poll, 4000);
// Re-check the moment the Mini App returns to the foreground, where the missed event landed.
const onVisible = (): void => {
if (document.visibilityState === 'visible') poll();
};
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
};
});
// toggleVariant flips a variant in the preference set, refusing to remove the last one — // toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant. // the player must allow themselves at least one variant.
function toggleVariant(id: Variant) { function toggleVariant(id: Variant) {
@@ -392,8 +420,10 @@
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email <!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the (add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
host provider is already linked. Email is never unlinked — it is changed. Unlink is host provider is already linked, so its own row is hidden — the platform you are signed
offered only when another identity remains (the backend also refuses the last one). --> in through cannot be unlinked from here (the other provider's row still shows). Email is
never unlinked — it is changed. Unlink is offered only when another identity remains
(the backend also refuses the last one). -->
<section class="accounts"> <section class="accounts">
<h3>{t('profile.accountsTitle')}</h3> <h3>{t('profile.accountsTitle')}</h3>
@@ -440,7 +470,7 @@
</div> </div>
{/if} {/if}
{#if p.telegramLinked} {#if p.telegramLinked && !hostTelegram}
<div class="acctrow"> <div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span> <span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink} {#if canUnlink}
@@ -453,7 +483,7 @@
</button> </button>
{/if} {/if}
{#if p.vkLinked} {#if p.vkLinked && !hostVK}
<div class="acctrow"> <div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span> <span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink} {#if canUnlink}