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
+11 -21
View File
@@ -1,8 +1,10 @@
<!--
Top header for the in-game shell. Composes the in-game ID strip
(race name @ game name, turn N), view dropdown / hamburger, and the
account menu. The sidebar-toggle slot to its left appears only on
tablet viewports (7681024 px) and is wired by `+layout.svelte`.
(race name @ game name) followed by the Phase 26 turn navigator (a
`← Turn N →` triplet with a popover of every turn), the view
dropdown / hamburger, and the account menu. The sidebar-toggle slot
to its left appears only on tablet viewports (7681024 px) and is
wired by `+layout.svelte`.
The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece
@@ -22,6 +24,7 @@ absent until Phase 24 wires push-event state.
} from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
import TurnNavigator from "./turn-navigator.svelte";
type Props = {
gameId: string;
@@ -44,27 +47,14 @@ absent until Phase 24 wires push-event state.
const name = gameState?.gameName ?? "";
return name === "" ? i18n.t("game.shell.unknown") : name;
});
const turn = $derived.by(() => {
const report = gameState?.report;
return report === null || report === undefined
? i18n.t("game.shell.unknown")
: String(report.turn);
});
const headline = $derived(
i18n.t("game.shell.headline", {
race: raceName,
game: gameName,
turn,
}),
);
</script>
<header class="game-shell-header" data-testid="game-shell-header">
<div class="left">
<span class="headline" data-testid="game-shell-headline">
{headline}
<div class="left" data-testid="game-shell-headline">
<span class="identity" data-testid="game-shell-identity">
{raceName} @ {gameName}
</span>
<TurnNavigator />
</div>
<div class="right">
<button
@@ -106,7 +96,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem;
min-width: 0;
}
.headline {
.identity {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -0,0 +1,80 @@
<!--
Phase 26 read-only banner. Renders directly under the shell header
whenever the user is viewing a past turn (`gameState.historyMode`).
Carries the turn number and a "Return to current turn" action that
delegates to `gameState.returnToCurrent()`. The banner is invisible
in live mode so the active-view chrome keeps its full vertical
budget.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
const visible = $derived(gameState?.historyMode === true);
function onReturn(): void {
void gameState?.returnToCurrent();
}
</script>
{#if visible}
<aside class="history-banner" data-testid="history-banner" role="status">
<span class="message">
{i18n.t("game.shell.history.viewing", { turn: String(viewedTurn) })}
</span>
<button
type="button"
class="return"
data-testid="history-banner-return"
onclick={onReturn}
>
{i18n.t("game.shell.history.return_to_current")}
</button>
</aside>
{/if}
<style>
.history-banner {
position: sticky;
top: 3rem;
z-index: 35;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.9rem;
background: #2a2438;
color: #efe9c8;
border-bottom: 1px solid #45375a;
font-family: system-ui, sans-serif;
font-size: 0.9rem;
}
.message {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.return {
font: inherit;
padding: 0.25rem 0.65rem;
background: transparent;
color: inherit;
border: 1px solid #6c5a8a;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.return:hover {
background: #3a3050;
}
</style>
@@ -0,0 +1,263 @@
<!--
Phase 26 header turn navigator. Replaces the Phase 11 inline turn
number with a `← Turn N →` triplet. The arrows step ±1 (disabled at
boundaries `0` and `currentTurn`), the middle button opens a popover
listing every turn `Turn #0`…`Turn #currentTurn` with the current row
tagged. No free-text input — every reachable turn is in the list, so
there is nothing to validate.
Desktop and mobile share the same component: an absolute-positioned
popover anchored to the trigger on desktop becomes a fixed full-width
drawer below the 768 px breakpoint, mirroring `view-menu.svelte`.
Selecting a row calls `gameState.viewTurn(N)`; the row that matches
`currentTurn` delegates to `gameState.returnToCurrent()` so the
"leave history" path always flows through one method.
-->
<script lang="ts">
import { getContext, onMount } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
let open = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
const currentTurn = $derived(gameState?.currentTurn ?? 0);
const viewedTurn = $derived(gameState?.viewedTurn ?? 0);
const ready = $derived(gameState?.status === "ready");
const canPrev = $derived(ready && viewedTurn > 0);
const canNext = $derived(ready && viewedTurn < currentTurn);
// Until the boot completes the store has no report, so the
// counter falls back to the same `?` placeholder Phase 11 used in
// the static headline. Avoids briefly flashing `turn 0` while the
// lobby / report calls are in flight.
const turnText = $derived(
ready ? String(viewedTurn) : i18n.t("game.shell.unknown"),
);
// Descending list newest → oldest. The popover stays compact for
// short games and scrolls for long ones; the current-turn row is
// pinned at the top with an explicit badge so the affordance to
// jump back is always reachable without scrolling.
const turns = $derived.by(() => {
if (!ready) return [] as number[];
const out: number[] = [];
for (let i = currentTurn; i >= 0; i--) {
out.push(i);
}
return out;
});
function toggleOpen(): void {
if (!ready) return;
open = !open;
}
async function goToTurn(turn: number): Promise<void> {
open = false;
if (gameState === undefined) return;
if (turn === gameState.currentTurn) {
await gameState.returnToCurrent();
return;
}
await gameState.viewTurn(turn);
}
async function step(delta: number): Promise<void> {
if (gameState === undefined) return;
const next = viewedTurn + delta;
if (next < 0 || next > currentTurn) return;
if (next === gameState.currentTurn) {
await gameState.returnToCurrent();
return;
}
await gameState.viewTurn(next);
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape" && open) {
open = false;
}
}
onMount(() => {
const handleClick = (event: MouseEvent): void => {
if (!open || rootEl === null) return;
const target = event.target;
if (target instanceof Node && rootEl.contains(target)) return;
open = false;
};
document.addEventListener("click", handleClick, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("click", handleClick, true);
document.removeEventListener("keydown", onKeyDown);
};
});
</script>
<div class="turn-navigator" bind:this={rootEl} data-testid="turn-navigator">
<button
type="button"
class="step"
data-testid="turn-navigator-prev"
aria-label={i18n.t("game.shell.turn.prev")}
disabled={!canPrev}
onclick={() => void step(-1)}
>
</button>
<button
type="button"
class="trigger"
data-testid="turn-navigator-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label={open
? i18n.t("game.shell.turn.close_navigator")
: i18n.t("game.shell.turn.open_navigator")}
disabled={!ready}
onclick={toggleOpen}
>
{i18n.t("game.shell.turn.label", { turn: turnText })}
</button>
<button
type="button"
class="step"
data-testid="turn-navigator-next"
aria-label={i18n.t("game.shell.turn.next")}
disabled={!canNext}
onclick={() => void step(1)}
>
</button>
{#if open}
<div class="surface" role="menu" data-testid="turn-navigator-list">
{#each turns as turn (turn)}
<button
type="button"
role="menuitem"
class="row"
class:viewed={turn === viewedTurn}
data-testid={`turn-navigator-item-${turn}`}
onclick={() => void goToTurn(turn)}
>
<span class="label">
{i18n.t("game.shell.turn.list_item", { turn: String(turn) })}
</span>
{#if turn === currentTurn}
<span class="badge" data-testid="turn-navigator-current-badge">
{i18n.t("game.shell.history.current_badge")}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.turn-navigator {
position: relative;
display: inline-flex;
align-items: stretch;
gap: 0.25rem;
}
.step,
.trigger {
font: inherit;
font-size: 1rem;
padding: 0.25rem 0.55rem;
background: transparent;
color: inherit;
border: 1px solid #2a3150;
border-radius: 4px;
cursor: pointer;
line-height: 1;
}
.step:hover:not(:disabled),
.trigger:hover:not(:disabled) {
background: #1c2238;
}
.step:disabled,
.trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.trigger {
min-width: 5rem;
font-weight: 500;
}
.surface {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
min-width: 10rem;
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
background: #14182a;
border: 1px solid #2a3150;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
text-align: left;
font: inherit;
padding: 0.4rem 0.75rem;
background: transparent;
color: inherit;
border: 0;
cursor: pointer;
}
.row:hover {
background: #1c2238;
}
.row.viewed {
font-weight: 600;
background: #1a2040;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
flex: 0 0 auto;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.05rem 0.4rem;
background: #2a3150;
color: #d8def0;
border-radius: 999px;
}
@media (max-width: 767.98px) {
.surface {
position: fixed;
top: 3rem;
right: 0;
left: 0;
min-width: 0;
max-height: calc(100vh - 3rem);
border-radius: 0;
border-left: 0;
border-right: 0;
}
}
</style>