Files
galaxy-game/ui/frontend/src/lib/game/game-shell.svelte
T
Ilia Denisov 4a23c357e5
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Waiting to run
feat(ui): F8-05 — game-mode chrome cleanup + inspector compact rows (#48)
Drains six F8 polish items (parent #43) in one feature:

а) Chrome cleanup
- п.6 — remove the AccountMenu (settings/sessions/theme/language/logout
  ∼ rudimentary in-game) and replace it with a single icon-button
  light/dark theme toggle. The toggle flips an in-memory `theme.override`;
  game-shell unmount calls `theme.clearOverride()` so the lobby (and
  any re-entry) re-projects the persisted lobby choice.
- п.8 — remove the wrap-scrolling radio from the map gear popover. The
  per-game `wrapMode` store and the renderer's no-wrap path stay in
  place for a future engine-side topology feature; only the UI surface
  is dropped (wrap is a server-side concept, not a per-session UI
  affordance).

б) Inspector compact rows (single idiom: select + ✓ apply / ✗ cancel,
or contextual edit/remove/add)
- п.13 — planet name is now click-to-edit: clicking the name opens an
  inline `<input>` + ✓ confirm icon; Escape cancels; the explicit
  Rename action button and Cancel button are gone.
- п.14 — production becomes one row: primary `<select>` picks
  industry/materials/research/ship, conditional secondary `<select>`
  picks the target (tech / science / ship class) for research and
  ship contexts. Apply is gated until row state differs from the
  planet's current effective production; auto-submit-on-click is
  replaced by the apply-gate.
- п.16 — cargo routes collapse to one row: a single dropdown
  (COL/CAP/MAT/EMP plus a placeholder that absorbs the old section
  title) and contextual action buttons (add / edit + remove) to the
  right. After a successful pick or remove the dropdown stays on the
  type the user just acted on.
- п.32 — stationed ship groups hoist the race column into a dropdown
  above the table. The dropdown seeds with the player's own race when
  local groups are stationed here, otherwise the first race
  alphabetically; rendered only when more than one race is in orbit.
  The race column is dropped in both single- and multi-race modes —
  the dropdown's value already names the active race.

Tests: unit and Playwright e2e updated for every changed test-id and
flow; new coverage added for `theme.override`, the in-game toggle, the
apply-gate behaviour, and the stationed-race dropdown. i18n keys for
the removed menu items, the wrap radios, the cargo title, and the
explicit `rename.cancel` are dropped from both locales; new
`game.shell.theme_toggle.*`, `production.main/target.*`,
`production.apply/cancel`, `cargo.placeholder`, and
`ship_groups.race_filter.aria` keys land.

Docs synced: `docs/FUNCTIONAL.md` §6.7 + `docs/FUNCTIONAL_ru.md`
mirror drop the torus / no-wrap radio mention; `ui/docs/design-system.md`
documents the lobby-owned persisted picker + the in-game ephemeral
override channel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:38:42 +02:00

701 lines
24 KiB
Svelte

