Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke

- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session
- pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru
- in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend
- board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat
- Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
Ilia Denisov
2026-06-03 00:32:50 +02:00
parent 19ae8f04a2
commit 453ddc5e94
48 changed files with 5696 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
<script lang="ts">
import { onMount } from 'svelte';
import Header from '../components/Header.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { GameView } from '../lib/model';
let games = $state<GameView[]>([]);
let menuOpen = $state(false);
async function load() {
try {
games = (await gateway.gamesList()).games;
} catch (e) {
handleError(e);
}
}
onMount(load);
// Refresh the lists when a live event lands (move / your-turn / match-found).
$effect(() => {
if (app.lastEvent) void load();
});
const myId = $derived(app.session?.userId ?? '');
const active = $derived(games.filter((g) => g.status === 'active'));
const finished = $derived(games.filter((g) => g.status !== 'active'));
function mySeat(g: GameView) {
return g.seats.find((s) => s.accountId === myId);
}
function opponents(g: GameView): string {
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
.join(', ');
}
function subtitle(g: GameView): string {
const me = mySeat(g);
if (g.status === 'active') {
return g.toMove === me?.seat ? t('lobby.yourTurn') : t('lobby.theirTurn');
}
if (me?.isWinner) return t('game.won');
return g.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
function scoreline(g: GameView): string {
const me = mySeat(g);
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
function go(path: string) {
menuOpen = false;
navigate(path);
}
</script>
<Header title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => go('/profile')}>{t('lobby.profile')}</button>
<button onclick={() => go('/settings')}>{t('lobby.settings')}</button>
<button onclick={() => go('/about')}>{t('lobby.about')}</button>
</div>
{/if}
{/snippet}
</Header>
<main class="lobby">
<section>
<h2>{t('lobby.activeGames')}</h2>
{#if active.length === 0}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
{#each active as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub" class:turn={g.toMove === mySeat(g)?.seat}>{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
<section>
<h2>{t('lobby.finishedGames')}</h2>
{#if finished.length === 0}
<p class="empty">{t('lobby.noFinished')}</p>
{/if}
{#each finished as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub">{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
</main>
<nav class="tabs">
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button>
</nav>
<style>
.lobby {
padding: var(--pad);
padding-bottom: 84px;
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;
}
.row {
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);
}
.who {
font-weight: 600;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
.sub.turn {
color: var(--accent);
font-weight: 600;
}
.score {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 0.85rem;
}
.tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 8px;
padding: 10px var(--pad);
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.tab.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
}
</style>