ui/phase-26: history mode (turn navigator + read-only banner)

Split GameStateStore into currentTurn (server's latest) and viewedTurn
(displayed snapshot) so history excursions don't corrupt the resume
bookmark or the live-turn bound. Add viewTurn / returnToCurrent /
historyMode rune, plus a game-history cache namespace that stores
past-turn reports for fast re-entry. OrderDraftStore.bindClient takes
a getHistoryMode getter and short-circuits add / remove / move while
the user is viewing a past turn; RenderedReportSource skips the order
overlay in the same case. Header replaces the static "turn N" with a
clickable triplet (TurnNavigator), the layout mounts HistoryBanner
under the header, and visibility-refresh is a no-op while history is
active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-12 00:13:19 +02:00
parent 070fdc0ee5
commit 2d17760a5e
20 changed files with 1572 additions and 118 deletions
+24 -11
View File
@@ -1,9 +1,11 @@
// Component tests for the in-game shell header. The header composes
// the headline strip (`<race> @ <game>, turn N`, falling back to `?`
// while the lobby / report calls are in flight), the view-menu, and
// the account-menu. The tests assert the headline copy, that every
// view-menu entry dispatches `goto` with the right URL, and that the
// Logout entry of the account-menu calls `session.signOut("user")`.
// the identity strip (`<race> @ <game>`, falling back to `?` while
// the lobby / report calls are in flight), the Phase 26 turn
// navigator (`← turn N →` with a popover of every turn), the
// view-menu, and the account-menu. The tests assert the visible
// copy, that every view-menu entry dispatches `goto` with the right
// URL, and that the Logout entry of the account-menu calls
// `session.signOut("user")`.
import "@testing-library/jest-dom/vitest";
import { fireEvent, render } from "@testing-library/svelte";
@@ -48,6 +50,8 @@ function withGameState(opts: {
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
};
store.currentTurn = opts.turn ?? 0;
store.viewedTurn = opts.turn ?? 0;
store.status = "ready";
}
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
@@ -75,8 +79,11 @@ describe("game-shell header", () => {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
context: withGameState(),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"? @ ?, turn ?",
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
"? @ ?",
);
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
"turn ?",
);
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
@@ -91,8 +98,11 @@ describe("game-shell header", () => {
turn: 7,
}),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"Federation @ Phase 14, turn 7",
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
"Federation @ Phase 14",
);
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
"turn 7",
);
});
@@ -101,8 +111,11 @@ describe("game-shell header", () => {
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
context: withGameState({ race: "Federation", turn: 3 }),
});
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
"Federation @ ?, turn 3",
expect(ui.getByTestId("game-shell-identity")).toHaveTextContent(
"Federation @ ?",
);
expect(ui.getByTestId("turn-navigator-trigger")).toHaveTextContent(
"turn 3",
);
});