feat(ui): app-shell core — single-route dispatcher, route collapse, nav→state

Collapse the game UI to one route (`/`): a screen dispatcher renders
login/lobby/lobby-create/game from `appScreen`/`activeView` state instead of
URL routes. Move screen components to lib/screens & lib/game; the game shell
reads the game id from `appScreen.gameId` and re-inits per-game stores via an
$effect; in-game views render from `activeView`. Flip ~23 goto/href nav sites
to store mutations; drop the `?sidebar=` URL coupling. Auth gate is now
state-based. WIP: browser-history (Back→lobby), restore-validation, the
return-to-lobby button, push deep-links, and the test migration are follow-ups
on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-23 20:04:04 +02:00
parent 182beebcd6
commit b6770d394c
30 changed files with 294 additions and 394 deletions
@@ -0,0 +1,293 @@
<script lang="ts">
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import { LobbyError, createGame } from "../../api/lobby";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
const DEFAULT_MIN_PLAYERS = 2;
const DEFAULT_MAX_PLAYERS = 8;
const DEFAULT_START_GAP_HOURS = 24;
const DEFAULT_START_GAP_PLAYERS = 2;
const DEFAULT_TARGET_ENGINE_VERSION = "v1";
let gameName = $state("");
let description = $state("");
let turnSchedule = $state("0 0 * * *");
let enrollmentEndsAt = $state("");
let minPlayers = $state(DEFAULT_MIN_PLAYERS);
let maxPlayers = $state(DEFAULT_MAX_PLAYERS);
let startGapHours = $state(DEFAULT_START_GAP_HOURS);
let startGapPlayers = $state(DEFAULT_START_GAP_PLAYERS);
let targetEngineVersion = $state(DEFAULT_TARGET_ENGINE_VERSION);
let formError: string | null = $state(null);
let configError: string | null = $state(null);
let submitting = $state(false);
let client: GalaxyClient | null = null;
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) {
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 cancel(): void {
appScreen.go("lobby");
}
async function submit(): Promise<void> {
formError = null;
const trimmedName = gameName.trim();
const trimmedSchedule = turnSchedule.trim();
const trimmedEnrollment = enrollmentEndsAt.trim();
if (trimmedName === "") {
formError = i18n.t("lobby.create.game_name_required");
return;
}
if (trimmedSchedule === "") {
formError = i18n.t("lobby.create.turn_schedule_required");
return;
}
if (trimmedEnrollment === "") {
formError = i18n.t("lobby.create.enrollment_ends_at_required");
return;
}
const enrollmentDate = new Date(trimmedEnrollment);
if (Number.isNaN(enrollmentDate.getTime())) {
formError = i18n.t("lobby.create.enrollment_ends_at_required");
return;
}
if (client === null) {
formError = configError ?? "client not ready";
return;
}
submitting = true;
try {
await createGame(client, {
gameName: trimmedName,
description: description.trim(),
minPlayers,
maxPlayers,
startGapHours,
startGapPlayers,
enrollmentEndsAt: enrollmentDate,
turnSchedule: trimmedSchedule,
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
});
appScreen.go("lobby");
} catch (err) {
formError = describeLobbyError(err);
} finally {
submitting = false;
}
}
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;
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,
});
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<main id="main-content" tabindex="-1">
<h1>{i18n.t("lobby.create.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="lobby-create-config-error">{configError}</p>
{/if}
<form
onsubmit={(event) => {
event.preventDefault();
submit();
}}
data-testid="lobby-create-form"
>
<label>
{i18n.t("lobby.create.game_name_label")}
<input
type="text"
bind:value={gameName}
data-testid="lobby-create-game-name"
autocomplete="off"
/>
</label>
<label>
{i18n.t("lobby.create.description_label")}
<textarea
bind:value={description}
data-testid="lobby-create-description"
rows="3"
></textarea>
</label>
<label>
{i18n.t("lobby.create.turn_schedule_label")}
<input
type="text"
bind:value={turnSchedule}
data-testid="lobby-create-turn-schedule"
autocomplete="off"
/>
<small>{i18n.t("lobby.create.turn_schedule_hint")}</small>
</label>
<label>
{i18n.t("lobby.create.enrollment_ends_at_label")}
<input
type="datetime-local"
bind:value={enrollmentEndsAt}
data-testid="lobby-create-enrollment-ends-at"
/>
</label>
<details data-testid="lobby-create-advanced">
<summary>{i18n.t("lobby.create.advanced")}</summary>
<label>
{i18n.t("lobby.create.min_players_label")}
<input
type="number"
min="1"
bind:value={minPlayers}
data-testid="lobby-create-min-players"
/>
</label>
<label>
{i18n.t("lobby.create.max_players_label")}
<input
type="number"
min="1"
bind:value={maxPlayers}
data-testid="lobby-create-max-players"
/>
</label>
<label>
{i18n.t("lobby.create.start_gap_hours_label")}
<input
type="number"
min="0"
bind:value={startGapHours}
data-testid="lobby-create-start-gap-hours"
/>
</label>
<label>
{i18n.t("lobby.create.start_gap_players_label")}
<input
type="number"
min="0"
bind:value={startGapPlayers}
data-testid="lobby-create-start-gap-players"
/>
</label>
<label>
{i18n.t("lobby.create.target_engine_version_label")}
<input
type="text"
bind:value={targetEngineVersion}
data-testid="lobby-create-target-engine-version"
autocomplete="off"
/>
</label>
</details>
{#if formError !== null}
<p role="alert" data-testid="lobby-create-error">{formError}</p>
{/if}
<div class="actions">
<button type="submit" disabled={submitting} data-testid="lobby-create-submit">
{submitting ? i18n.t("lobby.create.submitting") : i18n.t("lobby.create.submit")}
</button>
<button type="button" onclick={cancel} data-testid="lobby-create-cancel">
{i18n.t("lobby.create.cancel")}
</button>
</div>
</form>
</main>
<style>
main {
padding: 1.5rem 1rem;
max-width: 32rem;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
input[type="text"],
input[type="number"],
input[type="datetime-local"],
textarea {
font-size: 1rem;
padding: 0.4rem 0.5rem;
}
details {
border: 1px solid var(--color-border);
border-radius: 0.4rem;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
details > label {
margin-top: 0.5rem;
}
details summary {
cursor: pointer;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
small {
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,605 @@
<script lang="ts">
import { onMount } from "svelte";
import { appScreen, activeView } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
LobbyError,
listMyApplications,
listMyGames,
listMyInvites,
listPublicGames,
redeemInvite,
declineInvite,
submitApplication,
type ApplicationSummary,
type GameSummary,
type InviteSummary,
} from "../../api/lobby";
import { ByteBuffer } from "flatbuffers";
import { AccountResponse } from "../../proto/galaxy/fbs/user";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
loadSyntheticReportFromJSON,
} from "../../api/synthetic-report";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { Builder } from "flatbuffers";
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
let displayName: string | null = $state(null);
let configError: string | null = $state(null);
let listsLoading = $state(true);
let lobbyError: string | null = $state(null);
let myGames: GameSummary[] = $state([]);
let invitations: InviteSummary[] = $state([]);
let applications: ApplicationSummary[] = $state([]);
let publicGames: GameSummary[] = $state([]);
let openApplicationFor: string | null = $state(null);
let raceNameInput = $state("");
let raceNameError: string | null = $state(null);
let submittingApplication = $state(false);
let inviteActionInFlight: string | null = $state(null);
let syntheticError: string | null = $state(null);
let client: GalaxyClient | null = null;
async function logout(): Promise<void> {
await session.signOut("user");
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describeLobbyError(err: unknown): string {
if (err instanceof LobbyError) {
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";
}
async function refreshAll(): Promise<void> {
if (client === null) return;
listsLoading = true;
lobbyError = null;
try {
const [games, invites, apps, publicPage] = await Promise.all([
listMyGames(client),
listMyInvites(client),
listMyApplications(client),
listPublicGames(client),
]);
myGames = games;
invitations = invites.filter((invite) => invite.status === "pending");
applications = apps;
publicGames = publicPage.items;
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
listsLoading = false;
}
}
function applicationStatusLabel(status: string): string {
const key = `lobby.application.status.${status}` as TranslationKey;
const translated = i18n.t(key);
if (translated === key) {
return i18n.t("lobby.application.status.unknown", { status });
}
return translated;
}
function openApplicationForm(gameId: string): void {
openApplicationFor = gameId;
raceNameInput = "";
raceNameError = null;
}
function cancelApplicationForm(): void {
openApplicationFor = null;
raceNameInput = "";
raceNameError = null;
}
async function submitApplicationFor(gameId: string): Promise<void> {
if (client === null) return;
const trimmed = raceNameInput.trim();
if (trimmed === "") {
raceNameError = i18n.t("lobby.application.race_name_required");
return;
}
submittingApplication = true;
raceNameError = null;
try {
const result = await submitApplication(client, gameId, trimmed);
applications = [result, ...applications];
openApplicationFor = null;
raceNameInput = "";
} catch (err) {
raceNameError = describeLobbyError(err);
} finally {
submittingApplication = false;
}
}
async function acceptInvite(invite: InviteSummary): Promise<void> {
if (client === null) return;
inviteActionInFlight = invite.inviteId;
try {
await redeemInvite(client, invite.gameId, invite.inviteId);
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
myGames = await listMyGames(client);
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
inviteActionInFlight = null;
}
}
async function rejectInvite(invite: InviteSummary): Promise<void> {
if (client === null) return;
inviteActionInFlight = invite.inviteId;
try {
await declineInvite(client, invite.gameId, invite.inviteId);
invitations = invitations.filter((i) => i.inviteId !== invite.inviteId);
} catch (err) {
lobbyError = describeLobbyError(err);
} finally {
inviteActionInFlight = null;
}
}
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 {
appScreen.go("lobby-create");
}
function gotoGame(gameId: string): void {
// Enter a fresh game on the map view: reset the in-game view
// state first so a stale snapshot from a previous game does not
// leak into the new one, then switch the top-level screen.
activeView.reset();
appScreen.go("game", { gameId });
}
async function onSyntheticFileChange(
event: Event & { currentTarget: HTMLInputElement },
): Promise<void> {
// Capture the element synchronously: `event.currentTarget`
// is nulled by the time any of the awaits below resolve, and
// reaching for it from the `finally` block then throws
// "null is not an object". The reset still has to happen so
// re-selecting the same file fires `change` again.
const input = event.currentTarget;
syntheticError = null;
const file = input.files?.[0];
if (file === undefined) return;
try {
const text = await file.text();
const json: unknown = JSON.parse(text);
const { gameId } = loadSyntheticReportFromJSON(json);
activeView.reset();
appScreen.go("game", { gameId });
} catch (err) {
if (err instanceof SyntheticReportError) {
syntheticError = err.message;
} else if (err instanceof SyntaxError) {
syntheticError = `invalid JSON: ${err.message}`;
} else if (err instanceof Error) {
syntheticError = err.message;
} else {
syntheticError = "failed to load synthetic report";
}
} finally {
input.value = "";
}
}
// Statuses for which the game has a navigable in-game view.
// Lobby-internal statuses (draft, enrollment_open, ready_to_start,
// starting, start_failed) and terminal ones (cancelled) stay
// non-clickable; entering them otherwise opens the game shell on a
// game whose runtime state does not exist yet.
function isPlayableStatus(status: string): boolean {
return status === "running" || status === "paused" || status === "finished";
}
onMount(async () => {
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
listsLoading = false;
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,
});
loadGreeting(client).catch(() => {});
await refreshAll();
} catch (err) {
lobbyError = describeLobbyError(err);
listsLoading = false;
}
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<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}
<p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null}
<p role="alert" data-testid="lobby-error">{lobbyError}</p>
{/if}
<section data-testid="lobby-create-section">
<button onclick={gotoCreate} data-testid="lobby-create-button">
{i18n.t("lobby.create_button")}
</button>
</section>
<section data-testid="lobby-my-games-section">
<h2>{i18n.t("lobby.section.my_games")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if myGames.length === 0}
<p data-testid="lobby-my-games-empty">{i18n.t("lobby.my_games.empty")}</p>
{:else}
<ul class="card-list">
{#each myGames as game (game.gameId)}
<li>
<button
class="card"
onclick={() => gotoGame(game.gameId)}
disabled={!isPlayableStatus(game.status)}
data-testid="lobby-my-game-card"
>
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
</button>
</li>
{/each}
</ul>
{/if}
</section>
<section data-testid="lobby-invitations-section">
<h2>{i18n.t("lobby.section.invitations")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if invitations.length === 0}
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
{:else}
<ul class="card-list">
{#each invitations as invite (invite.inviteId)}
<li class="card">
<strong>{invite.raceName}</strong>
<span class="meta">{invite.gameId}</span>
<div class="actions">
<button
onclick={() => acceptInvite(invite)}
disabled={inviteActionInFlight === invite.inviteId}
data-testid="lobby-invite-accept"
>
{i18n.t("lobby.invitation.accept")}
</button>
<button
onclick={() => rejectInvite(invite)}
disabled={inviteActionInFlight === invite.inviteId}
data-testid="lobby-invite-decline"
>
{i18n.t("lobby.invitation.decline")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<section data-testid="lobby-applications-section">
<h2>{i18n.t("lobby.section.applications")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if applications.length === 0}
<p data-testid="lobby-applications-empty">{i18n.t("lobby.applications.empty")}</p>
{:else}
<ul class="card-list">
{#each applications as app (app.applicationId)}
<li class="card" data-testid="lobby-application-card">
<strong>{app.raceName}</strong>
<span class="meta">{app.gameId}</span>
<span class="status" data-status={app.status}>
{applicationStatusLabel(app.status)}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true"}
<!--
Synthetic-report loader. Dev-only affordance for visual testing
against rich game states without playing many turns. The JSON
is produced offline by the Go CLI in
`tools/local-dev/legacy-report/`; see
`ui/docs/testing.md#synthetic-reports` for the workflow. Gated
on `VITE_GALAXY_DEV_AFFORDANCES` (set in `.env.development` and
mirrored by `dev-deploy.yaml`) rather than `import.meta.env.DEV`
so the long-lived dev environment can also surface it from a
production-mode bundle. The prod build path leaves the flag
unset, so the section is stripped from prod chunks.
-->
<section data-testid="lobby-synthetic-section">
<h2>Synthetic test reports (DEV)</h2>
<p class="meta">
Load a JSON file produced by
<code>legacy-report-to-json</code> to open the map view
against a synthetic snapshot. Orders compose locally but
never reach the server.
</p>
<label class="synthetic-loader">
Load JSON…
<input
type="file"
accept=".json,application/json"
onchange={onSyntheticFileChange}
data-testid="lobby-synthetic-file"
/>
</label>
{#if syntheticError !== null}
<p role="alert" data-testid="lobby-synthetic-error">
{syntheticError}
</p>
{/if}
</section>
{/if}
<section data-testid="lobby-public-games-section">
<h2>{i18n.t("lobby.section.public_games")}</h2>
{#if listsLoading}
<p role="status">{i18n.t("lobby.list_loading")}</p>
{:else if publicGames.length === 0}
<p data-testid="lobby-public-games-empty">{i18n.t("lobby.public_games.empty")}</p>
{:else}
<ul class="card-list">
{#each publicGames as game (game.gameId)}
<li class="card">
<strong>{game.gameName}</strong>
<span class="meta">{game.status}</span>
<span class="meta">{game.minPlayers}{game.maxPlayers} players</span>
{#if openApplicationFor === game.gameId}
<form
onsubmit={(event) => {
event.preventDefault();
submitApplicationFor(game.gameId);
}}
data-testid="lobby-application-form"
>
<label>
{i18n.t("lobby.application.race_name_label")}
<input
type="text"
bind:value={raceNameInput}
data-testid="lobby-application-race-name"
autocomplete="off"
/>
</label>
{#if raceNameError !== null}
<p role="alert" data-testid="lobby-application-error">
{raceNameError}
</p>
{/if}
<div class="actions">
<button
type="submit"
disabled={submittingApplication}
data-testid="lobby-application-submit"
>
{i18n.t("lobby.application.submit")}
</button>
<button
type="button"
onclick={cancelApplicationForm}
data-testid="lobby-application-cancel"
>
{i18n.t("lobby.application.cancel")}
</button>
</div>
</form>
{:else}
<button
onclick={() => openApplicationForm(game.gameId)}
data-testid="lobby-public-game-apply"
>
{i18n.t("lobby.application.submit_for", { name: game.gameName })}
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
</main>
<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 {
margin-bottom: 2rem;
}
section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.card-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 0.4rem;
background: var(--color-surface-raised);
text-align: left;
font: inherit;
cursor: pointer;
width: 100%;
}
button.card:disabled {
cursor: not-allowed;
color: var(--color-text-faint);
background: var(--color-surface);
}
li.card {
cursor: default;
}
.meta {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.status {
align-self: flex-start;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--color-surface-raised);
font-size: 0.8rem;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
input[type="text"] {
font-size: 1rem;
padding: 0.4rem 0.5rem;
}
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border: 1px dashed var(--color-text-muted);
border-radius: 0.4rem;
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] {
font-size: 0.9rem;
}
</style>
@@ -0,0 +1,345 @@
<script lang="ts">
import { appScreen } from "$lib/app-nav.svelte";
import {
AuthError,
confirmEmailCode,
sendEmailCode,
} from "../../api/auth";
import { GATEWAY_BASE_URL } from "$lib/env";
import { i18n, SUPPORTED_LOCALES } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
type Step = "email" | "code";
let step: Step = $state("email");
let email = $state("");
let code = $state("");
let challengeId: string | null = $state(null);
let pending = $state(false);
let error: string | null = $state(null);
// Safari ignores `autocomplete="off"` on type=email / login-shaped
// fields and pops the Keychain suggester regardless. The classic
// workaround is to render the input as `readonly` initially —
// Safari does not autofill readonly fields — and drop the
// attribute on the first user focus so typing still works. Once
// dropped, the flag stays false for the rest of the page life.
let emailReadonly = $state(true);
let codeReadonly = $state(true);
function describe(err: unknown): string {
if (err instanceof AuthError) {
return err.message;
}
if (err instanceof Error) {
return err.message;
}
return "request failed";
}
async function submitEmail(event: Event): Promise<void> {
event.preventDefault();
if (pending) return;
const trimmed = email.trim();
if (trimmed.length === 0) {
error = i18n.t("login.email_required");
return;
}
pending = true;
error = null;
try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
challengeId = result.challengeId;
code = "";
step = "code";
} catch (err) {
error = describe(err);
} finally {
pending = false;
}
}
async function submitCode(event: Event): Promise<void> {
event.preventDefault();
if (pending) return;
const trimmedCode = code.trim();
if (trimmedCode.length === 0) {
error = i18n.t("login.code_required");
return;
}
if (challengeId === null) {
error = i18n.t("login.challenge_expired");
step = "email";
return;
}
if (session.keypair === null) {
error = i18n.t("login.device_key_not_ready");
return;
}
pending = true;
error = null;
try {
const result = await confirmEmailCode(GATEWAY_BASE_URL, {
challengeId,
code: trimmedCode,
publicKey: session.keypair.publicKey,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
await session.signIn(result.deviceSessionId);
appScreen.go("lobby");
} catch (err) {
if (err instanceof AuthError && err.code === "invalid_request") {
challengeId = null;
code = "";
step = "email";
error = i18n.t("login.code_expired_or_used");
} else {
error = describe(err);
}
} finally {
pending = false;
}
}
async function resend(): Promise<void> {
if (pending) return;
const trimmed = email.trim();
if (trimmed.length === 0) {
step = "email";
return;
}
pending = true;
error = null;
try {
const result = await sendEmailCode(GATEWAY_BASE_URL, trimmed, {
locale: i18n.locale,
});
challengeId = result.challengeId;
code = "";
} catch (err) {
error = describe(err);
} finally {
pending = false;
}
}
function changeEmail(): void {
challengeId = null;
code = "";
error = null;
step = "email";
}
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<main id="main-content" tabindex="-1">
<header>
<h1>{i18n.t("login.title")}</h1>
<div class="language-picker">
<svg
class="globe"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
focusable="false"
>
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M3 12h18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<path
d="M12 3a13 13 0 0 1 0 18M12 3a13 13 0 0 0 0 18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
</svg>
<label class="sr-only" for="login-language-select">
{i18n.t("common.language")}
</label>
<select
id="login-language-select"
data-testid="login-language-select"
bind:value={i18n.locale}
>
{#each SUPPORTED_LOCALES as locale (locale.code)}
<option value={locale.code}>{locale.nativeName}</option>
{/each}
</select>
</div>
</header>
{#if step === "email"}
<form
onsubmit={submitEmail}
aria-busy={pending}
autocomplete="off"
>
<label>
{i18n.t("login.email_label")}
<input
type="email"
name="galaxy-login-email"
autocomplete="new-password"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
readonly={emailReadonly}
onfocus={() => (emailReadonly = false)}
bind:value={email}
disabled={pending}
required
data-testid="login-email-input"
/>
</label>
<button
type="submit"
disabled={pending}
data-testid="login-email-submit"
>
{pending ? i18n.t("login.sending") : i18n.t("login.send_code")}
</button>
</form>
{:else}
<form
onsubmit={submitCode}
aria-busy={pending}
autocomplete="off"
>
<p data-testid="login-code-target">
{i18n.t("login.code_sent_to", { email })}
</p>
<label>
{i18n.t("login.code_label")}
<input
type="text"
name="galaxy-login-code"
inputmode="numeric"
autocomplete="new-password"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
readonly={codeReadonly}
onfocus={() => (codeReadonly = false)}
bind:value={code}
disabled={pending}
required
data-testid="login-code-input"
/>
</label>
<button type="submit" disabled={pending} data-testid="login-code-submit">
{pending ? i18n.t("login.verifying") : i18n.t("login.verify")}
</button>
<div class="secondary">
<button
type="button"
onclick={resend}
disabled={pending}
data-testid="login-resend"
>
{i18n.t("login.send_new_code")}
</button>
<button
type="button"
onclick={changeEmail}
disabled={pending}
data-testid="login-change-email"
>
{i18n.t("login.change_email")}
</button>
</div>
</form>
{/if}
{#if error !== null}
<p role="alert" data-testid="login-error">{error}</p>
{/if}
</main>
<style>
main {
padding: 2rem;
font-family: system-ui, sans-serif;
max-width: 32rem;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
header h1 {
margin: 0;
}
.language-picker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
opacity: 0.85;
}
.globe {
flex: none;
}
.language-picker select {
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.5rem;
}
form > label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
}
input {
font-size: 1rem;
padding: 0.5rem;
}
button {
font-size: 1rem;
padding: 0.5rem 1rem;
}
.secondary {
display: flex;
gap: 0.5rem;
}
.secondary button {
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
}
[role="alert"] {
margin-top: 1rem;
color: var(--color-danger);
}
</style>