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);
}
}