From 2ecdecad1e9f6548c2fe32cd1473f0ac218f55f3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 26 May 2026 13:42:10 +0200 Subject: [PATCH] feat(ui): lobby site-style sidebar + profile screen (#47) - Wrap lobby and profile in a shared `lobby-shell.svelte` chrome: page-list sidebar (Overview/Profile) and a top "Player-xxxx" identity strip mirroring the project site's monospace look. - Strip the legacy `lobby.title`, device-session-id ``, and `lobby.greeting` paragraph; the identity strip both names the user and opens the profile editor. - Add a top-level `profile` AppScreen with a three-field form (`display_name`, `preferred_language`, `time_zone`) backed by a new `src/api/account.ts` wrapper around `user.account.get`, `user.profile.update`, and `user.settings.update`. Saving switches the active i18n locale in-place when the new preferred language is one the UI ships translations for. - Update e2e fixture + auth-flow / lobby-flow specs to use the new `lobby-account-name` testid and wait for the loaded identity before releasing pending `SubscribeEvents` (webkit revocation race). New `profile-screen.spec.ts` covers navigation, edit-save, and cancel. - Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new layout. Closes #47 --- ui/docs/lobby.md | 63 +++- ui/docs/navigation.md | 18 +- ui/frontend/src/api/account.ts | 132 +++++++ ui/frontend/src/app.d.ts | 2 +- ui/frontend/src/lib/app-nav.svelte.ts | 9 +- ui/frontend/src/lib/i18n/locales/en.ts | 21 +- ui/frontend/src/lib/i18n/locales/ru.ts | 21 +- .../src/lib/screens/lobby-screen.svelte | 132 +++---- .../src/lib/screens/lobby-shell.svelte | 220 ++++++++++++ .../src/lib/screens/profile-screen.svelte | 333 ++++++++++++++++++ ui/frontend/src/routes/+page.svelte | 5 + ui/frontend/tests/e2e/auth-flow.spec.ts | 27 +- ui/frontend/tests/e2e/fixtures/lobby-fbs.ts | 9 +- ui/frontend/tests/e2e/lobby-flow.spec.ts | 2 +- ui/frontend/tests/e2e/profile-screen.spec.ts | 260 ++++++++++++++ 15 files changed, 1122 insertions(+), 132 deletions(-) create mode 100644 ui/frontend/src/api/account.ts create mode 100644 ui/frontend/src/lib/screens/lobby-shell.svelte create mode 100644 ui/frontend/src/lib/screens/profile-screen.svelte create mode 100644 ui/frontend/tests/e2e/profile-screen.spec.ts diff --git a/ui/docs/lobby.md b/ui/docs/lobby.md index 634134c..e5a8ebd 100644 --- a/ui/docs/lobby.md +++ b/ui/docs/lobby.md @@ -2,16 +2,36 @@ The lobby is the first authenticated view; the user lands here after the email-code login completes (see -[`docs/auth-flow.md`](auth-flow.md)). This doc captures the -sections, the application / invite lifecycle the user sees, and -the defaults baked into the create-game form. +[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared +shell, the Overview sections, the profile sub-screen, and the +defaults baked into the create-game form. -## Sections +## Shell -The lobby renders one column of sections, top to bottom, with the -common content max-width capped at `32rem` (same convention as the -login page). Cards inside each section take the full available -width. +Lobby and profile share a single chrome implemented in +`lib/screens/lobby-shell.svelte`. The chrome mirrors the project +site's VitePress layout: a left page-list sidebar (Overview / +Profile), a top identity strip on the right, and the page content in +the right-hand column. The shell uses `var(--font-mono)` so the +post-login pages adopt the "nerdy" type stack that the public site +already uses. + +The identity strip renders the caller's `display_name` (falling back +to the immutable `user_name` handle, then to a loading placeholder +while `user.account.get` resolves) as a `data-testid="lobby-account-name"` +button. Clicking it switches the top-level screen to `profile` +(`appScreen.go("profile")`); the e2e suites use that testid as their +lobby-loaded signal. The logout button sits next to it +(`session.signOut("user")`). + +The sidebar always renders both pages; clicking the active page is a +no-op. The shell collapses to a horizontal scrolling strip below +640px. + +## Overview sections + +The Overview page renders one column of sections, top to bottom. +Cards inside each section take the full available width. | Section | Empty state | Source | Action | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | @@ -21,9 +41,30 @@ width. | `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) | -The header preserves the device-session-id `` block (kept as -a debug affordance) plus a greeting if the gateway returns a -`display_name` for the caller. +## Profile sub-screen + +`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of +`lobby` and `lobby-create`). The browser Back stack treats it the +same as the create screen — pushing a fresh history entry on entry, +falling back to lobby on Back/Forward (see +[`navigation.md`](navigation.md)). + +On mount it issues `user.account.get` through `src/api/account.ts` +and renders an identity read-out (immutable `user_name`, `email`) +plus a three-field form: + +| Field | Endpoint | Notes | +| --------------------- | --------------------- | -------------------------------------------------------------- | +| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). | +| `preferred_language` | `user.settings.update`| ` + {i18n.t("profile.hint.display_name")} + + + + + + + {#if saveError !== null} +

{saveError}

+ {/if} + +
+ + +
+ + {/if} + + + diff --git a/ui/frontend/src/routes/+page.svelte b/ui/frontend/src/routes/+page.svelte index 182154d..d7505b5 100644 --- a/ui/frontend/src/routes/+page.svelte +++ b/ui/frontend/src/routes/+page.svelte @@ -19,6 +19,7 @@ import LoginScreen from "$lib/screens/login-screen.svelte"; import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte"; + import ProfileScreen from "$lib/screens/profile-screen.svelte"; import GameShell from "$lib/game/game-shell.svelte"; import { pushState } from "$app/navigation"; import { page } from "$app/state"; @@ -67,6 +68,8 @@ pushState("", { screen: "game", gameId: appScreen.gameId }); } else if (appScreen.screen === "lobby-create") { pushState("", { screen: "lobby-create" }); + } else if (appScreen.screen === "profile") { + pushState("", { screen: "profile" }); } } }); @@ -83,6 +86,8 @@ {#if session.status === "authenticated"} {#if appScreen.screen === "lobby-create"} + {:else if appScreen.screen === "profile"} + {:else if appScreen.screen === "game" && appScreen.gameId !== null} {:else} diff --git a/ui/frontend/tests/e2e/auth-flow.spec.ts b/ui/frontend/tests/e2e/auth-flow.spec.ts index de3e286..66d9d89 100644 --- a/ui/frontend/tests/e2e/auth-flow.spec.ts +++ b/ui/frontend/tests/e2e/auth-flow.spec.ts @@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); - // Sign-in switches the in-memory screen to the lobby; the device - // session id surfaces only on the lobby screen. - await expect(page.getByTestId("device-session-id")).toBeVisible(); + // Sign-in switches the in-memory screen to the lobby; the identity + // strip rendered by `lobby-shell.svelte` is the lobby-loaded signal. + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("Phase 7 — auth flow", () => { @@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("device-session-id")).toHaveText( - "dev-test-1", - ); - await expect(page.getByTestId("account-greeting")).toContainText("Pilot"); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); mocks.pendingSubscribes.forEach((resolve) => resolve()); }); @@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("account-greeting")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); await page.reload(); // The restored session re-renders the lobby screen directly (no // `/lobby` route to land on). - await expect(page.getByTestId("device-session-id")).toHaveText( - "dev-test-1", + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Pilot", ); mocks.pendingSubscribes.forEach((resolve) => resolve()); @@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => { }) => { const mocks = await mockGatewayHappyPath(page, "Pilot"); await completeLogin(page); - await expect(page.getByTestId("account-greeting")).toBeVisible(); + // `lobby-account-name` becomes visible on lobby mount with the + // "loading account…" placeholder before the gateway responds. + // Wait for the loaded name to settle so the event-stream effect + // has had a chance to issue its `SubscribeEvents` request — the + // release below targets that pending stream, and an empty + // `pendingSubscribes` list defeats the test. + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Pilot", + ); // Fire all pending SubscribeEvents requests with an empty 200 // response. Connect-Web's server-streaming reader sees no frames diff --git a/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts b/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts index 23a0580..9488ede 100644 --- a/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts +++ b/ui/frontend/tests/e2e/fixtures/lobby-fbs.ts @@ -215,6 +215,9 @@ export interface AccountFixture { email: string; userName: string; displayName: string; + preferredLanguage?: string; + timeZone?: string; + declaredCountry?: string; } export function buildAccountResponsePayload(account: AccountFixture): Uint8Array { @@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array const email = builder.createString(account.email); const userName = builder.createString(account.userName); const displayName = builder.createString(account.displayName); - const preferredLanguage = builder.createString("en"); - const timeZone = builder.createString("UTC"); - const declaredCountry = builder.createString(""); + const preferredLanguage = builder.createString(account.preferredLanguage ?? "en"); + const timeZone = builder.createString(account.timeZone ?? "UTC"); + const declaredCountry = builder.createString(account.declaredCountry ?? ""); AccountView.startAccountView(builder); AccountView.addUserId(builder, userId); AccountView.addEmail(builder, email); diff --git a/ui/frontend/tests/e2e/lobby-flow.spec.ts b/ui/frontend/tests/e2e/lobby-flow.spec.ts index 958e3e7..7d14cf5 100644 --- a/ui/frontend/tests/e2e/lobby-flow.spec.ts +++ b/ui/frontend/tests/e2e/lobby-flow.spec.ts @@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise { await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-submit").click(); // Sign-in switches the in-memory screen to the lobby. - await expect(page.getByTestId("device-session-id")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); } test.describe("Phase 8 — lobby flow", () => { diff --git a/ui/frontend/tests/e2e/profile-screen.spec.ts b/ui/frontend/tests/e2e/profile-screen.spec.ts new file mode 100644 index 0000000..dc7640c --- /dev/null +++ b/ui/frontend/tests/e2e/profile-screen.spec.ts @@ -0,0 +1,260 @@ +// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the +// lobby boots with an account aggregate, then exercises the sidebar +// navigation into the profile, the edit form, and the save round-trip +// against the FlatBuffers-decoded `user.profile.update` / +// `user.settings.update` payloads. + +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { ByteBuffer } from "flatbuffers"; +import { expect, test, type Page } from "@playwright/test"; +import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb"; +import { + UpdateMyProfileRequest, + UpdateMySettingsRequest, +} from "../../src/proto/galaxy/fbs/user"; +import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; +import { + buildAccountResponsePayload, + buildMyApplicationsListPayload, + buildMyGamesListPayload, + buildMyInvitesListPayload, + buildPublicGamesListPayload, + type AccountFixture, +} from "./fixtures/lobby-fbs"; + +interface ProfileMocks { + pendingSubscribes: Array<() => void>; + account: AccountFixture; + profileUpdates: Array<{ displayName: string }>; + settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>; +} + +async function mockGateway( + page: Page, + initial: AccountFixture, +): Promise { + const mocks: ProfileMocks = { + pendingSubscribes: [], + account: { ...initial }, + profileUpdates: [], + settingsUpdates: [], + }; + + await page.route("**/api/v1/public/auth/send-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ challenge_id: "ch-test-1" }), + }); + }); + await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ device_session_id: "dev-test-1" }), + }); + }); + + await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => { + const reqText = route.request().postData(); + if (reqText === null) { + await route.fulfill({ status: 400 }); + return; + } + const req = fromJson( + ExecuteCommandRequestSchema, + JSON.parse(reqText) as JsonValue, + ); + let payload: Uint8Array; + switch (req.messageType) { + case "user.account.get": + payload = buildAccountResponsePayload(mocks.account); + break; + case "user.profile.update": { + const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest( + new ByteBuffer(req.payloadBytes), + ); + const next = decoded.displayName() ?? ""; + mocks.profileUpdates.push({ displayName: next }); + mocks.account = { ...mocks.account, displayName: next }; + payload = buildAccountResponsePayload(mocks.account); + break; + } + case "user.settings.update": { + const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest( + new ByteBuffer(req.payloadBytes), + ); + const preferredLanguage = decoded.preferredLanguage() ?? ""; + const timeZone = decoded.timeZone() ?? ""; + mocks.settingsUpdates.push({ preferredLanguage, timeZone }); + mocks.account = { ...mocks.account, preferredLanguage, timeZone }; + payload = buildAccountResponsePayload(mocks.account); + break; + } + case "lobby.my.games.list": + payload = buildMyGamesListPayload([]); + break; + case "lobby.public.games.list": + payload = buildPublicGamesListPayload([]); + break; + case "lobby.my.invites.list": + payload = buildMyInvitesListPayload([]); + break; + case "lobby.my.applications.list": + payload = buildMyApplicationsListPayload([]); + break; + default: + payload = new Uint8Array(); + } + const responseJson = await forgeExecuteCommandResponseJson({ + requestId: req.requestId, + timestampMs: BigInt(Date.now()), + resultCode: "ok", + payloadBytes: payload, + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: responseJson, + }); + }); + + await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => { + const action = await new Promise<"endOfStream" | "abort">((resolve) => { + mocks.pendingSubscribes.push(() => resolve("endOfStream")); + }); + if (action === "abort") { + await route.abort(); + return; + } + const body = new TextEncoder().encode("{}"); + const frame = new Uint8Array(5 + body.length); + frame[0] = 0x02; + new DataView(frame.buffer).setUint32(1, body.length, false); + frame.set(body, 5); + await route.fulfill({ + status: 200, + contentType: "application/connect+json", + body: Buffer.from(frame), + }); + }); + + return mocks; +} + +async function completeLogin(page: Page): Promise { + await page.goto("/"); + await expect(page.getByTestId("login-email-input")).toBeVisible(); + await page.getByTestId("login-email-input").click(); + await page.getByTestId("login-email-input").fill("pilot@example.com"); + await page.getByTestId("login-email-submit").click(); + await expect(page.getByTestId("login-code-input")).toBeVisible(); + await page.getByTestId("login-code-input").click(); + await page.getByTestId("login-code-input").fill("123456"); + await page.getByTestId("login-code-submit").click(); + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); +} + +test.describe("F8-04 — profile screen", () => { + test("clicking the identity strip opens the profile and renders the form", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot"); + + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot"); + await expect(page.getByTestId("profile-identity")).toContainText( + "player-abc12345", + ); + await expect(page.getByTestId("profile-identity")).toContainText( + "pilot@example.com", + ); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("saving an edited display name posts user.profile.update and returns to lobby", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + + await page.getByTestId("profile-display-name").fill("Captain"); + await page.getByTestId("profile-save").click(); + + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + await expect(page.getByTestId("lobby-account-name")).toContainText( + "Captain", + ); + expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]); + expect(mocks.settingsUpdates).toEqual([]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("changing the language posts user.settings.update and switches the active locale", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + + await page.getByTestId("profile-preferred-language").selectOption("ru"); + await page.getByTestId("profile-save").click(); + + await expect(page.getByTestId("lobby-account-name")).toBeVisible(); + // The lobby switches to the Russian dictionary after the save — + // the "create new game" button label is the visible signal. + await expect(page.getByTestId("lobby-create-button")).toHaveText( + "создать новую игру", + ); + expect(mocks.settingsUpdates).toEqual([ + { preferredLanguage: "ru", timeZone: "UTC" }, + ]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("cancel returns to the lobby without posting anything", async ({ + page, + }) => { + const mocks = await mockGateway(page, { + userId: "user-1", + email: "pilot@example.com", + userName: "player-abc12345", + displayName: "Pilot", + }); + await completeLogin(page); + await page.getByTestId("lobby-account-name").click(); + await expect(page.getByTestId("profile-form")).toBeVisible(); + + await page.getByTestId("profile-display-name").fill("ignored"); + await page.getByTestId("profile-cancel").click(); + + await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible(); + expect(mocks.profileUpdates).toEqual([]); + expect(mocks.settingsUpdates).toEqual([]); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); +});