ui/phase-12: order composer skeleton

OrderDraftStore persists per-game command drafts in Cache; the
sidebar Order tab renders the list with a per-row delete control.
The layout passes a `historyMode` prop through Sidebar / BottomTabs
as a constant `false`, so Phase 26 only flips the source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 23:26:58 +02:00
parent e5dab2a43a
commit 460591c159
18 changed files with 1022 additions and 53 deletions
+2 -1
View File
@@ -118,7 +118,8 @@ const en = {
"game.sidebar.tab.order": "order",
"game.sidebar.empty.calculator": "coming soon",
"game.sidebar.empty.inspector": "select an object on the map",
"game.sidebar.empty.order": "coming soon",
"game.sidebar.empty.order": "order is empty",
"game.sidebar.order.command_delete": "delete",
"game.bottom_tabs.map": "map",
"game.bottom_tabs.calc": "calc",
"game.bottom_tabs.order": "order",
+2 -1
View File
@@ -119,7 +119,8 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.tab.order": "приказ",
"game.sidebar.empty.calculator": "скоро будет",
"game.sidebar.empty.inspector": "выберите объект на карте",
"game.sidebar.empty.order": "скоро будет",
"game.sidebar.empty.order": "приказ пуст",
"game.sidebar.order.command_delete": "удалить",
"game.bottom_tabs.map": "карта",
"game.bottom_tabs.calc": "калк",
"game.bottom_tabs.order": "приказ",
+20 -12
View File
@@ -22,8 +22,14 @@ destinations beats the duplication.
gameId: string;
activeTool: MobileTool;
onSelectTool: (tool: MobileTool) => void;
hideOrder?: boolean;
};
let { gameId, activeTool, onSelectTool }: Props = $props();
let {
gameId,
activeTool,
onSelectTool,
hideOrder = false,
}: Props = $props();
let moreOpen = $state(false);
let rootEl: HTMLDivElement | null = $state(null);
@@ -99,17 +105,19 @@ destinations beats the duplication.
<span class="icon" aria-hidden="true">🧮</span>
<span class="label">{i18n.t("game.bottom_tabs.calc")}</span>
</button>
<button
type="button"
role="tab"
data-testid="bottom-tab-order"
aria-selected={activeTool === "order"}
class:active={activeTool === "order"}
onclick={() => selectTool("order")}
>
<span class="icon" aria-hidden="true">📝</span>
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
</button>
{#if !hideOrder}
<button
type="button"
role="tab"
data-testid="bottom-tab-order"
aria-selected={activeTool === "order"}
class:active={activeTool === "order"}
onclick={() => selectTool("order")}
>
<span class="icon" aria-hidden="true">📝</span>
<span class="label">{i18n.t("game.bottom_tabs.order")}</span>
</button>
{/if}
<button
type="button"
data-testid="bottom-tab-more"
+93 -4
View File
@@ -1,14 +1,60 @@
<!--
Phase 10 stub for the Order composer sidebar tool. Phase 12 ships
the composer skeleton; Phase 14 lands the first end-to-end command.
Order composer tool. Resolves the per-game `OrderDraftStore` from
context (set by `routes/games/[id]/+layout.svelte`) and renders the
draft as a vertical, top-to-bottom command list. Empty state shows
the i18n empty-state copy; non-empty state shows an ordered list of
rows, each with a stable `data-testid` plus a per-row delete button.
Phase 12 has no UI for adding commands — Phase 14 lands the first
end-to-end command (`planetRename`) and the inspector affordance
that pushes it into the draft. Tests exercise the skeleton through
`__galaxyDebug.seedOrderDraft` (Playwright) and via direct store
construction (Vitest).
-->
<script lang="ts">
import { getContext } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
function describe(cmd: { kind: string; label?: string }): string {
if (cmd.kind === "placeholder") return cmd.label ?? cmd.kind;
return cmd.kind;
}
</script>
<section class="tool" data-testid="sidebar-tool-order">
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
<p>{i18n.t("game.sidebar.empty.order")}</p>
{#if draft === undefined || draft.commands.length === 0}
<p class="empty" data-testid="order-empty">
{i18n.t("game.sidebar.empty.order")}
</p>
{:else}
<ol class="commands" data-testid="order-list">
{#each draft.commands as cmd, index (cmd.id)}
<li class="command" data-testid="order-command-{index}">
<span class="index" aria-hidden="true">{index + 1}.</span>
<span class="label" data-testid="order-command-label-{index}">
{describe(cmd)}
</span>
<button
type="button"
class="delete"
data-testid="order-command-delete-{index}"
onclick={() => draft?.remove(cmd.id)}
>
{i18n.t("game.sidebar.order.command_delete")}
</button>
</li>
{/each}
</ol>
{/if}
</section>
<style>
@@ -20,8 +66,51 @@ the composer skeleton; Phase 14 lands the first end-to-end command.
margin: 0 0 0.5rem;
font-size: 1rem;
}
.tool p {
.empty {
margin: 0;
color: #888;
}
.commands {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.command {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
background: #14182a;
border: 1px solid #20253a;
border-radius: 4px;
}
.index {
min-width: 1.5rem;
color: #aab;
font-variant-numeric: tabular-nums;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete {
font: inherit;
font-size: 0.85rem;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.delete:hover {
color: #e8eaf6;
border-color: #6d8cff;
}
</style>
+29 -10
View File
@@ -1,14 +1,19 @@
<!--
Phase 10 sidebar with three tabs (Calculator, Inspector, Order). The
parent layout 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/*`.
Sidebar with three tabs (Calculator, Inspector, Order). The parent
layout 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.
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.
-->
<script lang="ts">
import { onMount } from "svelte";
@@ -23,8 +28,9 @@ when later phases want to land directly on a particular tool.
type Props = {
open: boolean;
onClose: () => void;
historyMode?: boolean;
};
let { open, onClose }: Props = $props();
let { open, onClose, historyMode = false }: Props = $props();
let activeTab: SidebarTab = $state("inspector");
@@ -36,11 +42,20 @@ when later phases want to land directly on a particular tool.
return null;
}
$effect(() => {
if (historyMode && activeTab === "order") {
activeTab = "inspector";
}
});
onMount(() => {
const seed = readUrlSeed();
if (seed !== null) {
activeTab = seed;
if (seed === null) return;
if (seed === "order" && historyMode) {
activeTab = "inspector";
return;
}
activeTab = seed;
});
</script>
@@ -51,7 +66,11 @@ when later phases want to land directly on a particular tool.
data-open={open}
>
<div class="head">
<TabBar {activeTab} onSelect={(tab) => (activeTab = tab)} />
<TabBar
{activeTab}
onSelect={(tab) => (activeTab = tab)}
hideOrder={historyMode}
/>
<button
type="button"
class="close"
+16 -6
View File
@@ -1,8 +1,14 @@
<!--
Three-button tab switcher for the Phase 10 sidebar. Each button is
labelled and tagged so component tests can target it; the parent
sidebar component owns the selected-tab state and re-renders the
matching tool panel.
Three-button tab switcher for the sidebar. Each button is labelled
and tagged so component tests can target it; the parent sidebar
component owns the selected-tab state and re-renders the matching
tool panel.
Phase 12 introduces the `hideOrder` prop: when true the Order entry
is filtered out of the tab list. The current consumer is the
`historyMode` flag forwarded from the in-game shell layout — the
flag is constant `false` in Phase 12 and Phase 26's history mode
flips it on.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -11,14 +17,18 @@ matching tool panel.
type Props = {
activeTab: SidebarTab;
onSelect: (tab: SidebarTab) => void;
hideOrder?: boolean;
};
let { activeTab, onSelect }: Props = $props();
let { activeTab, onSelect, hideOrder = false }: Props = $props();
const tabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
const allTabs: ReadonlyArray<{ id: SidebarTab; key: TranslationKey }> = [
{ id: "calculator", key: "game.sidebar.tab.calculator" },
{ id: "inspector", key: "game.sidebar.tab.inspector" },
{ id: "order", key: "game.sidebar.tab.order" },
];
const tabs = $derived(
hideOrder ? allTabs.filter((t) => t.id !== "order") : allTabs,
);
</script>
<div class="tab-bar" role="tablist" data-testid="sidebar-tab-bar">
@@ -6,6 +6,7 @@
setDeviceSessionId,
} from "../../../api/session";
import { loadStore } from "../../../platform/store/index";
import type { OrderCommand } from "../../../sync/order-types";
interface DebugSnapshot {
publicKey: number[];
@@ -22,6 +23,11 @@
message: number[],
signature: number[],
): Promise<boolean>;
seedOrderDraft(
gameId: string,
commands: OrderCommand[],
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
}
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
@@ -96,6 +102,20 @@
throw new Error(`verifyWithStoredPublicKey: ${describe(err)}`);
}
},
async seedOrderDraft(gameId, commands) {
try {
await cache.put("order-drafts", `${gameId}/draft`, commands);
} catch (err) {
throw new Error(`seedOrderDraft: ${describe(err)}`);
}
},
async clearOrderDraft(gameId) {
try {
await cache.delete("order-drafts", `${gameId}/draft`);
} catch (err) {
throw new Error(`clearOrderDraft: ${describe(err)}`);
}
},
};
(window as DebugWindow).__galaxyDebug = surface;
ready = true;
@@ -34,6 +34,10 @@ the next game's snapshot is loaded fresh.
import Order from "$lib/sidebar/order-tab.svelte";
import type { MobileTool } from "$lib/sidebar/types";
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../../sync/order-draft.svelte";
import { session } from "$lib/session-store.svelte";
import { loadStore } from "../../../platform/store/index";
import { loadCore } from "../../../platform/core/index";
@@ -45,6 +49,9 @@ the next game's snapshot is loaded fresh.
let sidebarOpen = $state(false);
let mobileTool: MobileTool = $state("map");
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
// with the real history-mode signal from `lib/history-mode.ts`.
const historyMode = false;
const gameId = $derived(page.params.id ?? "");
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
@@ -54,6 +61,8 @@ the next game's snapshot is loaded fresh.
const gameState = new GameStateStore();
setContext(GAME_STATE_CONTEXT_KEY, gameState);
const orderDraft = new OrderDraftStore();
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
function toggleSidebar(): void {
sidebarOpen = !sidebarOpen;
@@ -85,7 +94,10 @@ the next game's snapshot is loaded fresh.
deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
await gameState.init({ client, cache, gameId });
await Promise.all([
gameState.init({ client, cache, gameId }),
orderDraft.init({ cache, gameId }),
]);
} catch (err) {
gameState.failBootstrap(describeBootstrapError(err));
}
@@ -94,6 +106,7 @@ the next game's snapshot is loaded fresh.
onDestroy(() => {
gameState.dispose();
orderDraft.dispose();
});
function describeBootstrapError(err: unknown): string {
@@ -118,12 +131,17 @@ the next game's snapshot is loaded fresh.
{@render children()}
{/if}
</main>
<Sidebar open={sidebarOpen} onClose={() => (sidebarOpen = false)} />
<Sidebar
open={sidebarOpen}
onClose={() => (sidebarOpen = false)}
{historyMode}
/>
</div>
<BottomTabs
{gameId}
activeTool={effectiveTool}
onSelectTool={(tool) => (mobileTool = tool)}
hideOrder={historyMode}
/>
</div>
+125
View File
@@ -0,0 +1,125 @@
// Per-game runes store that owns the local order draft. Mirrors the
// Phase 11 `GameStateStore` lifecycle: one instance per game, created
// in `routes/games/[id]/+layout.svelte`, exposed to descendants via
// Svelte context, disposed when the layout unmounts.
//
// Draft state is persisted into the platform `Cache` under the
// `order-drafts` namespace with a per-game key, so a reload, a
// browser restart, or a navigation through the lobby and back into
// the same game restores the previously composed list. Phase 14
// will add the submit pipeline that drains the draft to the server;
// Phase 26 will hide the order tab in history mode through a flag
// passed by the layout (the store itself remains alive across that
// transition so the draft survives history-mode round-trips).
//
// The store deliberately carries no Svelte component imports so it
// can be tested directly with a synthetic `Cache` without rendering
// any UI.
import type { Cache } from "../platform/store/index";
import type { OrderCommand } from "./order-types";
const NAMESPACE = "order-drafts";
const draftKey = (gameId: string): string => `${gameId}/draft`;
/**
* ORDER_DRAFT_CONTEXT_KEY is the Svelte context key the in-game shell
* layout uses to expose its `OrderDraftStore` instance to descendants.
* The order tab and any later command-builder UI resolve the store via
* `getContext(ORDER_DRAFT_CONTEXT_KEY)`.
*/
export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
type Status = "idle" | "ready" | "error";
export class OrderDraftStore {
commands: OrderCommand[] = $state([]);
status: Status = $state("idle");
error: string | null = $state(null);
private cache: Cache | null = null;
private gameId = "";
private destroyed = false;
/**
* init loads the persisted draft for `opts.gameId` from `opts.cache`
* into `commands` and flips `status` to `ready`. The call is
* idempotent on the same store instance — the layout always
* constructs a fresh store per game, so there is no need to support
* mid-life game switching here.
*/
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
this.cache = opts.cache;
this.gameId = opts.gameId;
try {
const stored = await opts.cache.get<OrderCommand[]>(
NAMESPACE,
draftKey(opts.gameId),
);
if (this.destroyed) return;
this.commands = Array.isArray(stored) ? [...stored] : [];
this.status = "ready";
} catch (err) {
if (this.destroyed) return;
this.status = "error";
this.error = err instanceof Error ? err.message : "load failed";
}
}
/**
* add appends a command to the end of the draft and persists the
* updated list. Mutations made before `init` resolves are ignored —
* the layout always awaits `init` before exposing the store.
*/
async add(command: OrderCommand): Promise<void> {
if (this.status !== "ready") return;
this.commands = [...this.commands, command];
await this.persist();
}
/**
* remove drops the command with the given id from the draft and
* persists the result. A miss is a no-op.
*/
async remove(id: string): Promise<void> {
if (this.status !== "ready") return;
const next = this.commands.filter((cmd) => cmd.id !== id);
if (next.length === this.commands.length) return;
this.commands = next;
await this.persist();
}
/**
* move relocates the command at `fromIndex` to `toIndex`, shifting
* the intermediate commands. Out-of-range indices and identical
* positions are no-ops; both indices are clamped against the
* current `commands` length.
*/
async move(fromIndex: number, toIndex: number): Promise<void> {
if (this.status !== "ready") return;
const length = this.commands.length;
if (fromIndex < 0 || fromIndex >= length) return;
if (toIndex < 0 || toIndex >= length) return;
if (fromIndex === toIndex) return;
const next = [...this.commands];
const [picked] = next.splice(fromIndex, 1);
if (picked === undefined) return;
next.splice(toIndex, 0, picked);
this.commands = next;
await this.persist();
}
dispose(): void {
this.destroyed = true;
this.cache = null;
}
private async persist(): Promise<void> {
if (this.cache === null || this.destroyed) return;
// `commands` is `$state`, so individual entries are proxies.
// IndexedDB's structured clone refuses to clone proxies, so the
// snapshot must be taken before the put.
const snapshot = $state.snapshot(this.commands) as OrderCommand[];
await this.cache.put(NAMESPACE, draftKey(this.gameId), snapshot);
}
}
+59
View File
@@ -0,0 +1,59 @@
// Typed shape of a single command entry inside the local order
// draft. Phase 12 intentionally ships exactly one variant
// (`placeholder`) — Phase 14 lands the first real command
// (`planetRename`) together with the inspector UI that constructs
// it and the submit pipeline that drains the draft to the server.
//
// `OrderCommand` is a discriminated union on the `kind` field so
// later variants can extend the union without changing the array
// shape persisted in `Cache`. The whole draft round-trips through
// IndexedDB structured clone, so every variant must use only
// JSON-friendly value types (`string`, `number`, `boolean`,
// nested plain objects, and `Uint8Array`).
/**
* PlaceholderCommand is the single variant shipped with the Phase 12
* skeleton. It carries a stable `id` (used by remove and as a
* `data-testid` suffix) and a human-readable `label` rendered in the
* order tab's vertical list. The variant is deliberately content-free
* so test fixtures and the empty composer skeleton do not pre-bias
* Phase 14's first real command shape.
*/
export interface PlaceholderCommand {
readonly kind: "placeholder";
readonly id: string;
readonly label: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
* narrowing on it enables exhaustive `switch` statements at every
* call site. Phase 14 will widen the union with `planetRename`.
*/
export type OrderCommand = PlaceholderCommand;
/**
* CommandStatus is the lifecycle of a single command from the moment
* it lands in the draft to the moment the server resolves it. The
* skeleton stores only the type description; Phase 14 adds the
* `valid` / `invalid` transitions driven by local validation, and
* Phase 25 introduces `submitting` / `applied` / `rejected` driven
* by the submit pipeline.
*
* The state machine is:
*
* draft → valid → submitting → applied
* ↘ invalid ↘ rejected
*
* A command is `draft` until local validation has run, then `valid`
* or `invalid`. On submit the entry transitions to `submitting`,
* then to `applied` or `rejected` once the gateway responds.
*/
export type CommandStatus =
| "draft"
| "valid"
| "invalid"
| "submitting"
| "applied"
| "rejected";