<!--
In-game shell. Composes the header, a conditionally-visible sidebar
(Calculator / Inspector / Order tabs), the active-view area selected
by `activeView`, and a mobile-only bottom-tab bar. In the single-URL
app-shell there are no per-view routes: the active game id comes from
`appScreen.gameId` and the visible view from `activeView`, both held
in `$lib/app-nav.svelte`. The shell 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 area when the active view is the
map, so switching 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.
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
`SelectionStore`. All three are exposed to descendants via Svelte
context; their lifetimes match the shell instance.
The per-game `GameStateStore` constructs the `GalaxyClient`, fetches
the matching lobby record to discover `current_turn`, then loads the
report. The store is shared with descendants via
`setContext(GAME_STATE_CONTEXT_KEY, ...)` so the header turn counter,
the map view, and the inspector tab all read from the same snapshot.
The planet inspector: the shell 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.
The per-game bootstrap (client construction, store init, push-event
subscriptions) runs from an `$effect` keyed on `appScreen.gameId`:
the cleanup tears the previous game's subscriptions down and the body
re-initialises the shared stores for the new id, so a direct
game → game switch (without leaving the shell) rebinds cleanly. The
shell unmounts when the dispatcher leaves the `game` screen, so a
return to the lobby still disposes the stores via `onDestroy`.
-->
<script lang="ts">
import { onDestroy, setContext, untrack } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { theme } from "$lib/theme/theme.svelte";
import { appScreen, activeView } from "$lib/app-nav.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 MapView from "$lib/active-view/map.svelte";
import TableView from "$lib/active-view/table.svelte";
import ReportView from "$lib/active-view/report.svelte";
import BattleView from "$lib/active-view/battle.svelte";
import MailView from "$lib/active-view/mail.svelte";
import DesignerScience from "$lib/active-view/designer-science.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 sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
let activeTab: SidebarTab = $state("inspector");
// The tool overlay (Calculator / Order) only replaces the active
// view while the map is showing; switching to any other view drops
// it, matching the previous URL-driven behaviour.
const isOnMap = $derived(activeView.view === "map");
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,
});
});
function teardownSubscriptions(): void {
if (unsubTurnReady !== null) {
unsubTurnReady();
unsubTurnReady = null;
}
if (unsubGamePaused !== null) {
unsubGamePaused();
unsubGamePaused = null;
}
if (unsubMailReceived !== null) {
unsubMailReceived();
unsubMailReceived = null;
}
}
// Per-game bootstrap. The effect re-runs whenever `appScreen.gameId`
// changes: its cleanup tears the previous game's push-event
// subscriptions down, then the body rebinds the shared stores to the
// new id. The shared store instances persist across the switch
// (descendants captured them through context at construction), so a
// game → game switch re-initialises them in place rather than
// recreating them; `onDestroy` performs the terminal `dispose()`
// when the dispatcher leaves the `game` screen and unmounts the
// shell. A null id (no active game) is a no-op.
$effect(() => {
const activeGameId = appScreen.gameId;
if (activeGameId === null || activeGameId === "") return;
(async (): Promise<void> => {
// DEV-only synthetic-report path. The lobby's "Load
// synthetic report" affordance enters the game 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 returns to the lobby
// so the user reloads the JSON.
if (isSyntheticGameId(activeGameId)) {
const report = getSyntheticReport(activeGameId);
if (report === undefined) {
appScreen.go("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: activeGameId, report }),
orderDraft.init({ cache, gameId: activeGameId }),
]);
// 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 !== activeGameId) {
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 !== activeGameId) {
return;
}
orderDraft.markPaused({ reason: parsed.reason });
});
unsubMailReceived = eventStream.on(
"diplomail.message.received",
(event) => {
const parsed = parseMailReceivedPayload(event);
if (parsed === null || parsed.gameId !== activeGameId) {
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: () => {
activeView.select("mail");
},
durationMs: 8000,
});
},
);
await Promise.all([
gameState.init({ client, cache, gameId: activeGameId }),
orderDraft.init({ cache, gameId: activeGameId }),
mailStore.init({ client, cache, gameId: activeGameId }),
]);
// A restored or stale game id may point at a game that is
// no longer in the player's list (cancelled, removed, or
// access revoked). `init` flags that distinct case via
// `gameState.notFound` (a transient network error keeps it
// false and surfaces the in-game error state instead). Drop
// to the lobby with a toast rather than stranding the user
// on the in-game "not in your list" error. Leaving the
// `game` screen unmounts the shell, so the stores are
// disposed via `onDestroy`; the rest of the bootstrap
// (client bind, server hydration) is skipped for the dead
// game.
if (gameState.notFound) {
appScreen.go("lobby");
toast.show({
messageKey: "game.events.unavailable.message",
durationMs: 8000,
});
return;
}
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));
}
})();
return teardownSubscriptions;
});
onDestroy(() => {
teardownSubscriptions();
gameState.dispose();
orderDraft.dispose();
selection.dispose();
// The in-game header carries an ephemeral light/dark override
// on `theme`; drop it on shell teardown so the lobby (and any
// future re-entry into the game) re-projects the persisted
// preference.
theme.clearOverride();
});
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
{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 if activeView.view === "map"}
<MapView />
{:else if activeView.view === "table"}
<TableView entity={activeView.state.tableEntity ?? ""} />
{:else if activeView.view === "report"}
<ReportView />
{:else if activeView.view === "battle"}
<BattleView
gameId={appScreen.gameId ?? ""}
turn={activeView.state.turn ?? 0}
battleId={activeView.state.battleId ?? ""}
/>
{:else if activeView.view === "mail"}
<MailView />
{:else if activeView.view === "designer-science"}
<DesignerScience scienceId={activeView.state.scienceId} />
{/if}
</main>
<Sidebar
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
bind:activeTab
/>
</div>
<BottomTabs
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) {
/*
Pin the shell to the viewport on mobile and take it out of
document flow. With `min-height: 100vh` the shell can overflow
the viewport by a few sub-pixels, which makes the document
scrollable; scrolling then toggles the mobile browser's dynamic
toolbar, resizing the viewport and the `position: fixed` overlays
(map-toggles menu, bottom-tab drawer, planet sheet) mid-gesture.
`position: fixed; inset: 0` keeps the viewport — and those
overlays — stable, leaving the active-view area as the single
internal scroll region.
*/
.game-shell {
position: fixed;
inset: 0;
min-height: 0;
}
.body {
padding-bottom: 3.25rem;
}
}
</style>