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
+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";