feat(ui): lobby site-style sidebar + profile screen (#47)
Deploy · Dev / deploy (push) Successful in 49s
Tests · UI / test (push) Successful in 2m39s

Closes #47

Round 2 polish: time-zone dropdown, save-stay on profile, shared account store removing header flicker.
This commit was merged in pull request #60.
This commit is contained in:
2026-05-26 20:53:14 +00:00
18 changed files with 1494 additions and 135 deletions
+71 -11
View File
@@ -2,16 +2,48 @@
The lobby is the first authenticated view; the user lands here after The lobby is the first authenticated view; the user lands here after
the email-code login completes (see the email-code login completes (see
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the [`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared
sections, the application / invite lifecycle the user sees, and shell, the Overview sections, the profile sub-screen, and the
the defaults baked into the create-game form. defaults baked into the create-game form.
## Sections ## Shell
The lobby renders one column of sections, top to bottom, with the Lobby and profile share a single chrome implemented in
common content max-width capped at `32rem` (same convention as the `lib/screens/lobby-shell.svelte`. The chrome mirrors the project
login page). Cards inside each section take the full available site's VitePress layout: a left page-list sidebar (Overview /
width. 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 reads the caller's account from
`lib/account-store.svelte.ts` — a session-wide cache that fetches
`user.account.get` once on first access and is written through after
every Profile save. Both `lobby-screen.svelte` and
`profile-screen.svelte` populate the same cache through
`account.ensure(client)`, so switching Overview ⇄ Profile never
re-issues `user.account.get` and the strip never flashes the
`lobby.account_loading` placeholder mid-navigation. The cache is
cleared by `session.signOut("user")` / `signOut("revoked")` so a
different user signing in on the same browser does not briefly see
the previous identity.
The strip falls back to `display_name` → immutable `user_name`
`lobby.account_loading` while the first `ensure(...)` resolves. It
renders 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 | | Section | Empty state | Source | Action |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- | | -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
@@ -21,9 +53,37 @@ width.
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) | | `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`) | | `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 `<code>` block (kept as ## Profile sub-screen
a debug affordance) plus a greeting if the gateway returns a
`display_name` for the caller. `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 reads the caller's account through `account.ensure(...)`
(see [Shell](#shell)) — the first visit issues `user.account.get`,
subsequent visits resolve from the session-wide cache without a
gateway round-trip. The form renders an identity read-out (immutable
`user_name`, `email`) plus three editable fields:
| Field | Endpoint | Notes |
| --------------------- | --------------------- | -------------------------------------------------------------- |
| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). |
| `preferred_language` | `user.settings.update`| `<select>` over `SUPPORTED_LOCALES`; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it. |
| `time_zone` | `user.settings.update`| `<select>` of every IANA zone the browser knows (`Intl.supportedValuesOf("timeZone")`), grouped by leading slash segment (Africa / America / …; singletons like `UTC` collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to `Intl.DateTimeFormat().resolvedOptions().timeZone`. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack `supportedValuesOf` fall back to a free-text input; the backend validates with `time.LoadLocation` in every shape. |
Save fires `user.profile.update` and/or `user.settings.update`
conditionally on which fields actually changed, then **stays on the
profile** and surfaces a transient `profile-saved-notice` line
(`data-testid="profile-saved-notice"`). Editing any field clears the
notice. Only the explicit `cancel` button navigates back to the lobby
(`appScreen.go("lobby")`). When the saved `preferred_language` is one
the UI also ships translations for, the active i18n locale switches
in-place so the rest of the session matches the new preference. The
write-through is also pushed into the shared `account` store so the
shell identity strip picks up the new `display_name` without a second
`user.account.get`.
`GameSummary` carries a `current_turn` field that the lobby UI does `GameSummary` carries a `current_turn` field that the lobby UI does
not display directly — the in-game shell reads it from the same not display directly — the in-game shell reads it from the same
+11 -7
View File
@@ -18,9 +18,9 @@ for the whole session. The only other routes are the dev/test-only
rune singletons in `src/lib/app-nav.svelte.ts`: rune singletons in `src/lib/app-nav.svelte.ts`:
- **`appScreen`** — the top-level screen - **`appScreen`** — the top-level screen
(`login` / `lobby` / `lobby-create` / `game`) plus the active (`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the
`gameId`. It replaces the old `goto`-based redirects and the `[id]` active `gameId`. It replaces the old `goto`-based redirects and the
route param. `[id]` route param.
- **`activeView`** — the in-game view (`map` / `table` / `report` / - **`activeView`** — the in-game view (`map` / `table` / `report` /
`battle` / `mail` / `designer-science`) plus the sub-parameters the `battle` / `mail` / `designer-science`) plus the sub-parameters the
old route segments carried (`tableEntity`, `battleId`, `turn`, old route segments carried (`tableEntity`, `battleId`, `turn`,
@@ -31,8 +31,11 @@ render: it gates on `session.status` (anonymous → login, authenticated
→ the `appScreen.screen`), and for the authenticated tree mounts the → the `appScreen.screen`), and for the authenticated tree mounts the
matching screen component from `src/lib/screens/` matching screen component from `src/lib/screens/`
(`login-screen.svelte`, `lobby-screen.svelte`, (`login-screen.svelte`, `lobby-screen.svelte`,
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game `lobby-create-screen.svelte`, `profile-screen.svelte`) or, for
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders `screen === "game"`, the in-game shell
`src/lib/game/game-shell.svelte`. Lobby and profile share a
post-login chrome (sidebar + identity strip) implemented in
`lib/screens/lobby-shell.svelte`; see [`lobby.md`](lobby.md). The game shell in turn renders
the active view from `activeView` (see below). Navigation is the active view from `activeView` (see below). Navigation is
`appScreen.go(screen, { gameId })` and `activeView.select(view, `appScreen.go(screen, { gameId })` and `activeView.select(view,
params)` — never `goto`. params)` — never `goto`.
@@ -73,8 +76,9 @@ Browser **Back/Forward move between screens**, not views, and they do
so without ever changing the URL. The shell layers screen history on so without ever changing the URL. The shell layers screen history on
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)` top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
calls `pushState("", { screen, gameId })` for the overlay screens calls `pushState("", { screen, gameId })` for the overlay screens
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`, (`game`, `lobby-create`, `profile`) and `replaceState(...)` for
so browser **Back from a game returns to the lobby** beneath it. On `lobby` / `login`, so browser **Back from a game (or profile) returns
to the lobby** beneath it. On
the first authenticated render the dispatcher stamps the restored the first authenticated render the dispatcher stamps the restored
overlay on top of the load entry, then mirrors `page.state` back into overlay on top of the load entry, then mirrors `page.state` back into
the store on every popstate through `appScreen.syncFromHistory(...)`. the store on every popstate through `appScreen.syncFromHistory(...)`.
+132
View File
@@ -0,0 +1,132 @@
// Typed wrappers around `GalaxyClient.executeCommand` for the user-
// account command catalog. Each wrapper builds a FlatBuffers request
// payload via the generated TS bindings, calls `executeCommand`, then
// decodes the `AccountResponse` reply. Errors with a non-`ok`
// `result_code` surface as a thrown `AccountError` carrying the
// canonical backend code (`invalid_request`, `subject_not_found`,
// `forbidden`, `conflict`, `internal_error`).
import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "./galaxy-client";
import {
AccountResponse,
AccountView,
ErrorResponse as FbsErrorResponse,
GetMyAccountRequest,
UpdateMyProfileRequest,
UpdateMySettingsRequest,
} from "../proto/galaxy/fbs/user";
const RESULT_CODE_OK = "ok";
export class AccountError extends Error {
readonly code: string;
readonly resultCode: string;
constructor(resultCode: string, code: string, message: string) {
super(message);
this.name = "AccountError";
this.resultCode = resultCode;
this.code = code;
}
}
export interface Account {
userId: string;
email: string;
userName: string;
displayName: string;
preferredLanguage: string;
timeZone: string;
declaredCountry: string;
}
export async function getMyAccount(client: GalaxyClient): Promise<Account> {
const builder = new Builder(32);
GetMyAccountRequest.startGetMyAccountRequest(builder);
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
const payload = await execute(client, "user.account.get", builder.asUint8Array());
return decodeAccountResponse(payload);
}
export async function updateMyProfile(
client: GalaxyClient,
displayName: string,
): Promise<Account> {
const builder = new Builder(128);
const displayNameOff = builder.createString(displayName);
UpdateMyProfileRequest.startUpdateMyProfileRequest(builder);
UpdateMyProfileRequest.addDisplayName(builder, displayNameOff);
builder.finish(UpdateMyProfileRequest.endUpdateMyProfileRequest(builder));
const payload = await execute(client, "user.profile.update", builder.asUint8Array());
return decodeAccountResponse(payload);
}
export async function updateMySettings(
client: GalaxyClient,
preferredLanguage: string,
timeZone: string,
): Promise<Account> {
const builder = new Builder(128);
const preferredLanguageOff = builder.createString(preferredLanguage);
const timeZoneOff = builder.createString(timeZone);
UpdateMySettingsRequest.startUpdateMySettingsRequest(builder);
UpdateMySettingsRequest.addPreferredLanguage(builder, preferredLanguageOff);
UpdateMySettingsRequest.addTimeZone(builder, timeZoneOff);
builder.finish(UpdateMySettingsRequest.endUpdateMySettingsRequest(builder));
const payload = await execute(client, "user.settings.update", builder.asUint8Array());
return decodeAccountResponse(payload);
}
async function execute(
client: GalaxyClient,
messageType: string,
payloadBytes: Uint8Array,
): Promise<Uint8Array> {
const result = await client.executeCommand(messageType, payloadBytes);
if (result.resultCode !== RESULT_CODE_OK) {
throw decodeAccountError(result.resultCode, result.payloadBytes);
}
return result.payloadBytes;
}
function decodeAccountError(resultCode: string, payload: Uint8Array): AccountError {
let code = resultCode;
let message = resultCode;
try {
const errorResponse = FbsErrorResponse.getRootAsErrorResponse(new ByteBuffer(payload));
const body = errorResponse.error();
if (body) {
code = body.code() ?? resultCode;
message = body.message() ?? resultCode;
}
} catch (_err) {
// fall through with the raw result code
}
return new AccountError(resultCode, code, message);
}
function decodeAccountResponse(payload: Uint8Array): Account {
if (payload.length === 0) {
throw new AccountError("internal_error", "internal_error", "empty account response");
}
const response = AccountResponse.getRootAsAccountResponse(new ByteBuffer(payload));
const view = response.account();
if (view === null) {
throw new AccountError("internal_error", "internal_error", "account missing in response");
}
return decodeAccountView(view);
}
function decodeAccountView(view: AccountView): Account {
return {
userId: view.userId() ?? "",
email: view.email() ?? "",
userName: view.userName() ?? "",
displayName: view.displayName() ?? "",
preferredLanguage: view.preferredLanguage() ?? "",
timeZone: view.timeZone() ?? "",
declaredCountry: view.declaredCountry() ?? "",
};
}
+1 -1
View File
@@ -7,7 +7,7 @@ declare global {
// (and active game) live in `page.state` so browser Back/Forward // (and active game) live in `page.state` so browser Back/Forward
// move between screens while the address bar stays at /game/. // move between screens while the address bar stays at /game/.
interface PageState { interface PageState {
screen?: "login" | "lobby" | "lobby-create" | "game"; screen?: "login" | "lobby" | "lobby-create" | "profile" | "game";
gameId?: string | null; gameId?: string | null;
} }
} }
@@ -0,0 +1,76 @@
// `AccountStore` is the session-wide cache for the caller's
// `user.account.get` aggregate. The lobby shell and every post-login
// screen read the identity (display name, immutable user_name, time
// zone, …) from the same rune, so navigating between Overview and
// Profile does not refetch and does not flash the
// `lobby.account_loading` placeholder.
//
// `ensure(client)` fetches once on first call, dedupes concurrent
// callers onto a single in-flight promise, and resolves immediately
// from the cache thereafter. `set(account)` is the write-through
// path used by Profile after `user.profile.update` /
// `user.settings.update` succeeds — both the shell and the screen
// pick up the change without an extra round-trip. `clear()` resets
// the cache on logout so a different user signing in on the same
// browser does not briefly see the previous identity.
//
// The store is intentionally narrow: it caches one struct, never
// retries on failure (the caller decides), and exposes no error
// state of its own. Callers that need a tighter error surface (the
// Profile form) catch the rejection from `ensure(client)` directly.
import type { GalaxyClient } from "../api/galaxy-client";
import { getMyAccount, type Account } from "../api/account";
class AccountStore {
current: Account | null = $state(null);
#inFlight: Promise<Account> | null = null;
/**
* ensure returns the cached `Account` when present, otherwise issues
* `user.account.get` through the supplied client and caches the
* result. Concurrent callers during the first fetch share the same
* in-flight promise so the gateway only sees one request per
* session.
*/
ensure(client: GalaxyClient): Promise<Account> {
if (this.current !== null) {
return Promise.resolve(this.current);
}
if (this.#inFlight !== null) {
return this.#inFlight;
}
const pending = getMyAccount(client)
.then((account) => {
this.current = account;
return account;
})
.finally(() => {
this.#inFlight = null;
});
this.#inFlight = pending;
return pending;
}
/**
* set replaces the cached `Account` with the supplied value. Used
* by the Profile screen after a successful save so both the form
* and the shell identity strip pick up the new fields without a
* second round-trip.
*/
set(next: Account): void {
this.current = next;
}
/**
* clear resets the cache. Called on logout so a different user
* signing in on the same browser does not briefly see the
* previous identity through the rune.
*/
clear(): void {
this.current = null;
this.#inFlight = null;
}
}
export const account = new AccountStore();
+7 -2
View File
@@ -22,7 +22,7 @@
import { pushState, replaceState } from "$app/navigation"; import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "game"; export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game";
export type GameView = export type GameView =
| "map" | "map"
@@ -51,6 +51,7 @@ const APP_SCREENS: readonly AppScreen[] = [
"login", "login",
"lobby", "lobby",
"lobby-create", "lobby-create",
"profile",
"game", "game",
]; ];
const GAME_VIEWS: readonly GameView[] = [ const GAME_VIEWS: readonly GameView[] = [
@@ -183,7 +184,11 @@ class AppScreenStore {
#syncHistory(): void { #syncHistory(): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const state: App.PageState = { screen: this.#screen, gameId: this.#gameId }; const state: App.PageState = { screen: this.#screen, gameId: this.#gameId };
if (this.#screen === "game" || this.#screen === "lobby-create") { if (
this.#screen === "game" ||
this.#screen === "lobby-create" ||
this.#screen === "profile"
) {
pushState("", state); pushState("", state);
} else { } else {
replaceState("", state); replaceState("", state);
+19 -3
View File
@@ -50,11 +50,11 @@ const en = {
"login.device_key_not_ready": "login.device_key_not_ready":
"device key is not ready, please reload the page", "device key is not ready, please reload the page",
"lobby.title": "you are logged in",
"lobby.device_session_id_label": "device session id",
"lobby.greeting": "hello, {name}!",
"lobby.account_loading": "loading account…", "lobby.account_loading": "loading account…",
"lobby.logout": "logout", "lobby.logout": "logout",
"lobby.nav.aria_label": "lobby pages",
"lobby.nav.overview": "Overview",
"lobby.nav.profile": "Profile",
"lobby.section.my_games": "my games", "lobby.section.my_games": "my games",
"lobby.section.invitations": "pending invitations", "lobby.section.invitations": "pending invitations",
"lobby.section.applications": "my applications", "lobby.section.applications": "my applications",
@@ -103,6 +103,22 @@ const en = {
"lobby.error.internal_error": "internal server error", "lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}", "lobby.error.unknown": "{message}",
"profile.title": "Profile",
"profile.loading": "loading account…",
"profile.field.user_name": "username",
"profile.field.email": "email",
"profile.field.display_name": "display name",
"profile.field.preferred_language": "preferred language",
"profile.field.time_zone": "time zone",
"profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.",
"profile.hint.time_zone": "IANA zones grouped by continent. The form opens on your browser's current zone when no value is saved.",
"profile.save": "save",
"profile.saving": "saving…",
"profile.saved": "saved",
"profile.cancel": "cancel",
"profile.error.language_required": "language must not be empty",
"profile.error.time_zone_required": "time zone must not be empty",
"game.shell.unknown": "?", "game.shell.unknown": "?",
"game.shell.connection.online": "online", "game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…", "game.shell.connection.reconnecting": "reconnecting…",
+19 -3
View File
@@ -51,11 +51,11 @@ const ru: Record<keyof typeof en, string> = {
"login.device_key_not_ready": "login.device_key_not_ready":
"ключ устройства ещё не готов, перезагрузите страницу", "ключ устройства ещё не готов, перезагрузите страницу",
"lobby.title": "вы вошли в систему",
"lobby.device_session_id_label": "идентификатор сессии устройства",
"lobby.greeting": "здравствуйте, {name}!",
"lobby.account_loading": "загрузка профиля…", "lobby.account_loading": "загрузка профиля…",
"lobby.logout": "выйти", "lobby.logout": "выйти",
"lobby.nav.aria_label": "разделы лобби",
"lobby.nav.overview": "Обзор",
"lobby.nav.profile": "Профиль",
"lobby.section.my_games": "мои игры", "lobby.section.my_games": "мои игры",
"lobby.section.invitations": "ожидающие приглашения", "lobby.section.invitations": "ожидающие приглашения",
"lobby.section.applications": "мои заявки", "lobby.section.applications": "мои заявки",
@@ -104,6 +104,22 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.internal_error": "внутренняя ошибка сервера", "lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}", "lobby.error.unknown": "{message}",
"profile.title": "Профиль",
"profile.loading": "загрузка профиля…",
"profile.field.user_name": "идентификатор",
"profile.field.email": "электронная почта",
"profile.field.display_name": "отображаемое имя",
"profile.field.preferred_language": "язык интерфейса",
"profile.field.time_zone": "часовой пояс",
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
"profile.hint.time_zone": "пояса IANA, сгруппированные по континентам. Если сохранённого значения нет, форма открывается на поясе, который определил браузер.",
"profile.save": "сохранить",
"profile.saving": "сохраняем…",
"profile.saved": "сохранено",
"profile.cancel": "отмена",
"profile.error.language_required": "язык не должен быть пустым",
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
"game.shell.unknown": "?", "game.shell.unknown": "?",
"game.shell.connection.online": "онлайн", "game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…", "game.shell.connection.reconnecting": "переподключение…",
+32 -93
View File
@@ -17,8 +17,7 @@
type GameSummary, type GameSummary,
type InviteSummary, type InviteSummary,
} from "../../api/lobby"; } from "../../api/lobby";
import { ByteBuffer } from "flatbuffers"; import { account } from "$lib/account-store.svelte";
import { AccountResponse } from "../../proto/galaxy/fbs/user";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env"; import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { import {
SyntheticReportError, SyntheticReportError,
@@ -27,10 +26,8 @@
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index"; import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte"; import { session } from "$lib/session-store.svelte";
import { Builder } from "flatbuffers"; import LobbyShell from "./lobby-shell.svelte";
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
let displayName: string | null = $state(null);
let configError: string | null = $state(null); let configError: string | null = $state(null);
let listsLoading = $state(true); let listsLoading = $state(true);
let lobbyError: string | null = $state(null); let lobbyError: string | null = $state(null);
@@ -51,10 +48,6 @@
let client: GalaxyClient | null = null; let client: GalaxyClient | null = null;
async function logout(): Promise<void> {
await session.signOut("user");
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> { async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource); const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest); return new Uint8Array(digest);
@@ -163,26 +156,6 @@
} }
} }
async function loadGreeting(c: GalaxyClient): Promise<void> {
const builder = new Builder(32);
GetMyAccountRequest.startGetMyAccountRequest(builder);
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
const result = await c.executeCommand("user.account.get", builder.asUint8Array());
if (result.resultCode !== "ok") {
return;
}
const response = AccountResponse.getRootAsAccountResponse(
new ByteBuffer(result.payloadBytes),
);
const account = response.account();
if (account === null) {
return;
}
const display = account.displayName();
const userName = account.userName();
displayName = display && display.length > 0 ? display : userName;
}
function gotoCreate(): void { function gotoCreate(): void {
appScreen.go("lobby-create"); appScreen.go("lobby-create");
} }
@@ -260,7 +233,11 @@
deviceSessionId: session.deviceSessionId, deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY, gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
}); });
loadGreeting(client).catch(() => {}); // Populate the session-wide identity cache; the shell's
// identity strip reads from there. Swallowed errors leave
// the shell on the `lobby.account_loading` placeholder
// without breaking the rest of the lobby.
account.ensure(client).catch(() => {});
await refreshAll(); await refreshAll();
} catch (err) { } catch (err) {
lobbyError = describeLobbyError(err); lobbyError = describeLobbyError(err);
@@ -269,24 +246,7 @@
}); });
</script> </script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a> <LobbyShell activePage="overview">
<main id="main-content" tabindex="-1">
<header>
<h1>{i18n.t("lobby.title")}</h1>
<p>
{i18n.t("lobby.device_session_id_label")}:
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
</p>
{#if displayName !== null}
<p data-testid="account-greeting">
{i18n.t("lobby.greeting", { name: displayName })}
</p>
{/if}
<button onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
{#if configError !== null} {#if configError !== null}
<p role="alert" data-testid="account-error">{configError}</p> <p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null} {:else if lobbyError !== null}
@@ -483,38 +443,16 @@
</ul> </ul>
{/if} {/if}
</section> </section>
</main> </LobbyShell>
<style> <style>
main {
padding: 1.5rem 1rem;
max-width: 32rem;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
header h1 {
margin: 0;
}
header button {
align-self: flex-start;
}
section { section {
margin-bottom: 2rem; margin-bottom: var(--space-6);
} }
section h2 { section h2 {
font-size: 1.1rem; font-size: var(--text-lg);
margin: 0 0 0.75rem; margin: 0 0 var(--space-3);
} }
.card-list { .card-list {
@@ -523,16 +461,16 @@
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: var(--space-2);
} }
.card { .card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: var(--space-1);
padding: 0.75rem; padding: var(--space-3);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.4rem; border-radius: var(--radius-md);
background: var(--color-surface-raised); background: var(--color-surface-raised);
text-align: left; text-align: left;
font: inherit; font: inherit;
@@ -552,54 +490,55 @@
.meta { .meta {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9rem; font-size: var(--text-sm);
} }
.status { .status {
align-self: flex-start; align-self: flex-start;
padding: 0.1rem 0.5rem; padding: 0.1rem var(--space-2);
border-radius: 999px; border-radius: var(--radius-pill);
background: var(--color-surface-raised); background: var(--color-surface-raised);
font-size: 0.8rem; font-size: var(--text-xs);
} }
.actions { .actions {
display: flex; display: flex;
gap: 0.5rem; gap: var(--space-2);
margin-top: 0.25rem; margin-top: var(--space-1);
} }
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: var(--space-2);
margin-top: 0.5rem; margin-top: var(--space-2);
} }
label { label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: var(--space-1);
} }
input[type="text"] { input[type="text"] {
font-size: 1rem; font: inherit;
padding: 0.4rem 0.5rem; font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
} }
.synthetic-loader { .synthetic-loader {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: var(--space-2);
padding: 0.4rem 0.75rem; padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted); border: 1px dashed var(--color-text-muted);
border-radius: 0.4rem; border-radius: var(--radius-md);
background: var(--color-surface-raised); background: var(--color-surface-raised);
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
} }
.synthetic-loader input[type="file"] { .synthetic-loader input[type="file"] {
font-size: 0.9rem; font-size: var(--text-sm);
} }
</style> </style>
@@ -0,0 +1,227 @@
<!--
Shared chrome for the post-login "site"-style pages — the lobby
landing and the editable profile. Renders a left page-list sidebar
(mirroring the project site's VitePress layout) plus a top identity
strip ("Player-xxxx" → opens profile, logout). Children fill the
right-hand column. Pages mark themselves active via `activePage`.
The identity strip reads directly from the session-wide `account`
store so navigating Overview ⇄ Profile never re-renders an empty
placeholder: both screens populate the same cache through
`account.ensure(client)` and the shell renders the latest value.
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
type Page = "overview" | "profile";
interface Props {
activePage: Page;
children: Snippet;
}
let { activePage, children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
];
let identityLabel = $derived.by(() => {
const current = account.current;
if (current !== null) {
const trimmed = current.displayName.trim();
if (trimmed.length > 0) return trimmed;
if (current.userName.length > 0) return current.userName;
}
return i18n.t("lobby.account_loading");
});
function gotoPage(screen: "lobby" | "profile"): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoProfile(): void {
gotoPage("profile");
}
async function logout(): Promise<void> {
await session.signOut("user");
}
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<div class="layout">
<header class="topbar">
<button
type="button"
class="identity"
onclick={gotoProfile}
data-testid="lobby-account-name"
>
{identityLabel}
</button>
<button type="button" class="logout" onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
<div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<ul>
{#each PAGES as page (page.id)}
<li>
<button
type="button"
class="nav-link"
class:active={activePage === page.id}
aria-current={activePage === page.id ? "page" : undefined}
onclick={() => gotoPage(page.screen)}
data-testid="lobby-nav-{page.id}"
>
{i18n.t(page.labelKey)}
</button>
</li>
{/each}
</ul>
</nav>
<main id="main-content" tabindex="-1" class="content">
{@render children()}
</main>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: var(--font-mono);
color: var(--color-text);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface);
}
.identity {
font: inherit;
font-size: var(--text-md);
color: var(--color-text);
background: transparent;
border: none;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.identity:hover {
background: var(--color-surface-hover);
}
.logout {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.logout:hover {
background: var(--color-surface-hover);
}
.body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.sidebar {
flex: 0 0 14rem;
border-right: 1px solid var(--color-border-subtle);
padding: var(--space-4) var(--space-3);
background: var(--color-surface);
}
.sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.nav-link {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-base);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
.nav-link:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.nav-link.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.content {
flex: 1 1 auto;
padding: var(--space-5) var(--space-6);
max-width: 48rem;
}
@media (max-width: 640px) {
.body {
flex-direction: column;
}
.sidebar {
flex: 0 0 auto;
border-right: none;
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3);
}
.sidebar ul {
flex-direction: row;
gap: var(--space-2);
overflow-x: auto;
}
.nav-link {
white-space: nowrap;
padding: var(--space-1) var(--space-3);
}
.content {
padding: var(--space-4);
max-width: none;
}
}
</style>
@@ -0,0 +1,387 @@
<script lang="ts">
// Profile screen: a top-level appScreen (peer of `lobby` and
// `lobby-create`). Loads the caller's account aggregate, lets the
// user edit `display_name`, `preferred_language`, and `time_zone`,
// and posts the changes through `user.profile.update` /
// `user.settings.update`. The form stays on screen after a
// successful save (the shell-level identity strip picks up the
// new value through the shared `account` store) — only `cancel`
// returns to the lobby.
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
AccountError,
updateMyProfile,
updateMySettings,
type Account,
} from "../../api/account";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
i18n,
SUPPORTED_LOCALES,
type Locale,
type TranslationKey,
} from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
import {
browserTimeZone,
supportedTimeZones,
withPreservedValue,
type TimeZoneGroup,
} from "$lib/time-zones";
import LobbyShell from "./lobby-shell.svelte";
let loaded: Account | null = $state(null);
let displayNameInput = $state("");
let preferredLanguageInput = $state("");
let timeZoneInput = $state("");
let loadError: string | null = $state(null);
let configError: string | null = $state(null);
let saveError: string | null = $state(null);
let saving = $state(false);
let savedNotice = $state(false);
let client: GalaxyClient | null = null;
const SUPPORTED_LOCALE_CODES: ReadonlySet<string> = new Set(
SUPPORTED_LOCALES.map((entry) => entry.code),
);
// Built once: the IANA list is static for the page lifetime. The
// stored value is folded in lazily so a zone the runtime no longer
// advertises still renders.
const TIME_ZONE_GROUPS_BASE: readonly TimeZoneGroup[] = supportedTimeZones();
let timeZoneGroups = $derived<readonly TimeZoneGroup[]>(
withPreservedValue(TIME_ZONE_GROUPS_BASE, timeZoneInput),
);
let timeZoneFallbackToText = $derived(TIME_ZONE_GROUPS_BASE.length === 0);
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describe(err: unknown): string {
if (err instanceof AccountError) {
const key = `lobby.error.${err.code}` as TranslationKey;
const translated = i18n.t(key);
if (translated !== key) return translated;
return i18n.t("lobby.error.unknown", { message: err.message });
}
return err instanceof Error ? err.message : "request failed";
}
function applyAccount(next: Account): void {
loaded = next;
displayNameInput = next.displayName;
preferredLanguageInput = next.preferredLanguage;
// Seed an empty stored zone with the browser's current zone so
// the picker lands on a sensible default rather than the first
// IANA entry. The form treats "no change" as not posting, so
// the seeded value is only persisted on an explicit save.
timeZoneInput = next.timeZone.length > 0 ? next.timeZone : browserTimeZone();
}
function markDirty(): void {
// Any edit invalidates the "Saved" notice.
savedNotice = false;
saveError = null;
}
async function loadAccount(c: GalaxyClient): Promise<void> {
try {
applyAccount(await account.ensure(c));
} catch (err) {
loadError = describe(err);
}
}
async function save(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (client === null || loaded === null || saving) return;
const trimmedDisplay = displayNameInput.trim();
const trimmedLanguage = preferredLanguageInput.trim();
const trimmedZone = timeZoneInput.trim();
if (trimmedLanguage === "") {
saveError = i18n.t("profile.error.language_required");
return;
}
if (trimmedZone === "") {
saveError = i18n.t("profile.error.time_zone_required");
return;
}
saving = true;
saveError = null;
savedNotice = false;
try {
let next: Account = loaded;
if (trimmedDisplay !== loaded.displayName) {
next = await updateMyProfile(client, trimmedDisplay);
}
if (
trimmedLanguage !== loaded.preferredLanguage ||
trimmedZone !== loaded.timeZone
) {
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
}
applyAccount(next);
account.set(next);
// When the user picks a language the UI supports, switch the
// active locale immediately so the rest of the session sees
// the change without a reload. Unsupported BCP 47 codes are
// saved on the account but leave the active locale alone.
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
i18n.setLocale(next.preferredLanguage as Locale);
}
savedNotice = true;
} catch (err) {
saveError = describe(err);
} finally {
saving = false;
}
}
function cancel(): void {
appScreen.go("lobby");
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
}
return;
}
const keypair = session.keypair;
try {
const core = await loadCore();
client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await loadAccount(client);
} catch (err) {
loadError = describe(err);
}
});
</script>
<LobbyShell activePage="profile">
<h1>{i18n.t("profile.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="profile-config-error">{configError}</p>
{:else if loaded === null && loadError === null}
<p role="status" data-testid="profile-loading">{i18n.t("profile.loading")}</p>
{:else if loadError !== null}
<p role="alert" data-testid="profile-load-error">{loadError}</p>
{:else if loaded !== null}
<dl class="identity" data-testid="profile-identity">
<dt>{i18n.t("profile.field.user_name")}</dt>
<dd>{loaded.userName}</dd>
<dt>{i18n.t("profile.field.email")}</dt>
<dd>{loaded.email}</dd>
</dl>
<form onsubmit={save} data-testid="profile-form">
<label>
<span>{i18n.t("profile.field.display_name")}</span>
<input
type="text"
bind:value={displayNameInput}
oninput={markDirty}
autocomplete="nickname"
data-testid="profile-display-name"
/>
<small>{i18n.t("profile.hint.display_name")}</small>
</label>
<label>
<span>{i18n.t("profile.field.preferred_language")}</span>
<select
bind:value={preferredLanguageInput}
onchange={markDirty}
data-testid="profile-preferred-language"
>
{#each SUPPORTED_LOCALES as entry (entry.code)}
<option value={entry.code}>{entry.nativeName}</option>
{/each}
{#if !SUPPORTED_LOCALE_CODES.has(preferredLanguageInput) && preferredLanguageInput !== ""}
<!--
Backend stores arbitrary BCP 47 tags, but the UI only
ships translations for the codes in `SUPPORTED_LOCALES`.
Preserve the saved value so saving the form unchanged
does not silently switch it.
-->
<option value={preferredLanguageInput}>{preferredLanguageInput}</option>
{/if}
</select>
</label>
<label>
<span>{i18n.t("profile.field.time_zone")}</span>
{#if timeZoneFallbackToText}
<!--
Browser lacks `Intl.supportedValuesOf("timeZone")` —
fall back to a free-text field so a viable runtime can
still save a zone. The backend remains the validator.
-->
<input
type="text"
bind:value={timeZoneInput}
oninput={markDirty}
placeholder={browserTimeZone()}
autocomplete="off"
data-testid="profile-time-zone"
/>
{:else}
<select
bind:value={timeZoneInput}
onchange={markDirty}
data-testid="profile-time-zone"
>
{#each timeZoneGroups as group (group.label)}
<optgroup label={group.label}>
{#each group.values as zone (zone)}
<option value={zone}>{zone}</option>
{/each}
</optgroup>
{/each}
</select>
{/if}
<small>{i18n.t("profile.hint.time_zone")}</small>
</label>
{#if saveError !== null}
<p role="alert" data-testid="profile-save-error">{saveError}</p>
{:else if savedNotice}
<p role="status" data-testid="profile-saved-notice">
{i18n.t("profile.saved")}
</p>
{/if}
<div class="actions">
<button type="submit" disabled={saving} data-testid="profile-save">
{saving ? i18n.t("profile.saving") : i18n.t("profile.save")}
</button>
<button
type="button"
onclick={cancel}
disabled={saving}
data-testid="profile-cancel"
>
{i18n.t("profile.cancel")}
</button>
</div>
</form>
{/if}
</LobbyShell>
<style>
h1 {
font-size: var(--text-xl);
margin: 0 0 var(--space-4);
}
.identity {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-4);
margin: 0 0 var(--space-5);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.identity dt {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.identity dd {
margin: 0;
font-size: var(--text-base);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
label > span {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
input[type="text"],
select {
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
small {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
[data-testid="profile-saved-notice"] {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin: 0;
}
.actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-2);
}
.actions button {
font: inherit;
font-size: var(--text-md);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.actions button[type="submit"] {
background: var(--color-accent);
color: var(--color-accent-contrast);
border-color: var(--color-accent);
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>
@@ -31,6 +31,7 @@ import {
loadDeviceSession, loadDeviceSession,
setDeviceSessionId, setDeviceSessionId,
} from "../api/session"; } from "../api/session";
import { account } from "./account-store.svelte";
export type SessionStatus = export type SessionStatus =
| "loading" | "loading"
@@ -94,6 +95,10 @@ export class SessionStore {
this.keypair = fresh.keypair; this.keypair = fresh.keypair;
this.deviceSessionId = null; this.deviceSessionId = null;
this.status = "anonymous"; this.status = "anonymous";
// Drop the cached identity so a different user signing in on the
// same browser does not briefly see the previous display name
// through the post-login shell.
account.clear();
if (reason === "revoked") { if (reason === "revoked") {
console.info("session store: device session revoked by gateway"); console.info("session store: device session revoked by gateway");
} }
+140
View File
@@ -0,0 +1,140 @@
// Time-zone option helpers for the Profile screen's `<select>`.
//
// The browser ships the full IANA list through
// `Intl.supportedValuesOf("timeZone")` (Chrome 99+, Firefox 93+,
// Safari 15.4+ — all within the PWA target). This module reads that
// list, groups the entries by their first slash-delimited segment
// (`Africa`, `America`, …), sorts both groups and entries within each
// group, and yields a shape that maps 1:1 onto `<optgroup>` /
// `<option>`.
//
// Two corner cases:
// * Singletons like `UTC` / `GMT` / `EST` have no slash, so they
// collapse into a single "Other" bucket at the bottom of the
// dropdown.
// * A stored value that is not in the browser-supplied list (an
// older zone the runtime no longer ships, or a name from a
// freshly-imported account) is appended as a one-entry "Other"
// option through `withPreservedValue`. The Profile form calls
// that helper so saving an unchanged form never silently
// downgrades a stored value to the default.
const OTHER_GROUP = "Other";
export interface TimeZoneGroup {
readonly label: string;
readonly values: readonly string[];
}
/**
* supportedTimeZones returns the browser-supplied IANA list, grouped
* by leading segment and sorted alphabetically. Returns an empty
* array when the runtime does not implement
* `Intl.supportedValuesOf("timeZone")` so callers can fall back to a
* text input.
*/
export function supportedTimeZones(): readonly TimeZoneGroup[] {
const zones = listSupportedZones();
if (zones.length === 0) return [];
return groupZones(zones);
}
/**
* withPreservedValue returns `groups` unchanged when the supplied
* `value` is empty or already appears in one of the groups.
* Otherwise it appends a single-entry "Other" group carrying the
* value so the `<select>` can render it without losing the saved
* zone. The original groups are not mutated.
*/
export function withPreservedValue(
groups: readonly TimeZoneGroup[],
value: string,
): readonly TimeZoneGroup[] {
const trimmed = value.trim();
if (trimmed === "") return groups;
for (const group of groups) {
if (group.values.includes(trimmed)) return groups;
}
const extra: TimeZoneGroup = { label: OTHER_GROUP, values: [trimmed] };
// Merge with an existing "Other" group if one is already present,
// otherwise append a fresh one.
const next: TimeZoneGroup[] = [];
let mergedIntoOther = false;
for (const group of groups) {
if (group.label === OTHER_GROUP) {
mergedIntoOther = true;
next.push({
label: OTHER_GROUP,
values: [...group.values, trimmed].sort((a, b) => a.localeCompare(b)),
});
} else {
next.push(group);
}
}
if (!mergedIntoOther) next.push(extra);
return next;
}
/**
* browserTimeZone returns the time zone the runtime believes the
* user is in. An empty string is returned when `Intl.DateTimeFormat`
* is missing or rejects the resolution.
*/
export function browserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
} catch {
return "";
}
}
interface IntlWithSupportedValues {
supportedValuesOf?: (key: "timeZone") => string[];
}
function listSupportedZones(): string[] {
const intl = Intl as unknown as IntlWithSupportedValues;
if (typeof intl.supportedValuesOf !== "function") return [];
try {
const zones = intl.supportedValuesOf("timeZone");
return Array.isArray(zones) ? zones.slice() : [];
} catch {
return [];
}
}
function groupZones(zones: readonly string[]): readonly TimeZoneGroup[] {
const buckets = new Map<string, string[]>();
const others: string[] = [];
for (const zone of zones) {
const slash = zone.indexOf("/");
if (slash === -1) {
others.push(zone);
continue;
}
const prefix = zone.slice(0, slash);
const bucket = buckets.get(prefix);
if (bucket === undefined) {
buckets.set(prefix, [zone]);
} else {
bucket.push(zone);
}
}
const groups: TimeZoneGroup[] = [];
const sortedPrefixes = Array.from(buckets.keys()).sort((a, b) =>
a.localeCompare(b),
);
for (const prefix of sortedPrefixes) {
const values = (buckets.get(prefix) ?? []).slice().sort((a, b) =>
a.localeCompare(b),
);
groups.push({ label: prefix, values });
}
if (others.length > 0) {
groups.push({
label: OTHER_GROUP,
values: others.slice().sort((a, b) => a.localeCompare(b)),
});
}
return groups;
}
+5
View File
@@ -19,6 +19,7 @@
import LoginScreen from "$lib/screens/login-screen.svelte"; import LoginScreen from "$lib/screens/login-screen.svelte";
import LobbyScreen from "$lib/screens/lobby-screen.svelte"; import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-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 GameShell from "$lib/game/game-shell.svelte";
import { pushState } from "$app/navigation"; import { pushState } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
@@ -67,6 +68,8 @@
pushState("", { screen: "game", gameId: appScreen.gameId }); pushState("", { screen: "game", gameId: appScreen.gameId });
} else if (appScreen.screen === "lobby-create") { } else if (appScreen.screen === "lobby-create") {
pushState("", { screen: "lobby-create" }); pushState("", { screen: "lobby-create" });
} else if (appScreen.screen === "profile") {
pushState("", { screen: "profile" });
} }
} }
}); });
@@ -83,6 +86,8 @@
{#if session.status === "authenticated"} {#if session.status === "authenticated"}
{#if appScreen.screen === "lobby-create"} {#if appScreen.screen === "lobby-create"}
<LobbyCreateScreen /> <LobbyCreateScreen />
{:else if appScreen.screen === "profile"}
<ProfileScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null} {:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell /> <GameShell />
{:else} {:else}
+16 -11
View File
@@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").click(); await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click(); await page.getByTestId("login-code-submit").click();
// Sign-in switches the in-memory screen to the lobby; the device // Sign-in switches the in-memory screen to the lobby; the identity
// session id surfaces only on the lobby screen. // strip rendered by `lobby-shell.svelte` is the lobby-loaded signal.
await expect(page.getByTestId("device-session-id")).toBeVisible(); await expect(page.getByTestId("lobby-account-name")).toBeVisible();
} }
test.describe("Phase 7 — auth flow", () => { test.describe("Phase 7 — auth flow", () => {
@@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => {
}) => { }) => {
const mocks = await mockGatewayHappyPath(page, "Pilot"); const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page); await completeLogin(page);
await expect(page.getByTestId("device-session-id")).toHaveText( await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
"dev-test-1",
);
await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
mocks.pendingSubscribes.forEach((resolve) => resolve()); mocks.pendingSubscribes.forEach((resolve) => resolve());
}); });
@@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => {
}) => { }) => {
const mocks = await mockGatewayHappyPath(page, "Pilot"); const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page); await completeLogin(page);
await expect(page.getByTestId("account-greeting")).toBeVisible(); await expect(page.getByTestId("lobby-account-name")).toBeVisible();
await page.reload(); await page.reload();
// The restored session re-renders the lobby screen directly (no // The restored session re-renders the lobby screen directly (no
// `/lobby` route to land on). // `/lobby` route to land on).
await expect(page.getByTestId("device-session-id")).toHaveText( await expect(page.getByTestId("lobby-account-name")).toContainText(
"dev-test-1", "Pilot",
); );
mocks.pendingSubscribes.forEach((resolve) => resolve()); mocks.pendingSubscribes.forEach((resolve) => resolve());
@@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => {
}) => { }) => {
const mocks = await mockGatewayHappyPath(page, "Pilot"); const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page); 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 // Fire all pending SubscribeEvents requests with an empty 200
// response. Connect-Web's server-streaming reader sees no frames // response. Connect-Web's server-streaming reader sees no frames
+6 -3
View File
@@ -215,6 +215,9 @@ export interface AccountFixture {
email: string; email: string;
userName: string; userName: string;
displayName: string; displayName: string;
preferredLanguage?: string;
timeZone?: string;
declaredCountry?: string;
} }
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array { export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
@@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array
const email = builder.createString(account.email); const email = builder.createString(account.email);
const userName = builder.createString(account.userName); const userName = builder.createString(account.userName);
const displayName = builder.createString(account.displayName); const displayName = builder.createString(account.displayName);
const preferredLanguage = builder.createString("en"); const preferredLanguage = builder.createString(account.preferredLanguage ?? "en");
const timeZone = builder.createString("UTC"); const timeZone = builder.createString(account.timeZone ?? "UTC");
const declaredCountry = builder.createString(""); const declaredCountry = builder.createString(account.declaredCountry ?? "");
AccountView.startAccountView(builder); AccountView.startAccountView(builder);
AccountView.addUserId(builder, userId); AccountView.addUserId(builder, userId);
AccountView.addEmail(builder, email); AccountView.addEmail(builder, email);
+1 -1
View File
@@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").fill("123456"); await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click(); await page.getByTestId("login-code-submit").click();
// Sign-in switches the in-memory screen to the lobby. // 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", () => { test.describe("Phase 8 — lobby flow", () => {
@@ -0,0 +1,339 @@
// 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, the save-stay flow, and
// the time-zone dropdown.
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;
accountGetCount: number;
profileUpdates: Array<{ displayName: string }>;
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
}
async function mockGateway(
page: Page,
initial: AccountFixture,
): Promise<ProfileMocks> {
const mocks: ProfileMocks = {
pendingSubscribes: [],
account: { ...initial },
accountGetCount: 0,
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":
mocks.accountGetCount += 1;
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<void> {
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, stays on the form, and refreshes the identity strip", 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();
// Form stays on screen; the saved notice surfaces and the
// shell-level identity strip picks up the new name without a
// second `user.account.get`.
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toContainText(
"Captain",
);
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
expect(mocks.settingsUpdates).toEqual([]);
// Editing the form again clears the notice so a follow-up save is
// unambiguous.
await page.getByTestId("profile-display-name").fill("Pilot");
await expect(page.getByTestId("profile-saved-notice")).toHaveCount(0);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("changing the language posts user.settings.update, stays on the form, 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();
// Profile stays on screen; the Russian dictionary now drives the
// form copy. The save button label is the visible signal.
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("profile-save")).toHaveText("сохранить");
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
expect(mocks.settingsUpdates).toHaveLength(1);
expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru");
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());
});
test("time zone is a continent-grouped <select>; saving an edited zone posts user.settings.update", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
preferredLanguage: "en",
timeZone: "Europe/London",
});
await completeLogin(page);
await page.getByTestId("lobby-account-name").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
const select = page.getByTestId("profile-time-zone");
// The field renders as a <select> with at least the Europe and
// America optgroups present and the stored zone selected.
expect(await select.evaluate((el) => el.tagName)).toBe("SELECT");
const optgroupLabels = await select.evaluate((el) =>
Array.from((el as HTMLSelectElement).querySelectorAll("optgroup")).map(
(g) => g.label,
),
);
expect(optgroupLabels).toContain("Europe");
expect(optgroupLabels).toContain("America");
await expect(select).toHaveValue("Europe/London");
await select.selectOption("America/New_York");
await page.getByTestId("profile-save").click();
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
expect(mocks.settingsUpdates).toEqual([
{ preferredLanguage: "en", timeZone: "America/New_York" },
]);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("the identity strip persists across Overview ⇄ Profile without a second user.account.get", 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");
const firstCount = mocks.accountGetCount;
expect(firstCount).toBeGreaterThanOrEqual(1);
// Navigate Overview → Profile: identity must NOT flash the
// loading placeholder, and the cache must answer without a
// second gateway call.
await page.getByTestId("lobby-nav-profile").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
// Navigate back to Overview.
await page.getByTestId("lobby-nav-overview").click();
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
expect(mocks.accountGetCount).toBe(firstCount);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});