ui/phase-14: auto-sync order draft + always GET on boot + header headline

Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.

`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).

Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.

Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
  rejected response surfaces error sync status, rapid mutations
  coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
  remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
  in the optimistic projection
- header: live race / game / turn rendering with fall-back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 13:34:10 +02:00
parent 68d8607eaa
commit 229c43beb5
26 changed files with 1144 additions and 728 deletions
+47 -10
View File
@@ -1,16 +1,25 @@
<!--
Top header for the in-game shell. Composes the four artifacts called
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn
counter (static placeholder), view dropdown / hamburger, account
menu. The sidebar-toggle slot to its left appears only on tablet
viewports (7681024 px) and is wired by `+layout.svelte`.
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`.
The race name is read from the engine's `Report.race`, the game
name from the lobby's `GameSummary.gameName`. While either piece
is missing (boot, network error) we fall back to the
`game.shell.unknown` placeholder so the header chrome keeps its
shape.
The connection-state indicator from the IA section is intentionally
absent until Phase 24 wires push-event state.
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import TurnCounter from "./turn-counter.svelte";
import {
GAME_STATE_CONTEXT_KEY,
type GameStateStore,
} from "$lib/game-state.svelte";
import ViewMenu from "./view-menu.svelte";
import AccountMenu from "./account-menu.svelte";
@@ -20,14 +29,42 @@ absent until Phase 24 wires push-event state.
onToggleSidebar: () => void;
};
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
const gameState = getContext<GameStateStore | undefined>(
GAME_STATE_CONTEXT_KEY,
);
const raceName = $derived.by(() => {
const name = gameState?.report?.race;
return name === undefined || name === ""
? i18n.t("game.shell.unknown")
: name;
});
const gameName = $derived.by(() => {
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="race" data-testid="race-name">
{i18n.t("game.shell.race_placeholder")}
<span class="headline" data-testid="game-shell-headline">
{headline}
</span>
<TurnCounter />
</div>
<div class="right">
<button
@@ -69,7 +106,7 @@ absent until Phase 24 wires push-event state.
gap: 0.75rem;
min-width: 0;
}
.race {
.headline {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -1,37 +0,0 @@
<!--
Phase 11 turn counter: reads the live turn number from the per-game
`GameStateStore` provided through context by
`routes/games/[id]/+layout.svelte`. Renders the static `?` placeholder
from `game.shell.turn_unknown` when the store has not yet produced a
report (boot, network error, no membership) so the header chrome
keeps its width across loading transitions.
Phase 26 will turn this into a clickable trigger that opens the
turn navigator; Phase 24 wires push-event-driven turn-ready toasts
that may flash this counter when a new turn is ready.
-->
<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 store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
const display = $derived.by(() => {
const report = store?.report ?? null;
if (report === null) return i18n.t("game.shell.turn_unknown");
return String(report.turn);
});
</script>
<span class="turn" data-testid="turn-counter" data-turn={display}>
{i18n.t("game.shell.turn_label")}&nbsp;{display}
</span>
<style>
.turn {
font-size: 0.95rem;
color: #ddd;
white-space: nowrap;
}
</style>