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
+123 -22
View File
@@ -1381,9 +1381,13 @@ Decisions taken with the project owner during implementation:
6. **`historyMode` as a prop, not a module.** Layout passes
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and
`BottomTabs`; both forward to their tab-bar children which omit
the order entry when the flag is true. Phase 26 introduces the
real `lib/history-mode.ts` module and replaces the constant in
one place.
the order entry when the flag is true. Phase 26 superseded the
"introduce `lib/history-mode.ts`" half of this decision: the
single derivation `historyMode = $derived(gameState.historyMode)`
lives directly in `+layout.svelte`, the rune split between
`currentTurn` and `viewedTurn` lives in `GameStateStore`, and
no separate module is introduced. See Phase 26 decisions for
the rationale.
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
`coming soon` placeholder text is replaced; per-row delete
button reads `delete` / `удалить`.
@@ -2805,24 +2809,116 @@ Targeted tests:
## Phase 26. History Mode
Status: pending.
Status: pending (awaiting local-ci verification).
Goal: let the user navigate to past turns and view all data as it was,
with no order composition allowed.
Artifacts:
Decisions baked in during implementation:
- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn
counter expansion: popover (desktop) / bottom-sheet (mobile) listing
recent turns and a search field for jumping to a turn number
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every
view's data source: when active, all `state-binding`, table, report,
inspector, and map sources read from the historical snapshot for the
selected turn
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner
reading `Viewing turn N · read-only` with a `Return to current turn`
action
- order tab hidden in history mode (already prepared in Phase 12)
1. **History state lives in `GameStateStore`, no separate module.**
The Phase 12 plan-line "introduce `lib/history-mode.ts`" is
superseded: the only consumer needs a one-line derivation
(`historyMode = $derived(gameState.historyMode)`), and the
project's compactness rule rejects an abstraction with no second
caller. The store ships two distinct turn runes — `currentTurn`
(server's authoritative latest, set by `setGame` /
`advanceToPending`) and `viewedTurn` (what the UI displays, set
by `viewTurn` / `returnToCurrent`) — plus the derived
`historyMode` rune that flips when `viewedTurn < currentTurn`.
2. **`OrderDraftStore` gates mutations at one chokepoint.**
`bindClient` gains an optional `getHistoryMode: () => boolean`
alongside the existing `getCurrentTurn`; `add` / `remove` /
`move` return early when it reports `true`. Every Phase 1422
inspector that calls `orderDraft.add(...)` becomes inert in
history mode without per-component edits.
3. **Turn navigator UX.** Header replaces the static `turn N` text
with `← turn N →`: arrows step ±1 (disabled at `0` and
`currentTurn`), the middle button opens a dropdown of every
turn `Turn #0`…`Turn #currentTurn` with the current row carrying
a badge. No free-text input. Desktop uses an absolute popover
under the header; mobile reuses `view-menu.svelte`'s fixed-
drawer pattern (no new primitive). Selecting the current row
routes through `returnToCurrent()` so the "leave history" path
has one canonical entry.
4. **History is ephemeral across reloads.** `last-viewed-turn` is
written only when `viewedTurn === currentTurn`; historical
excursions never advance the resume bookmark. Page reload exits
history mode. The visibility-refresh listener is a no-op while
`historyMode` is true so a tab-focus event cannot silently kick
the user back onto the live turn. Push events (Phase 24) continue
to deliver new-turn notifications, so the pending-turn toast
still appears.
5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}`
namespace stores past-turn reports; `viewTurn(N)` reads cache
first and falls back to the network on miss. Past turns are
immutable so the cache has no TTL and no eviction. The current
turn deliberately skips the cache (it is mutable until the next
tick).
6. **Order overlay short-circuits in history mode.**
`RenderedReportSource.report` returns the raw server snapshot
instead of running `applyOrderOverlay`: the draft is composed
against the current turn, projecting it onto a past report would
render fictional intent.
7. **`game.shell.headline` removed.** The Phase 11 i18n key that
formatted `{race} @ {game}, turn {turn}` is deleted; the header
composes `race @ game` in plain text and delegates `turn N` to
`turn-navigator.svelte`. The existing `game-shell-headline`
testid moves to the `.left` wrapper so e2e specs that match
`toContainText("turn N")` continue to find the substring inside
the navigator's button.
Artifacts (delivered):
- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune,
derived `historyMode` rune, `viewTurn(turn)` /
`returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })`
refactor that gates `last-viewed-turn` writes, `readReport` cache
layer over the `game-history` namespace, visibility-refresh
short-circuit in history mode, `initSynthetic` keeps
`currentTurn === viewedTurn`.
- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts
`getHistoryMode`, `add` / `remove` / `move` no-op when active.
- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short-
circuit when `gameState.historyMode === true`.
- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header
triplet `← turn N →` + dropdown popover / drawer, reuses
`view-menu.svelte`'s outside-click / Escape pattern.
- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky
read-only banner under the header with a `Return to current turn`
action.
- `ui/frontend/src/lib/header/header.svelte` — embeds
`<TurnNavigator />` next to the race-and-game identity span;
drops the static turn portion.
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
`historyMode` derived rune, `getHistoryMode` passed to
`orderDraft.bindClient`, `<HistoryBanner />` mounted between
header and body.
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
`game.shell.history.*` and `game.shell.turn.*` keys; the now-
unused `game.shell.headline` entry is removed.
- `ui/docs/storage.md` — `game-history` namespace row; also adds
the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc
gap).
- `ui/docs/game-state.md` — current/viewed-turn rune table, the
new History mode section.
- `ui/docs/navigation.md` — describes the navigator, the read-only
banner, and the `historyMode` derivation wiring.
- `ui/docs/order-composer.md` — notes the mutation gate, the
overlay short-circuit, and the cross-doc references.
- Vitest: `tests/game-state.test.ts` extended with `viewTurn` /
`returnToCurrent` / `historyMode` derivation / cache hit /
visibility-refresh short-circuit / resume-from-stale-bookmark
flips; `tests/order-draft.test.ts` extended with the history-
mode gate cases; `tests/turn-navigator.test.ts` and
`tests/history-banner.test.ts` (new) cover the components in
isolation.
- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the
full chrome flow against `/games/<id>/table/planets`. The map
view is deliberately avoided because the Pixi renderer can
monopolise the headless Chromium main thread long enough to let
the `toContainText` poll find stale "turn ?" content; the table
view exercises the same wiring without that rendering tail.
Dependencies: Phases 11, 12, 23.
@@ -2834,15 +2930,20 @@ Acceptance criteria:
available;
- returning to the current turn restores live data and re-shows the
order tab with the prior draft intact (state preservation);
- all UI views (map, tables, report, battle, mail) work in history
mode.
- battle / mail stub views still render correctly while the
read-only banner is visible (Phases 27 / 28 will replace the
stubs with real implementations; the wiring is sufficient
today).
Targeted tests:
- Vitest unit tests for `history-mode` toggle and per-view source
selection;
- Playwright e2e: enter history mode, navigate three views, return,
confirm the order draft survived.
- Vitest unit tests for current/viewed turn rune split, view-turn
cache behaviour, visibility-refresh short-circuit, order-draft
history-mode gate, turn-navigator interactions, history-banner
rendering / action;
- Playwright e2e: enter history mode via arrow, navigate via
dropdown, return via banner action, confirm the order draft
survives the round-trip.
## Phase 27. Battle Viewer