feat(ui): single-URL game app-shell (in-memory screens/views) #35
@@ -12,9 +12,8 @@ header now — we just hand the routes down as callbacks so the
|
||||
viewer keeps its prop-driven contract.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import {
|
||||
BattleFetchError,
|
||||
@@ -127,10 +126,10 @@ viewer keeps its prop-driven contract.
|
||||
});
|
||||
|
||||
function backToReport() {
|
||||
goto(withBase(`/games/${gameId}/report`));
|
||||
activeView.select("report");
|
||||
}
|
||||
function backToMap() {
|
||||
goto(withBase(`/games/${gameId}/map`));
|
||||
activeView.select("map");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,10 +25,8 @@ fractions is a Phase 21 decision documented in
|
||||
`ui/docs/science-designer-ux.md`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext, tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import type { ScienceSummary } from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -53,8 +51,11 @@ fractions is a Phase 21 decision documented in
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const scienceId = $derived(page.params.scienceId ?? "");
|
||||
// `scienceId` is the only sub-parameter the science designer needs;
|
||||
// the active game id is implicit (the shell only mounts this view
|
||||
// for the active game) and is read from `appScreen` where required.
|
||||
let { scienceId = "" }: { scienceId?: string } = $props();
|
||||
|
||||
const isViewMode = $derived(scienceId !== "");
|
||||
|
||||
const localScience = $derived<ScienceSummary[]>(
|
||||
@@ -126,7 +127,7 @@ fractions is a Phase 21 decision documented in
|
||||
}
|
||||
|
||||
function backToTable(): void {
|
||||
void goto(withBase(`/games/${gameId}/table/sciences`));
|
||||
activeView.select("table", { tableEntity: "sciences" });
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
|
||||
@@ -6,7 +6,7 @@ pane, system-item pane, compose form) live under
|
||||
`./mail/*.svelte`.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { mailStore, type MailListEntry } from "$lib/mail-store.svelte";
|
||||
@@ -19,7 +19,7 @@ pane, system-item pane, compose form) live under
|
||||
let selectedKey = $state<string | null>(null);
|
||||
let composeOpen = $state(false);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const gameId = $derived(appScreen.gameId ?? "");
|
||||
|
||||
const entries = $derived(mailStore.entries);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--
|
||||
Phase 11 map active view: integrates the Phase 9 renderer with the
|
||||
per-game `GameStateStore` provided through context by
|
||||
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
|
||||
`lib/game/game-shell.svelte`. The view mounts the renderer
|
||||
once the store has produced a report and re-mounts when the
|
||||
report's turn changes (a refresh that returns the same turn keeps
|
||||
the existing renderer instance alive). Empty-planet reports render
|
||||
@@ -20,10 +20,8 @@ Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
|
||||
preference the store already manages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
createRenderer,
|
||||
@@ -615,6 +613,29 @@ preference the store already manages.
|
||||
// through the same `hit-test` plumbing — the hitLookup map keyed
|
||||
// by primitive id resolves a hit back to either a planet or a
|
||||
// ship-group selection variant.
|
||||
// scrollToBombingRow waits for the report's bombing row for the
|
||||
// given planet to mount, then scrolls it into view. The map context
|
||||
// menu switches to the report view through a store mutation, so the
|
||||
// section renders on a later frame; a short bounded poll bridges
|
||||
// that gap without coupling the map to the report's render timing.
|
||||
function scrollToBombingRow(planet: number): void {
|
||||
if (typeof document === "undefined") return;
|
||||
let attempts = 60;
|
||||
const tick = (): void => {
|
||||
const row = document.querySelector(
|
||||
`[data-testid="report-bombing-row"][data-planet="${planet}"]`,
|
||||
);
|
||||
if (row instanceof HTMLElement) {
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
attempts -= 1;
|
||||
if (attempts <= 0) return;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||
if (handle === null || store?.report === undefined || store.report === null) {
|
||||
return;
|
||||
@@ -634,26 +655,20 @@ preference the store already manages.
|
||||
selection.selectShipGroup(target.ref);
|
||||
break;
|
||||
case "battle": {
|
||||
const gameId = page.params.id ?? "";
|
||||
const turn = store?.report?.turn ?? 0;
|
||||
void goto(
|
||||
withBase(`/games/${gameId}/battle/${target.battleId}?turn=${turn}`),
|
||||
);
|
||||
activeView.select("battle", {
|
||||
battleId: target.battleId,
|
||||
turn,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "bombing": {
|
||||
const gameId = page.params.id ?? "";
|
||||
void goto(
|
||||
withBase(`/games/${gameId}/report#report-bombings`),
|
||||
).then(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const row = document.querySelector(
|
||||
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
||||
);
|
||||
if (row && row.scrollIntoView) {
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
});
|
||||
activeView.select("report");
|
||||
// The report sections render reactively after the view
|
||||
// switches above, so there is no navigation promise to
|
||||
// await; poll a bounded number of animation frames for
|
||||
// the bombing row, then scroll it into view.
|
||||
scrollToBombingRow(target.planet);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ TOC and the body iterate the same data.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import ReportToc, {
|
||||
type TocEntry,
|
||||
@@ -71,8 +70,6 @@ TOC and the body iterate the same data.
|
||||
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
||||
];
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
|
||||
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
||||
let bodyEl: HTMLDivElement | null = $state(null);
|
||||
|
||||
@@ -116,7 +113,7 @@ TOC and the body iterate the same data.
|
||||
</script>
|
||||
|
||||
<div class="report-view" data-testid="active-view-report">
|
||||
<ReportToc entries={ENTRIES} {activeSlug} {gameId} />
|
||||
<ReportToc entries={ENTRIES} {activeSlug} />
|
||||
|
||||
<div class="report-body" bind:this={bodyEl}>
|
||||
<SectionGalaxySummary />
|
||||
|
||||
@@ -3,9 +3,10 @@ Phase 23 Report View table of contents.
|
||||
|
||||
Responsibilities:
|
||||
- "Back to map" button at the top — visible on both desktop sidebar
|
||||
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
|
||||
active-view-host scroll restoration plays through SvelteKit's
|
||||
history machinery and the layout's `mobileTool` resets naturally.
|
||||
and mobile sticky toolbar. Switches the active view to the map
|
||||
through `activeView.select("map")`; the shell's tool gate resets
|
||||
the `mobileTool` overlay naturally once the map is no longer the
|
||||
active view.
|
||||
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
||||
section. The active link gets `aria-current="location"` and a
|
||||
`.active` style. Click scrolls the active-view-host (not the
|
||||
@@ -20,8 +21,7 @@ The active section is computed by the orchestrator
|
||||
`activeSlug` prop. The TOC itself owns no observers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
|
||||
@@ -33,10 +33,9 @@ The active section is computed by the orchestrator
|
||||
type Props = {
|
||||
entries: readonly TocEntry[];
|
||||
activeSlug: string;
|
||||
gameId: string;
|
||||
};
|
||||
|
||||
let { entries, activeSlug, gameId }: Props = $props();
|
||||
let { entries, activeSlug }: Props = $props();
|
||||
|
||||
function scrollToSlug(slug: string): void {
|
||||
const target = document.getElementById(`report-${slug}`);
|
||||
@@ -62,8 +61,8 @@ The active section is computed by the orchestrator
|
||||
scrollToSlug(slug);
|
||||
}
|
||||
|
||||
async function backToMap(): Promise<void> {
|
||||
await goto(withBase(`/games/${gameId}/map`));
|
||||
function backToMap(): void {
|
||||
activeView.select("map");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<!--
|
||||
Phase 27 Report View — battles section. Each row is a link into the
|
||||
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
||||
`turn` follows the current report's turn so history-mode views land
|
||||
on the right battle. Phase 23 rendered the same rows as inactive
|
||||
Phase 27 Report View — battles section. Each row opens the Battle
|
||||
Viewer through `activeView.select("battle", { battleId, turn })`
|
||||
where `turn` follows the current report's turn so history-mode views
|
||||
land on the right battle. Phase 23 rendered the same rows as inactive
|
||||
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||
decision log called out.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
@@ -22,8 +21,11 @@ decision log called out.
|
||||
);
|
||||
const report = $derived(rendered?.report ?? null);
|
||||
const battles = $derived(report?.battles ?? []);
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const turn = $derived(report?.turn ?? 0);
|
||||
|
||||
function openBattle(battleId: string): void {
|
||||
activeView.select("battle", { battleId, turn });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -46,12 +48,13 @@ decision log called out.
|
||||
<span class="label">
|
||||
{i18n.t("game.report.section.battles.id_label")}
|
||||
</span>
|
||||
<a
|
||||
<button
|
||||
type="button"
|
||||
class="uuid"
|
||||
href={withBase(`/games/${gameId}/battle/${b.id}?turn=${turn}`)}
|
||||
onclick={() => openBattle(b.id)}
|
||||
data-testid="report-battle-row"
|
||||
data-id={b.id}
|
||||
>{b.id}</a>
|
||||
>{b.id}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -90,10 +93,15 @@ decision log called out.
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.uuid {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: var(--color-accent);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.uuid:hover {
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -11,16 +11,14 @@ The four tech proportions are stored on the wire as fractions in
|
||||
`[0, 1]` and surfaced here as percentages with one decimal so the
|
||||
table matches the designer's input units.
|
||||
|
||||
The component sits inside the active-view slot owned by
|
||||
`routes/games/[id]/+layout.svelte`, so it inherits the per-game
|
||||
The component sits inside the active-view area owned by
|
||||
`lib/game/game-shell.svelte`, so it inherits the per-game
|
||||
`OrderDraftStore` and `RenderedReportSource` through context. No
|
||||
data fetching is performed here — the layout is responsible.
|
||||
data fetching is performed here — the shell is responsible.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import type { ScienceSummary } from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -60,8 +58,6 @@ data fetching is performed here — the layout is responsible.
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
|
||||
let sortColumn: SortColumn = $state("name");
|
||||
let sortDirection: SortDirection = $state("asc");
|
||||
let filter: string = $state("");
|
||||
@@ -118,11 +114,11 @@ data fetching is performed here — the layout is responsible.
|
||||
}
|
||||
|
||||
function openDesigner(name: string): void {
|
||||
void goto(withBase(`/games/${gameId}/designer/science/${encodeURIComponent(name)}`));
|
||||
activeView.select("designer-science", { scienceId: name });
|
||||
}
|
||||
|
||||
function newScience(): void {
|
||||
void goto(withBase(`/games/${gameId}/designer/science`));
|
||||
activeView.select("designer-science");
|
||||
}
|
||||
|
||||
async function deleteScience(name: string): Promise<void> {
|
||||
|
||||
+110
-76
@@ -1,59 +1,63 @@
|
||||
<!--
|
||||
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:
|
||||
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 slot when the URL is `/map`,
|
||||
so navigating to any other view through the More drawer or the
|
||||
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 (Phase 13).
|
||||
the map can flip it to `inspector` from the outside.
|
||||
- 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/*`.
|
||||
`SelectionStore`. All three are exposed to descendants via Svelte
|
||||
context; their lifetimes match the shell instance.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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 { withBase } from "$lib/paths";
|
||||
import { onDestroy, onMount, setContext, untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { onDestroy, setContext, untrack } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.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";
|
||||
@@ -71,7 +75,7 @@ fresh.
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
} from "../../../sync/order-draft.svelte";
|
||||
} from "../../sync/order-draft.svelte";
|
||||
import {
|
||||
MAP_PICK_CONTEXT_KEY,
|
||||
MapPickService,
|
||||
@@ -85,30 +89,30 @@ fresh.
|
||||
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 { 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";
|
||||
} from "../../api/synthetic-report";
|
||||
import {
|
||||
eventStream,
|
||||
type VerifiedEvent,
|
||||
} from "../../../api/events.svelte";
|
||||
} 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));
|
||||
// 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",
|
||||
);
|
||||
@@ -363,18 +367,45 @@ fresh.
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
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 navigates here with a
|
||||
// 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 redirects to /lobby
|
||||
// loses the map entry; that case returns to the lobby
|
||||
// so the user reloads the JSON.
|
||||
if (isSyntheticGameId(gameId)) {
|
||||
const report = getSyntheticReport(gameId);
|
||||
if (isSyntheticGameId(activeGameId)) {
|
||||
const report = getSyntheticReport(activeGameId);
|
||||
if (report === undefined) {
|
||||
await goto(withBase("/lobby"));
|
||||
appScreen.go("lobby");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -392,8 +423,8 @@ fresh.
|
||||
]);
|
||||
coreHolder.set(core);
|
||||
await Promise.all([
|
||||
gameState.initSynthetic({ cache, gameId, report }),
|
||||
orderDraft.init({ cache, gameId }),
|
||||
gameState.initSynthetic({ cache, gameId: activeGameId, report }),
|
||||
orderDraft.init({ cache, gameId: activeGameId }),
|
||||
]);
|
||||
// Deliberately no `galaxyClient.set` and no
|
||||
// `orderDraft.bindClient`: synthetic mode never
|
||||
@@ -439,7 +470,7 @@ fresh.
|
||||
// became history at the cutoff.
|
||||
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
||||
const parsed = parseTurnReadyPayload(event);
|
||||
if (parsed === null || parsed.gameId !== gameId) {
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
gameState.markPendingTurn(parsed.turn);
|
||||
@@ -455,7 +486,7 @@ fresh.
|
||||
});
|
||||
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
||||
const parsed = parseGamePausedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== gameId) {
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
orderDraft.markPaused({ reason: parsed.reason });
|
||||
@@ -464,7 +495,7 @@ fresh.
|
||||
"diplomail.message.received",
|
||||
(event) => {
|
||||
const parsed = parseMailReceivedPayload(event);
|
||||
if (parsed === null || parsed.gameId !== gameId) {
|
||||
if (parsed === null || parsed.gameId !== activeGameId) {
|
||||
return;
|
||||
}
|
||||
void mailStore.applyPushEvent(parsed.gameId);
|
||||
@@ -473,16 +504,16 @@ fresh.
|
||||
messageParams: { from: parsed.from },
|
||||
actionLabelKey: "game.events.mail_new.action",
|
||||
onAction: () => {
|
||||
void goto(withBase(`/games/${gameId}/mail`));
|
||||
activeView.select("mail");
|
||||
},
|
||||
durationMs: 8000,
|
||||
});
|
||||
},
|
||||
);
|
||||
await Promise.all([
|
||||
gameState.init({ client, cache, gameId }),
|
||||
orderDraft.init({ cache, gameId }),
|
||||
mailStore.init({ client, cache, gameId }),
|
||||
gameState.init({ client, cache, gameId: activeGameId }),
|
||||
orderDraft.init({ cache, gameId: activeGameId }),
|
||||
mailStore.init({ client, cache, gameId: activeGameId }),
|
||||
]);
|
||||
galaxyClient.set(client);
|
||||
orderDraft.bindClient(client, {
|
||||
@@ -503,21 +534,12 @@ fresh.
|
||||
gameState.failBootstrap(describeBootstrapError(err));
|
||||
}
|
||||
})();
|
||||
|
||||
return teardownSubscriptions;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubTurnReady !== null) {
|
||||
unsubTurnReady();
|
||||
unsubTurnReady = null;
|
||||
}
|
||||
if (unsubGamePaused !== null) {
|
||||
unsubGamePaused();
|
||||
unsubGamePaused = null;
|
||||
}
|
||||
if (unsubMailReceived !== null) {
|
||||
unsubMailReceived();
|
||||
unsubMailReceived = null;
|
||||
}
|
||||
teardownSubscriptions();
|
||||
gameState.dispose();
|
||||
orderDraft.dispose();
|
||||
selection.dispose();
|
||||
@@ -534,7 +556,6 @@ fresh.
|
||||
{i18n.t("common.skip_to_content")}
|
||||
</a>
|
||||
<Header
|
||||
{gameId}
|
||||
{sidebarOpen}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
/>
|
||||
@@ -550,8 +571,22 @@ fresh.
|
||||
<Calculator />
|
||||
{:else if effectiveTool === "order"}
|
||||
<Order />
|
||||
{:else}
|
||||
{@render children()}
|
||||
{: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
|
||||
@@ -562,7 +597,6 @@ fresh.
|
||||
/>
|
||||
</div>
|
||||
<BottomTabs
|
||||
{gameId}
|
||||
activeTool={effectiveTool}
|
||||
onSelectTool={(tool) => (mobileTool = tool)}
|
||||
hideOrder={historyMode}
|
||||
@@ -27,11 +27,10 @@ absent until Phase 24 wires push-event state.
|
||||
import TurnNavigator from "./turn-navigator.svelte";
|
||||
|
||||
type Props = {
|
||||
gameId: string;
|
||||
sidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
};
|
||||
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
|
||||
let { sidebarOpen, onToggleSidebar }: Props = $props();
|
||||
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
@@ -69,7 +68,7 @@ absent until Phase 24 wires push-event state.
|
||||
>
|
||||
⤧
|
||||
</button>
|
||||
<ViewMenu {gameId} />
|
||||
<ViewMenu />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -7,21 +7,18 @@ itself is identical. The same component is reused for the mobile
|
||||
|
||||
Lists the seven IA destinations: map, tables (sub-list of six
|
||||
entities), report, battle, mail, ship-class designer, science
|
||||
designer. Closes on Escape, on outside click, and after a
|
||||
navigation. Phase 26 introduces the history-mode entry; Phase 35
|
||||
polishes microcopy.
|
||||
designer. Each entry mutates `activeView` (the single-URL app-shell
|
||||
has no per-view routes) and closes the menu. Closes on Escape, on
|
||||
outside click, and after a selection. Phase 26 introduces the
|
||||
history-mode entry; Phase 35 polishes microcopy.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView, type GameView } from "$lib/app-nav.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { mailStore } from "$lib/mail-store.svelte";
|
||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||
|
||||
type Props = { gameId: string };
|
||||
let { gameId }: Props = $props();
|
||||
|
||||
const mailUnread = $derived(mailStore.unreadCount);
|
||||
|
||||
let open = $state(false);
|
||||
@@ -40,9 +37,12 @@ polishes microcopy.
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function go(path: string): void {
|
||||
function select(
|
||||
view: GameView,
|
||||
params: { tableEntity?: string } = {},
|
||||
): void {
|
||||
open = false;
|
||||
void goto(withBase(path));
|
||||
activeView.select(view, params);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
@@ -93,7 +93,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-map"
|
||||
onclick={() => go(`/games/${gameId}/map`)}
|
||||
onclick={() => select("map")}
|
||||
>
|
||||
{i18n.t("game.view.map")}
|
||||
</button>
|
||||
@@ -105,7 +105,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-table-{entry.slug}"
|
||||
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
||||
onclick={() => select("table", { tableEntity: entry.slug })}
|
||||
>
|
||||
{i18n.t(entry.key)}
|
||||
</button>
|
||||
@@ -116,7 +116,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-report"
|
||||
onclick={() => go(`/games/${gameId}/report`)}
|
||||
onclick={() => select("report")}
|
||||
>
|
||||
{i18n.t("game.view.report")}
|
||||
</button>
|
||||
@@ -124,7 +124,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-battle"
|
||||
onclick={() => go(`/games/${gameId}/battle`)}
|
||||
onclick={() => select("battle")}
|
||||
>
|
||||
{i18n.t("game.view.battle")}
|
||||
</button>
|
||||
@@ -133,7 +133,7 @@ polishes microcopy.
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-mail"
|
||||
class="with-badge"
|
||||
onclick={() => go(`/games/${gameId}/mail`)}
|
||||
onclick={() => select("mail")}
|
||||
>
|
||||
<span>{i18n.t("game.view.mail")}</span>
|
||||
{#if mailUnread > 0}
|
||||
@@ -146,7 +146,7 @@ polishes microcopy.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="view-menu-item-designer-science"
|
||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
||||
onclick={() => select("designer-science")}
|
||||
>
|
||||
{i18n.t("game.view.designer.science")}
|
||||
</button>
|
||||
|
||||
+7
-8
@@ -1,14 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { createGatewayClient } from "../../../api/connect";
|
||||
import { GalaxyClient } from "../../../api/galaxy-client";
|
||||
import { LobbyError, createGame } from "../../../api/lobby";
|
||||
import { 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 { loadCore } from "../../platform/core/index";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
|
||||
const DEFAULT_MIN_PLAYERS = 2;
|
||||
@@ -52,7 +51,7 @@
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
goto(withBase("/lobby"));
|
||||
appScreen.go("lobby");
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
@@ -94,7 +93,7 @@
|
||||
turnSchedule: trimmedSchedule,
|
||||
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
||||
});
|
||||
goto(withBase("/lobby"));
|
||||
appScreen.go("lobby");
|
||||
} catch (err) {
|
||||
formError = describeLobbyError(err);
|
||||
} finally {
|
||||
+11
-8
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { appScreen, activeView } from "$lib/app-nav.svelte";
|
||||
|
||||
import { createGatewayClient } from "../../api/connect";
|
||||
import { GalaxyClient } from "../../api/galaxy-client";
|
||||
@@ -185,11 +184,15 @@
|
||||
}
|
||||
|
||||
function gotoCreate(): void {
|
||||
goto(withBase("/lobby/create"));
|
||||
appScreen.go("lobby-create");
|
||||
}
|
||||
|
||||
function gotoGame(gameId: string): void {
|
||||
goto(withBase(`/games/${gameId}/map`));
|
||||
// Enter a fresh game on the map view: reset the in-game view
|
||||
// state first so a stale snapshot from a previous game does not
|
||||
// leak into the new one, then switch the top-level screen.
|
||||
activeView.reset();
|
||||
appScreen.go("game", { gameId });
|
||||
}
|
||||
|
||||
async function onSyntheticFileChange(
|
||||
@@ -208,7 +211,8 @@
|
||||
const text = await file.text();
|
||||
const json: unknown = JSON.parse(text);
|
||||
const { gameId } = loadSyntheticReportFromJSON(json);
|
||||
await goto(withBase(`/games/${gameId}/map`));
|
||||
activeView.reset();
|
||||
appScreen.go("game", { gameId });
|
||||
} catch (err) {
|
||||
if (err instanceof SyntheticReportError) {
|
||||
syntheticError = err.message;
|
||||
@@ -227,9 +231,8 @@
|
||||
// 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.
|
||||
// non-clickable; entering them otherwise opens the game shell on a
|
||||
// game whose runtime state does not exist yet.
|
||||
function isPlayableStatus(status: string): boolean {
|
||||
return status === "running" || status === "paused" || status === "finished";
|
||||
}
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { goto } from "$app/navigation";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
import {
|
||||
AuthError,
|
||||
confirmEmailCode,
|
||||
@@ -89,7 +88,7 @@
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
await session.signIn(result.deviceSessionId);
|
||||
void goto(withBase("/lobby"), { replaceState: true });
|
||||
appScreen.go("lobby");
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError && err.code === "invalid_request") {
|
||||
challengeId = null;
|
||||
@@ -1,33 +1,31 @@
|
||||
<!--
|
||||
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map navigates
|
||||
to `/games/:id/map` and resets the tool overlay. Calc and Order also
|
||||
navigate to `/games/:id/map` — the layout's tool gate replaces the
|
||||
active view with the matching sidebar tool only when the URL is
|
||||
`/map`, so navigating to any other view via the More drawer or the
|
||||
header view-menu naturally drops the overlay.
|
||||
Mobile-only bottom-tab bar `[Map, Calc, Order, More]`. Map switches
|
||||
the active view to the map and resets the tool overlay. Calc and
|
||||
Order also switch to the map view — the shell's tool gate replaces
|
||||
the active view with the matching sidebar tool only while the map is
|
||||
the active view, so navigating to any other view via the More drawer
|
||||
or the header view-menu naturally drops the overlay.
|
||||
|
||||
More opens a drawer with the same destination list as the header
|
||||
view-menu. Phase 35 polish narrows it to the IA-spec subset
|
||||
(Mail, Battle log, Tables, History, Settings, Logout) once History
|
||||
exists; until then the convenience of one source of truth for
|
||||
destinations beats the duplication.
|
||||
view-menu, each entry mutating `activeView` directly (the single-URL
|
||||
app-shell has no per-view routes). Phase 35 polish narrows it to the
|
||||
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
|
||||
once History exists; until then the convenience of one source of
|
||||
truth for destinations beats the duplication.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { withBase } from "$lib/paths";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeView, type GameView } from "$lib/app-nav.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { restoreFocus } from "$lib/a11y/restore-focus";
|
||||
import type { MobileTool } from "./types";
|
||||
|
||||
type Props = {
|
||||
gameId: string;
|
||||
activeTool: MobileTool;
|
||||
onSelectTool: (tool: MobileTool) => void;
|
||||
hideOrder?: boolean;
|
||||
};
|
||||
let {
|
||||
gameId,
|
||||
activeTool,
|
||||
onSelectTool,
|
||||
hideOrder = false,
|
||||
@@ -45,16 +43,18 @@ destinations beats the duplication.
|
||||
{ slug: "races", key: "game.view.table.races" },
|
||||
];
|
||||
|
||||
async function selectTool(tool: MobileTool): Promise<void> {
|
||||
function selectTool(tool: MobileTool): void {
|
||||
moreOpen = false;
|
||||
onSelectTool(tool);
|
||||
await goto(withBase(`/games/${gameId}/map`));
|
||||
// Calc / Order surface only over the map; selecting Map simply
|
||||
// drops the overlay. Either way the map must be the active view.
|
||||
activeView.select("map");
|
||||
}
|
||||
|
||||
async function go(path: string): Promise<void> {
|
||||
function go(view: GameView, params: { tableEntity?: string } = {}): void {
|
||||
moreOpen = false;
|
||||
onSelectTool("map");
|
||||
await goto(withBase(path));
|
||||
activeView.select(view, params);
|
||||
}
|
||||
|
||||
function toggleMore(): void {
|
||||
@@ -143,7 +143,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-map"
|
||||
onclick={() => go(`/games/${gameId}/map`)}
|
||||
onclick={() => go("map")}
|
||||
>
|
||||
{i18n.t("game.view.map")}
|
||||
</button>
|
||||
@@ -155,7 +155,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-table-{entry.slug}"
|
||||
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
||||
onclick={() => go("table", { tableEntity: entry.slug })}
|
||||
>
|
||||
{i18n.t(entry.key)}
|
||||
</button>
|
||||
@@ -166,7 +166,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-report"
|
||||
onclick={() => go(`/games/${gameId}/report`)}
|
||||
onclick={() => go("report")}
|
||||
>
|
||||
{i18n.t("game.view.report")}
|
||||
</button>
|
||||
@@ -174,7 +174,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-battle"
|
||||
onclick={() => go(`/games/${gameId}/battle`)}
|
||||
onclick={() => go("battle")}
|
||||
>
|
||||
{i18n.t("game.view.battle")}
|
||||
</button>
|
||||
@@ -182,7 +182,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-mail"
|
||||
onclick={() => go(`/games/${gameId}/mail`)}
|
||||
onclick={() => go("mail")}
|
||||
>
|
||||
{i18n.t("game.view.mail")}
|
||||
</button>
|
||||
@@ -190,7 +190,7 @@ destinations beats the duplication.
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="bottom-tabs-more-designer-science"
|
||||
onclick={() => go(`/games/${gameId}/designer/science`)}
|
||||
onclick={() => go("designer-science")}
|
||||
>
|
||||
{i18n.t("game.view.designer.science")}
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
@@ -67,7 +67,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
// Reset the design when the active game changes; a no-op otherwise, so
|
||||
// the design persists across tab switches within a game.
|
||||
$effect(() => {
|
||||
cs.ensureGame(page.params.id ?? "");
|
||||
cs.ensureGame(appScreen.gameId ?? "");
|
||||
});
|
||||
|
||||
const core = $derived(coreHandle?.core ?? null);
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
<!--
|
||||
Sidebar with three tabs (Calculator, Inspector, Order). The parent
|
||||
layout decides whether the sidebar is rendered at all (mobile hides
|
||||
shell decides whether the sidebar is rendered at all (mobile hides
|
||||
it, tablet collapses it behind the header toggle, desktop keeps it
|
||||
always visible). State preservation across active-view switches
|
||||
works for free because the layout never remounts when the user
|
||||
navigates within `/games/:id/*`.
|
||||
|
||||
The optional `?sidebar=calc|calculator|inspector|order` URL param
|
||||
seeds the initial tab on first mount — used by the lobby card path
|
||||
when later phases want to land directly on a particular tool.
|
||||
works for free because the shell never remounts when the user
|
||||
switches the active view within a game.
|
||||
|
||||
The `historyMode` prop hides the Order tab when true: the tab-bar
|
||||
filters it out and any URL seed targeting `order` falls back to
|
||||
`inspector`. Phase 12 wires the prop through the layout as a
|
||||
constant `false`; Phase 26 flips it on for past-turn snapshots.
|
||||
filters it out and the history-mode reset falls back to `inspector`.
|
||||
Phase 12 wires the prop through the shell as a constant `false`;
|
||||
Phase 26 flips it on for past-turn snapshots.
|
||||
|
||||
`activeTab` is a `$bindable` prop so the layout can drive it from
|
||||
`activeTab` is a `$bindable` prop so the shell can drive it from
|
||||
external events (Phase 13 reveals the inspector tab when a planet
|
||||
is clicked on the map). The URL seed and the history-mode reset
|
||||
both mutate the bindable in place; the layout sees the change
|
||||
through the binding without extra plumbing.
|
||||
is clicked on the map). The history-mode reset mutates the bindable
|
||||
in place; the shell sees the change through the binding without
|
||||
extra plumbing.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import TabBar from "./tab-bar.svelte";
|
||||
import Calculator from "./calculator-tab.svelte";
|
||||
import Inspector from "./inspector-tab.svelte";
|
||||
@@ -44,29 +38,11 @@ through the binding without extra plumbing.
|
||||
activeTab = $bindable<SidebarTab>("inspector"),
|
||||
}: Props = $props();
|
||||
|
||||
function readUrlSeed(): SidebarTab | null {
|
||||
const v = page.url.searchParams.get("sidebar");
|
||||
if (v === "calc" || v === "calculator") return "calculator";
|
||||
if (v === "inspector") return "inspector";
|
||||
if (v === "order") return "order";
|
||||
return null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (historyMode && activeTab === "order") {
|
||||
activeTab = "inspector";
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const seed = readUrlSeed();
|
||||
if (seed === null) return;
|
||||
if (seed === "order" && historyMode) {
|
||||
activeTab = "inspector";
|
||||
return;
|
||||
}
|
||||
activeTab = seed;
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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 ?? ""} />
|
||||
@@ -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,2 +0,0 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user