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
+1 -22
View File
@@ -2,10 +2,8 @@
import "$lib/theme/tokens.css";
import "$lib/theme/base.css";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { dev } from "$app/environment";
import { appBase, withBase } from "$lib/paths";
import { withBase } from "$lib/paths";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { eventStream } from "../api/events.svelte";
@@ -77,25 +75,6 @@
eventStream.stop();
streamSessionId = null;
}
// page.url.pathname includes the configured base path; strip it so
// the route comparisons below stay base-agnostic.
const pathname = page.url.pathname.slice(appBase.length);
// Debug-only routes under /__debug/* run their own bootstrap
// path against the storage primitives and must bypass the
// auth guard so Phase 6's Playwright spec can drive the
// keystore directly.
if (pathname.startsWith("/__debug/")) {
return;
}
if (session.status === "anonymous" && pathname !== "/login") {
void goto(withBase("/login"), { replaceState: true });
} else if (
session.status === "authenticated" &&
(pathname === "/login" || pathname === "/")
) {
void goto(withBase("/lobby"), { replaceState: true });
}
});
</script>
+29 -15
View File
@@ -1,18 +1,32 @@
<script lang="ts">
// The app root renders no content of its own. The root layout's auth
// guard redirects "/" to /lobby (authenticated) or /login
// (anonymous); this placeholder only shows for the brief moment
// before that client-side redirect resolves.
import { i18n } from "$lib/i18n/index.svelte";
// Single-route screen dispatcher for the app-shell. There are no
// per-screen routes: the visible screen is selected from in-memory
// state (`session.status` for the auth gate, `appScreen.screen` for
// the authenticated screen) rather than from the URL. The root
// layout intercepts the `loading` and `unsupported` session states
// before this component renders, so here `session.status` is either
// `anonymous` (login) or `authenticated` (lobby / create / game).
import { session } from "$lib/session-store.svelte";
import { appScreen } from "$lib/app-nav.svelte";
import LoginScreen from "$lib/screens/login-screen.svelte";
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte";
</script>
<main class="status">
<p>{i18n.t("common.loading")}</p>
</main>
<style>
.status {
padding: var(--space-6);
font-family: var(--font-sans);
}
</style>
{#if session.status === "authenticated"}
{#if appScreen.screen === "lobby-create"}
<LobbyCreateScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell />
{:else}
<!--
Default authenticated screen. Covers `lobby`, a stale `login`
screen restored from a previous anonymous session, and a `game`
screen with no active game id (a snapshot that lost its id).
-->
<LobbyScreen />
{/if}
{:else}
<LoginScreen />
{/if}
@@ -1,625 +0,0 @@
<!--
Phase 10 in-game shell. Composes the header, a conditionally-visible
sidebar (Calculator / Inspector / Order tabs), the active-view slot
filled by the child route, and a mobile-only bottom-tab bar. The
layout owns:
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
sidebar pinned via CSS; mobile hides it entirely.
- `mobileTool` — mobile-only tool overlay state. The tool only
visually overrides the active-view slot when the URL is `/map`,
so navigating to any other view through the More drawer or the
header view-menu naturally drops the overlay even if `mobileTool`
was set on a previous tap.
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
`order`). Held here, bound into the sidebar so a planet click on
the map can flip it to `inspector` from the outside (Phase 13).
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
Phase 13 `SelectionStore`. All three are exposed to descendants
via Svelte context; their lifetimes match the layout instance,
which itself stays mounted across active-view switches inside
`/games/:id/*`.
Phase 11 added the per-game `GameStateStore` instance owned by this
layout: it constructs the `GalaxyClient`, fetches the matching lobby
record to discover `current_turn`, then loads the report. The store
is shared with descendants via `setContext("gameState", ...)` so the
header turn counter, the map view, and the inspector tab all read
from the same snapshot.
Phase 13 adds the planet inspector. The layout watches the selection
store and, on the null → planet transition, flips `activeTab` to
`inspector` and `sidebarOpen` to `true` so the inspector becomes
visible regardless of breakpoint (desktop already has the sidebar
pinned; tablet needs the drawer to surface). On mobile the
`<PlanetSheet />` overlay reads the same selection and displays a
read-only sheet over the map; closing the sheet clears the
selection.
State preservation across active-view switches works for free
because SvelteKit keeps this layout instance mounted while children
swap; navigating between games unmounts and remounts the layout, so
the next game's snapshot — and the next game's selection — start
fresh.
-->
<script lang="ts">
import { withBase } from "$lib/paths";
import { onDestroy, onMount, setContext, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte";
import Header from "$lib/header/header.svelte";
import HistoryBanner from "$lib/header/history-banner.svelte";
import Sidebar from "$lib/sidebar/sidebar.svelte";
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
import Calculator from "$lib/sidebar/calculator-tab.svelte";
import Order from "$lib/sidebar/order-tab.svelte";
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.svelte";
import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
import {
SelectionStore,
SELECTION_CONTEXT_KEY,
} from "$lib/selection.svelte";
import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
import {
createRenderedReportSource,
RENDERED_REPORT_CONTEXT_KEY,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import {
MAP_PICK_CONTEXT_KEY,
MapPickService,
} from "$lib/map-pick.svelte";
import {
GALAXY_CLIENT_CONTEXT_KEY,
GalaxyClientHolder,
} from "$lib/galaxy-client-context.svelte";
import {
CORE_CONTEXT_KEY,
CoreHolder,
} from "$lib/core-context.svelte";
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
import { createGatewayClient } from "../../../api/connect";
import { GalaxyClient } from "../../../api/galaxy-client";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
getSyntheticReport,
isSyntheticGameId,
} from "../../../api/synthetic-report";
import {
eventStream,
type VerifiedEvent,
} from "../../../api/events.svelte";
import { toast } from "$lib/toast.svelte";
import { mailStore } from "$lib/mail-store.svelte";
let { children } = $props();
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
const effectiveTool: MobileTool = $derived.by(() =>
isOnMap ? mobileTool : "map",
);
const gameState = new GameStateStore();
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
// when the player is viewing a past turn. The flag is owned by
// `GameStateStore` (single source of truth for "what turn are we
// looking at") and surfaced here so the Phase 12 sidebar wiring,
// the new `HistoryBanner`, and `orderDraft.bindClient` all read
// from the same derivation.
const historyMode = $derived(gameState.historyMode);
const selection = new SelectionStore();
setContext(SELECTION_CONTEXT_KEY, selection);
const renderedReport = createRenderedReportSource(gameState, orderDraft);
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
const galaxyClient = new GalaxyClientHolder();
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
const coreHolder = new CoreHolder();
setContext(CORE_CONTEXT_KEY, coreHolder);
// `MapPickService` lives at the layout so both the active map
// view (which binds the renderer-side resolver) and the
// inspector subsections (which call `pick(...)`) see the same
// instance via context — they sit on sibling branches of the
// component tree.
const mapPick = new MapPickService();
setContext(MAP_PICK_CONTEXT_KEY, mapPick);
// selectedPlanet resolves the current selection against the live
// report so both the desktop sidebar and the mobile sheet display
// the same snapshot. A selection that points at a planet missing
// from the current report (e.g. visibility lost between turns)
// reads as `null` here, which collapses the inspector and the
// sheet without surfacing a stale row. The rendered report layers
// the local order draft on top so the player sees their pending
// renames immediately.
const selectedPlanet = $derived.by(() => {
const sel = selection.selected;
if (sel === null || sel.kind !== "planet") return null;
const report = renderedReport.report;
if (report === null) return null;
return report.planets.find((p) => p.number === sel.id) ?? null;
});
const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
const sel = selection.selected;
if (sel === null || sel.kind !== "shipGroup") return null;
const report = renderedReport.report;
if (report === null) return null;
const ref = sel.ref;
switch (ref.variant) {
case "local": {
const group = report.localShipGroups.find((g) => g.id === ref.id);
if (group === undefined) return null;
return { variant: "local", group };
}
case "other": {
const group = report.otherShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "other", group };
}
case "incoming": {
const group = report.incomingShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "incoming", group };
}
case "unidentified": {
const group = report.unidentifiedShipGroups[ref.index];
if (group === undefined) return null;
return { variant: "unidentified", group };
}
}
});
const localShipClass = $derived(
renderedReport.report?.localShipClass ?? [],
);
const localScience = $derived(renderedReport.report?.localScience ?? []);
const inspectorPlanets = $derived(renderedReport.report?.planets ?? []);
const inspectorRoutes = $derived(renderedReport.report?.routes ?? []);
const inspectorMapWidth = $derived(renderedReport.report?.mapWidth ?? 1);
const inspectorMapHeight = $derived(renderedReport.report?.mapHeight ?? 1);
const inspectorLocalDrive = $derived(
renderedReport.report?.localPlayerDrive ?? 0,
);
const inspectorLocalWeapons = $derived(
renderedReport.report?.localPlayerWeapons ?? 0,
);
const inspectorLocalShields = $derived(
renderedReport.report?.localPlayerShields ?? 0,
);
const inspectorLocalCargo = $derived(
renderedReport.report?.localPlayerCargo ?? 0,
);
const inspectorLocalShipGroups = $derived(
renderedReport.report?.localShipGroups ?? [],
);
const inspectorOtherShipGroups = $derived(
renderedReport.report?.otherShipGroups ?? [],
);
const inspectorLocalFleets = $derived(
renderedReport.report?.localFleets ?? [],
);
const inspectorOtherRaces = $derived(
renderedReport.report?.otherRaces ?? [],
);
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
// Reveal the inspector whenever a new planet selection lands.
// Reading `selection.selected` once outside the effect keeps the
// effect dependent on the rune transition and not on the derived
// `selectedPlanet`, which can flicker as the report refreshes.
$effect(() => {
const sel = selection.selected;
if (sel === null) return;
// Stay in the calculator when a planet is picked: the calculator
// consumes the selection in its planet area + reach circles, and
// it is a long-lived workspace the user should not be ejected
// from. `activeTab` is read untracked so a manual tab switch does
// not re-fire this effect. Any other case (including a ship-group
// selection, which the calculator does not use) reveals the
// inspector as before.
const tab = untrack(() => activeTab);
if (!(tab === "calculator" && sel.kind === "planet")) {
activeTab = "inspector";
}
sidebarOpen = true;
});
// Reveal the calculator whenever the ship-classes table or the
// bottom-tabs entry asks to load a class (or start a fresh design).
let lastCalcLoadToken = 0;
$effect(() => {
const token = calculatorLoadRequest.token;
if (token === lastCalcLoadToken) return;
lastCalcLoadToken = token;
activeTab = "calculator";
sidebarOpen = true;
});
function toggleSidebar(): void {
sidebarOpen = !sidebarOpen;
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
// `unsubTurnReady` / `unsubGamePaused` carry the
// `eventStream.on(...)` disposers for the game-scoped push
// handlers. The layout registers them once the local
// `GameStateStore` is initialised so an event arriving before
// `currentTurn` is known cannot misfire.
let unsubTurnReady: (() => void) | null = null;
let unsubGamePaused: (() => void) | null = null;
let unsubMailReceived: (() => void) | null = null;
const turnReadyDecoder = new TextDecoder("utf-8");
function parseTurnReadyPayload(
event: VerifiedEvent,
): { gameId: string; turn: number } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
const eventTurn = record.turn;
if (
typeof eventGameId !== "string" ||
typeof eventTurn !== "number" ||
!Number.isFinite(eventTurn)
) {
return null;
}
return { gameId: eventGameId, turn: eventTurn };
} catch {
return null;
}
}
function parseMailReceivedPayload(
event: VerifiedEvent,
): { gameId: string; from: string } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
if (typeof eventGameId !== "string") {
return null;
}
const subject =
typeof record.subject === "string" && record.subject !== ""
? record.subject
: typeof record.preview === "string"
? record.preview
: "";
return { gameId: eventGameId, from: subject };
} catch {
return null;
}
}
function parseGamePausedPayload(
event: VerifiedEvent,
): { gameId: string; reason: string } | null {
try {
const text = turnReadyDecoder.decode(event.payloadBytes);
const json: unknown = JSON.parse(text);
if (typeof json !== "object" || json === null) {
return null;
}
const record = json as Record<string, unknown>;
const eventGameId = record.game_id;
if (typeof eventGameId !== "string") {
return null;
}
const reason = typeof record.reason === "string" ? record.reason : "";
return { gameId: eventGameId, reason };
} catch {
return null;
}
}
let activeTurnReadyToastId: string | null = null;
$effect(() => {
const pending = gameState.pendingTurn;
if (pending === null) {
if (activeTurnReadyToastId !== null) {
toast.dismiss(activeTurnReadyToastId);
activeTurnReadyToastId = null;
}
return;
}
activeTurnReadyToastId = toast.show({
messageKey: "game.events.turn_ready.message",
messageParams: { turn: String(pending) },
actionLabelKey: "game.events.turn_ready.action",
onAction: () => {
void gameState.advanceToPending();
},
durationMs: null,
});
});
onMount(() => {
(async (): Promise<void> => {
// DEV-only synthetic-report path. The lobby's "Load
// synthetic report" affordance navigates here with a
// `synthetic-<uuid>` id and the matching report
// pre-registered in an in-memory map. A page reload
// loses the map entry; that case redirects to /lobby
// so the user reloads the JSON.
if (isSyntheticGameId(gameId)) {
const report = getSyntheticReport(gameId);
if (report === undefined) {
await goto(withBase("/lobby"));
return;
}
try {
// Synthetic mode still needs the wasm `Core` so
// components that bridge to `pkg/calc/ship.go`
// (designer preview, BattleViewer mass radii) can
// resolve their math against the same engine helpers
// the live path uses. The live branch below also
// calls `loadCore()`; without it here the Battle
// Viewer rendered every ship-class circle at
// MAX_RADIUS in synthetic mode.
const [{ cache }, core] = await Promise.all([
loadStore(),
loadCore(),
]);
coreHolder.set(core);
await Promise.all([
gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }),
]);
// Deliberately no `galaxyClient.set` and no
// `orderDraft.bindClient`: synthetic mode never
// sends to the gateway. The auto-sync pipeline
// already short-circuits via the UUID guard in
// `scheduleSync`, but skipping the bind keeps
// the path simple to reason about.
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
return;
}
if (
session.keypair === null ||
session.deviceSessionId === null ||
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
) {
return;
}
const keypair = session.keypair;
const deviceSessionId = session.deviceSessionId;
try {
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
coreHolder.set(core);
const client = new GalaxyClient({
core,
edge: createGatewayClient(gatewayRpcBaseUrl()),
signer: (canonical) => keypair.sign(canonical),
sha256,
deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
// Register the `game.turn.ready` dispatch before the
// network roundtrips below so an event delivered
// while `gameState.init` is still in flight is not
// dropped by the singleton stream. `markPendingTurn`
// already protects against turns that do not advance
// past the current snapshot. Phase 25: a turn-ready
// frame arriving while the draft is in `conflict` or
// `paused` state also resets the draft and rehydrates
// from the server for the new turn — the old commands
// became history at the cutoff.
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
const parsed = parseTurnReadyPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
gameState.markPendingTurn(parsed.turn);
if (
orderDraft.syncStatus === "conflict" ||
orderDraft.syncStatus === "paused"
) {
void orderDraft.resetForNewTurn({
client,
turn: parsed.turn,
});
}
});
unsubGamePaused = eventStream.on("game.paused", (event) => {
const parsed = parseGamePausedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
orderDraft.markPaused({ reason: parsed.reason });
});
unsubMailReceived = eventStream.on(
"diplomail.message.received",
(event) => {
const parsed = parseMailReceivedPayload(event);
if (parsed === null || parsed.gameId !== gameId) {
return;
}
void mailStore.applyPushEvent(parsed.gameId);
toast.show({
messageKey: "game.events.mail_new.message",
messageParams: { from: parsed.from },
actionLabelKey: "game.events.mail_new.action",
onAction: () => {
void goto(withBase(`/games/${gameId}/mail`));
},
durationMs: 8000,
});
},
);
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
mailStore.init({ client, cache, gameId }),
]);
galaxyClient.set(client);
orderDraft.bindClient(client, {
getCurrentTurn: () => gameState.currentTurn,
getHistoryMode: () => gameState.historyMode,
});
// The server is always polled at game boot — its
// stored order may be fresher than the local cache
// (e.g. user is on a new device), and an offline
// edit must catch up at re-sync time. The hydration
// is non-fatal: a network error keeps the local
// cache and surfaces through `draft.syncStatus`.
await orderDraft.hydrateFromServer({
client,
turn: gameState.currentTurn,
});
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
})();
});
onDestroy(() => {
if (unsubTurnReady !== null) {
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
gameState.dispose();
orderDraft.dispose();
selection.dispose();
});
function describeBootstrapError(err: unknown): string {
if (err instanceof Error) return err.message;
return "request failed";
}
</script>
<div class="game-shell" data-testid="game-shell">
<a class="skip-link" href="#active-view-host">
{i18n.t("common.skip_to_content")}
</a>
<Header
{gameId}
{sidebarOpen}
onToggleSidebar={toggleSidebar}
/>
<HistoryBanner />
<div class="body">
<main
class="active-view-host"
id="active-view-host"
tabindex="-1"
data-testid="active-view-host"
>
{#if effectiveTool === "calc"}
<Calculator />
{:else if effectiveTool === "order"}
<Order />
{:else}
{@render children()}
{/if}
</main>
<Sidebar
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
bind:activeTab
/>
</div>
<BottomTabs
{gameId}
activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode}
/>
<PlanetSheet
planet={selectedPlanet}
{localShipClass}
{localScience}
routes={inspectorRoutes}
planets={inspectorPlanets}
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
localShipGroups={inspectorLocalShipGroups}
otherShipGroups={inspectorOtherShipGroups}
localRace={inspectorLocalRace}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
<ShipGroupSheet
selection={selectedShipGroup}
planets={inspectorPlanets}
{localShipClass}
localFleets={inspectorLocalFleets}
otherRaces={inspectorOtherRaces}
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
localPlayerWeapons={inspectorLocalWeapons}
localPlayerShields={inspectorLocalShields}
localPlayerCargo={inspectorLocalCargo}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>
</div>
<style>
.game-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--color-bg);
color: var(--color-text);
}
.body {
flex: 1;
display: flex;
min-height: 0;
}
.active-view-host {
flex: 1;
min-width: 0;
overflow-y: auto;
}
@media (max-width: 767.98px) {
.body {
padding-bottom: 3.25rem;
}
}
</style>
@@ -1,8 +0,0 @@
// SPA mode for the in-game shell, mirroring the root layout. The
// session bootstrap and the auth gate already live in the root
// `+layout.svelte`; this layout just inherits the SPA flags so the
// static adapter does not try to prerender a per-game shell at build
// time.
export const ssr = false;
export const prerender = false;
@@ -1,12 +0,0 @@
// A bare `/games/:id` URL is not in the IA section — every in-game
// view sits under one of the typed sub-routes (`map`, `table/...`,
// etc.). Default the user to the map view so the URL is always
// pointing at a real active view; SvelteKit's `redirect` runs in the
// browser because the layout disables SSR.
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load: PageLoad = ({ params }) => {
throw redirect(307, `/games/${params.id}/map`);
};
@@ -1,16 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte";
const turn = $derived.by(() => {
const raw = page.url.searchParams.get("turn");
const n = raw === null ? NaN : Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
});
</script>
<BattleView
gameId={page.params.id ?? ""}
{turn}
battleId={page.params.battleId ?? ""}
/>
@@ -1,5 +0,0 @@
<script lang="ts">
import DesignerScience from "$lib/active-view/designer-science.svelte";
</script>
<DesignerScience />
@@ -1,5 +0,0 @@
<script lang="ts">
import MailView from "$lib/active-view/mail.svelte";
</script>
<MailView />
@@ -1,5 +0,0 @@
<script lang="ts">
import MapView from "$lib/active-view/map.svelte";
</script>
<MapView />
@@ -1,47 +0,0 @@
<!--
Phase 23 turn-report route. The orchestrator renders the table of
contents and the twenty sections; scroll save/restore is wired
through SvelteKit's `Snapshot` API on this route file.
`window.scrollY` is captured before navigating away and restored
after `afterNavigate` re-mounts the route. The in-game shell
layout expands the active-view-host to fit content rather than
constraining its own height, so the document body is what scrolls
— hence `window.scroll` rather than a host-element scrollTop.
A short `requestAnimationFrame` poll waits for the body to grow
tall enough to honour the saved offset, because the captured
position usually exceeds the viewport height before the sections
mount on return navigation.
-->
<script lang="ts">
import type { Snapshot } from "@sveltejs/kit";
import ReportView from "$lib/active-view/report.svelte";
function restoreScroll(target: number): void {
if (target <= 0) return;
let attempts = 60;
const tick = (): void => {
const need = target + window.innerHeight;
const have = document.documentElement.scrollHeight;
if (have >= need || attempts === 0) {
window.scrollTo(0, target);
return;
}
attempts -= 1;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
export const snapshot: Snapshot<{ scrollY: number }> = {
capture() {
return { scrollY: window.scrollY };
},
restore(value) {
restoreScroll(value.scrollY);
},
};
</script>
<ReportView />
@@ -1,6 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import TableView from "$lib/active-view/table.svelte";
</script>
<TableView entity={page.params.entity ?? ""} />
-602
View File
@@ -1,602 +0,0 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "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 {
goto(withBase("/lobby/create"));
}
function gotoGame(gameId: string): void {
goto(withBase(`/games/${gameId}/map`));
}
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);
await goto(withBase(`/games/${gameId}/map`));
} 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; clicking them otherwise lands on a 404 because
// /games/:id/map only meaningfully exists once the runtime has
// produced game state.
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>
-6
View File
@@ -1,6 +0,0 @@
// Lobby is the first authenticated screen and depends on the
// session keypair plus the WASM core loaded at runtime; SSR and
// prerendering stay disabled.
export const ssr = false;
export const prerender = false;
@@ -1,294 +0,0 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
import { onMount } from "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 {
goto(withBase("/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,
});
goto(withBase("/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>
@@ -1,2 +0,0 @@
export const ssr = false;
export const prerender = false;
-346
View File
@@ -1,346 +0,0 @@
<script lang="ts">
import { withBase } from "$lib/paths";
import { goto } from "$app/navigation";
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);
void goto(withBase("/lobby"), { replaceState: true });
} 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>
-6
View File
@@ -1,6 +0,0 @@
// Login depends on browser-only WebCrypto and IndexedDB through the
// session store; SSR and prerendering are disabled to keep the
// component out of the server-render pipeline.
export const ssr = false;
export const prerender = false;