db81bd8e08
Step 7 — header view-menu badge.
`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.
Step 8 — push event handler + MailStore init in the in-game layout.
`routes/games/[id]/+layout.svelte`:
- registers a `diplomail.message.received` handler alongside the
existing `game.turn.ready` / `game.paused` ones, parses the
signed payload, calls `mailStore.applyPushEvent` to refresh the
inbox for the matching game, and raises a toast with a "view"
deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
`Promise.all` so the inbox + sent lists are warm by the time the
view mounts, and the badge counter is populated before any user
interaction;
- disposes the new subscription in the `onDestroy` block so a game
switch does not leak handlers across navigations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
594 lines
20 KiB
Svelte
594 lines
20 KiB
Svelte
<!--
|
|
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 { onDestroy, onMount, setContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/state";
|
|
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 {
|
|
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 { createEdgeGatewayClient } from "../../../api/connect";
|
|
import { GalaxyClient } from "../../../api/galaxy-client";
|
|
import { GATEWAY_BASE_URL, 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;
|
|
activeTab = "inspector";
|
|
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("/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: createEdgeGatewayClient(GATEWAY_BASE_URL),
|
|
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(`/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">
|
|
<Header
|
|
{gameId}
|
|
{sidebarOpen}
|
|
onToggleSidebar={toggleSidebar}
|
|
/>
|
|
<HistoryBanner />
|
|
<div class="body">
|
|
<main class="active-view-host" 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: #0a0e1a;
|
|
color: #e8eaf6;
|
|
}
|
|
.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>
|