feat(ui): batch of UI polish tweaks
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

- admin dashboard "Users" count excludes robots (CountUsers, humans only)
- lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev)
- mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack
- lobby: status icons -25%, game-row top/bottom padding halved
- toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses
  on tap; error tier unchanged
- game: confirm-move button stays pinned at the right edge; rack tiles shrink to
  fit so a full rack never overflows onto it
- telegram: a successful invite-link redeem shows a welcome window pointing at the bot
- settings: remove the grid-lines toggle; the board is always a gapless checkerboard
- settings/comms hubs: the selected tab highlight wraps icon + label as a pill

Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the
removed grid-lines toggle.
This commit is contained in:
Ilia Denisov
2026-06-19 08:56:29 +02:00
parent 1c87313a78
commit ab1ad998aa
20 changed files with 227 additions and 101 deletions
+19 -4
View File
@@ -51,10 +51,25 @@
line-height: 1;
transition: background-color 0.12s;
}
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
while selected: a filled square with an accent underline. A tap itself leaves no
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */
:global(.tab.active .sq) {
/* A tab that navigates between peer views (Settings / Comms hubs) wraps its icon and label
in a .face so the selected highlight hugs both — a filled pill with an accent underline,
sized to the content with a small padding. A tap itself leaves no highlight (no press
tint, and the WebKit tap flash is disabled on .tab). Plain action tabs (the lobby nav,
the in-game controls) carry no .face and are never .active, so they are unaffected. */
:global(.tab .face) {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
transition: background-color 0.12s;
}
/* Inside a face the icon square needs no padding of its own — the face provides the
surround (the bare .sq padding still applies to the unwrapped action tabs). */
:global(.tab .face .sq) {
padding: 0;
}
:global(.tab.active .face) {
background: var(--surface-2);
box-shadow: inset 0 -2px 0 var(--accent);
}
+49 -5
View File
@@ -1,20 +1,28 @@
<script lang="ts">
import { fade, fly } from 'svelte/transition';
import { app } from '../lib/app.svelte';
import { app, dismissToast } from '../lib/app.svelte';
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
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour.
const isInfo = $derived(app.toast?.kind !== 'error');
</script>
{#if app.toast}
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
<!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
appear cycle: the newest toast cancels the previous one and starts its own. -->
{#key app.toast.seq}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="toast {app.toast.kind}"
class:rise={isInfo && !app.reduceMotion}
class:rise-reduced={isInfo && app.reduceMotion}
role="status"
aria-live="polite"
in:fly={{ y: 32, duration: dur }}
out:fade={{ duration: dur }}
onclick={dismissToast}
in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
out:fade={{ duration: isInfo ? 0 : dur }}
>
{app.toast.text}
</div>
@@ -36,9 +44,45 @@
box-shadow: var(--shadow);
z-index: 50;
text-align: center;
cursor: pointer;
}
.error {
border-color: var(--danger);
color: var(--danger);
}
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out,
all within 2s; the X centring is preserved across the rise. */
.toast.rise {
animation: toast-rise 2s ease-out forwards;
}
@keyframes toast-rise {
0% {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
12% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-56px);
}
}
/* Reduced motion: the same 2s life, fade only — no upward travel. */
.toast.rise-reduced {
animation: toast-rise-reduced 2s ease-out forwards;
}
@keyframes toast-rise-reduced {
0% {
opacity: 0;
}
12%,
70% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
@@ -0,0 +1,58 @@
<script lang="ts">
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to the
// interface locale, so an ru player is sent to the ru bot and an en player to the en one.
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// Greet the arriving player by their own display name.
const name = $derived(app.profile?.displayName || app.session?.displayName || '');
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders
// as an inline link (the {name} value is substituted first; {bot} is left for the split).
const parts = $derived(t('friends.welcomeRedeem', { name }).split('{bot}'));
function openBot() {
if (username) {
const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab.
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
}
dismissWelcomeRedeem();
}
</script>
{#if app.welcomeRedeem}
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
</Modal>
{/if}
<style>
.msg {
margin: 0 0 16px;
line-height: 1.5;
/* The greeting and the bot sentence are separated by a blank line in the message. */
white-space: pre-line;
}
.bot {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.ok {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
</style>