Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s

Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
This commit is contained in:
Ilia Denisov
2026-06-03 19:47:40 +02:00
parent 539e24fba1
commit d733ce3119
114 changed files with 8210 additions and 149 deletions
+46 -3
View File
@@ -1,6 +1,13 @@
<script lang="ts">
// The header hamburger + dropdown, shared by the lobby and game screens.
let { items }: { items: { label: string; onclick: () => void }[] } = $props();
// The header hamburger + dropdown, shared by the lobby and game screens. An item
// may carry a numeric badge; the hamburger shows the total via the `badge` prop so
// a pending count is visible while the menu is closed.
interface MenuItem {
label: string;
onclick: () => void;
badge?: number;
}
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
let open = $state(false);
function pick(fn: () => void) {
@@ -12,6 +19,7 @@
<div class="menu">
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
<span></span><span></span><span></span>
{#if badge > 0}<span class="dot" data-testid="menu-badge">{badge}</span>{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -19,7 +27,10 @@
<div class="backdrop" onclick={() => (open = false)}></div>
<div class="dropdown">
{#each items as it (it.label)}
<button onclick={() => pick(it.onclick)}>{it.label}</button>
<button onclick={() => pick(it.onclick)}>
<span>{it.label}</span>
{#if it.badge}<span class="idot">{it.badge}</span>{/if}
</button>
{/each}
</div>
{/if}
@@ -31,6 +42,7 @@
display: inline-flex;
}
.burger {
position: relative;
background: none;
border: none;
width: 44px;
@@ -43,6 +55,33 @@
user-select: none;
-webkit-user-select: none;
}
.dot {
position: absolute;
top: -2px;
right: 0;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.idot {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.burger span {
display: block;
height: 3px;
@@ -69,6 +108,10 @@
overflow: hidden;
}
.dropdown button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
text-align: left;
background: none;