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:
@@ -61,6 +61,20 @@ on mount and seeds the initial tab. Later phases that want to land
|
||||
the user on a particular tool (for example, Phase 14's first
|
||||
end-to-end command flow) can set it on navigation.
|
||||
|
||||
The Order entry is hidden when the layout's `historyMode` flag is
|
||||
true. Phase 12 plumbs the flag end-to-end as a prop —
|
||||
`+layout.svelte` passes a constant `false` to `Sidebar`, which
|
||||
forwards `hideOrder` to its `TabBar`; the same flag goes to
|
||||
`BottomTabs` so the mobile `Order` button is also suppressed. A
|
||||
`?sidebar=order` URL seed that arrives while the flag is true falls
|
||||
back to `inspector`, and an `$effect` on the sidebar resets
|
||||
`activeTab` away from `order` if the flag flips on mid-session.
|
||||
Phase 26 introduces `lib/history-mode.ts` and replaces the constant
|
||||
with the live signal; the order draft survives the toggle because
|
||||
`OrderDraftStore` lives one level above the sidebar in the layout
|
||||
hierarchy. See [`order-composer.md`](order-composer.md) for the
|
||||
draft-store side of the flow.
|
||||
|
||||
## Layout breakpoints
|
||||
|
||||
Three discrete CSS modes matched to the IA section diagrams:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Order composer
|
||||
|
||||
This doc covers the local order draft: the per-game in-memory list
|
||||
of commands the player has composed but not yet submitted, the
|
||||
`Cache`-backed persistence that survives reloads, and the
|
||||
`historyMode` flag that hides the composer when the user is browsing
|
||||
past turns. The user-facing spec — the IA section's sidebar
|
||||
description, the `Order` tab placement, the per-command status
|
||||
display — lives in [`../PLAN.md`](../PLAN.md), section
|
||||
`Information Architecture and Navigation`. This doc is the source of
|
||||
truth for how those rules are implemented.
|
||||
|
||||
## Draft replaces server order
|
||||
|
||||
The client's view of "the player's intent for the next turn" lives
|
||||
entirely in the local draft. The gateway exposes a turn-scoped
|
||||
`user.games.order` endpoint that the submit pipeline drains the
|
||||
draft into; until the player explicitly submits, the server has no
|
||||
view of pending commands. The composer never reads back the
|
||||
server's idea of an order — it reads its own draft and renders it.
|
||||
|
||||
The motivation is operational: a draft can be edited, reordered,
|
||||
or partially removed without round-trips, and a half-composed order
|
||||
during a connectivity hiccup keeps every line the player typed. A
|
||||
remote-first composer that reflects the gateway's pending-orders
|
||||
queue would force a sync on every keystroke.
|
||||
|
||||
When the submit pipeline lands (Phase 25), it iterates the draft
|
||||
in order, sending one `command` per line. The gateway's per-line
|
||||
result rejoins the draft entry through `cmdId`, and the entry's
|
||||
`CommandStatus` flips to `applied` or `rejected`. Successfully
|
||||
applied entries stay visible until the next turn cutoff so the
|
||||
player can see what was committed; rejected entries stay until the
|
||||
player edits or removes them.
|
||||
|
||||
## Local-validation invariant
|
||||
|
||||
Every command in the draft is *locally valid as of submission time*.
|
||||
A command may live in the draft as `draft` (not yet validated) or
|
||||
`valid`; `invalid` is allowed during composition but the submit
|
||||
pipeline refuses to drain a draft that contains any `invalid`
|
||||
entries. The validation step is per-command and pure — it consults
|
||||
the current `GameStateStore` snapshot only, never the network.
|
||||
|
||||
Phase 12 ships the skeleton without any concrete validators: the
|
||||
single `placeholder` variant is content-free and stays at `draft`
|
||||
forever. Phase 14's `planetRename` is the first variant that
|
||||
exercises the `draft → valid | invalid` transition.
|
||||
|
||||
## Command status state machine
|
||||
|
||||
```text
|
||||
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
|
||||
╲ │ ╲
|
||||
╲──validate──▶ invalid ╲──nack──▶ rejected
|
||||
```
|
||||
|
||||
Transitions:
|
||||
|
||||
- **`draft → valid` / `draft → invalid`**: local validation. May
|
||||
re-run when the underlying `GameStateStore` snapshot changes
|
||||
(Phase 14+).
|
||||
- **`valid → submitting`**: the submit pipeline picks the entry off
|
||||
the draft and sends it to the gateway.
|
||||
- **`submitting → applied` / `submitting → rejected`**: the gateway
|
||||
responded; the entry is no longer in flight.
|
||||
|
||||
Phase 12 stores the type but does not implement any transitions.
|
||||
Every entry remains at `draft` until later phases land the
|
||||
validators (Phase 14) and the submit pipeline (Phase 25).
|
||||
|
||||
## Discriminated union shape
|
||||
|
||||
`OrderCommand` is a discriminated union on the `kind` field. Phase
|
||||
12 ships a single variant:
|
||||
|
||||
```ts
|
||||
interface PlaceholderCommand {
|
||||
readonly kind: "placeholder";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
}
|
||||
type OrderCommand = PlaceholderCommand;
|
||||
```
|
||||
|
||||
The `id` field is the canonical identifier the store uses for
|
||||
remove and reorder; later variants must keep `id: string` so the
|
||||
store API stays uniform. The whole draft round-trips through
|
||||
IndexedDB structured clone, so every variant must use only
|
||||
JSON-friendly value types. Phase 14 adds the first real variant
|
||||
(`planetRename`) and updates this list.
|
||||
|
||||
## Store
|
||||
|
||||
`OrderDraftStore` lives in
|
||||
[`../frontend/src/sync/order-draft.svelte.ts`](../frontend/src/sync/order-draft.svelte.ts).
|
||||
The class is a Svelte 5 runes store, so the file extension is
|
||||
`.svelte.ts` (the original PLAN.md artifact line listed `.ts` —
|
||||
the deviation is documented inline in `PLAN.md`'s Phase 12
|
||||
"Decisions" subsection).
|
||||
|
||||
Lifecycle:
|
||||
|
||||
| Method | Effect |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `init({ cache, gameId })` | Reads the persisted draft from `cache`; sets `commands` and flips `status` to `ready`. Errors flow into `status = "error"`. |
|
||||
| `add(cmd)` | Appends to the end and persists. |
|
||||
| `remove(id)` | Drops the matching entry and persists. A miss is a no-op. |
|
||||
| `move(fromIndex, toIndex)` | Reorders within the list and persists. Out-of-range and identical indices are no-ops. |
|
||||
| `dispose()` | Marks the store destroyed; subsequent `persist()` calls are no-ops so a fast game-switch does not write stale state into the next id. |
|
||||
|
||||
Mutations made before `init` resolves are silently ignored — the
|
||||
layout always awaits `init` through `Promise.all([...])` next to
|
||||
`gameState.init` before exposing the store.
|
||||
|
||||
Layout integration mirrors `GameStateStore`:
|
||||
|
||||
- One instance per game, created in
|
||||
[`../frontend/src/routes/games/[id]/+layout.svelte`](../frontend/src/routes/games/[id]/+layout.svelte).
|
||||
- Exposed through the `ORDER_DRAFT_CONTEXT_KEY` Svelte context.
|
||||
- Disposed in the layout's `onDestroy`.
|
||||
|
||||
The order tab consumes the store via
|
||||
`getContext(ORDER_DRAFT_CONTEXT_KEY)`; Phase 14's planet inspector
|
||||
will use the same key to push a new command.
|
||||
|
||||
## Persistence
|
||||
|
||||
Cache row layout:
|
||||
|
||||
| Namespace | Key | Value type |
|
||||
| -------------- | ------------------ | ---------------- |
|
||||
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` |
|
||||
|
||||
The store writes the full draft on every mutation. Phase 25 may
|
||||
profile the submit pipeline and batch into a microtask if write
|
||||
amplification becomes a problem; until then the deterministic
|
||||
write-on-every-mutation model is what tests assert and what the
|
||||
layout relies on for crash safety.
|
||||
|
||||
The namespace is registered in [`storage.md`](storage.md). Cache
|
||||
implementation details live there; this doc only describes how the
|
||||
order composer uses the namespace.
|
||||
|
||||
## History mode wiring
|
||||
|
||||
Phase 26 introduces a global history-mode flag. The IA section
|
||||
specifies that the Order tab is hidden when history mode is active —
|
||||
the player is browsing a past turn snapshot, and composing commands
|
||||
against an immutable snapshot would be confusing.
|
||||
|
||||
Phase 12 wires the flag end-to-end as a prop. The layout owns the
|
||||
flag (a constant `false` until Phase 26) and passes it to:
|
||||
|
||||
- `Sidebar` as `historyMode`. The sidebar forwards it to its
|
||||
`TabBar` as `hideOrder`. The Order entry is filtered out of the
|
||||
tab list when true. If a `?sidebar=order` URL seed lands while
|
||||
the flag is true, the sidebar falls back to `inspector`. If the
|
||||
active tab is `order` when the flag flips on, an effect resets
|
||||
it to `inspector`.
|
||||
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
|
||||
button is suppressed when true.
|
||||
|
||||
The store itself stays alive across history-mode round-trips so
|
||||
the draft survives. Phase 26 will replace the constant with the
|
||||
real signal from `lib/history-mode.ts` and exercise the toggle in
|
||||
its own test suite.
|
||||
|
||||
## Testing
|
||||
|
||||
Two test artifacts cover the skeleton:
|
||||
|
||||
- [`../frontend/tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts)
|
||||
— Vitest unit tests for the store. Drives `OrderDraftStore`
|
||||
directly with `IDBCache` over `fake-indexeddb`. Covers init,
|
||||
add, remove, move, per-game isolation, mutations-before-init,
|
||||
and dispose hygiene.
|
||||
- [`../frontend/tests/e2e/order-composer.spec.ts`](../frontend/tests/e2e/order-composer.spec.ts)
|
||||
— Playwright spec. Seeds three commands through
|
||||
`__galaxyDebug.seedOrderDraft`, navigates into
|
||||
`/games/<id>/map`, opens the Order tool (sidebar tab on
|
||||
desktop, bottom tab on mobile), asserts the rows, reloads, and
|
||||
asserts the rows again.
|
||||
|
||||
The `__galaxyDebug.seedOrderDraft(gameId, commands)` and
|
||||
`__galaxyDebug.clearOrderDraft(gameId)` helpers in
|
||||
[`../frontend/src/routes/__debug/store/+page.svelte`](../frontend/src/routes/__debug/store/+page.svelte)
|
||||
write directly to the cache namespace, so seeding is independent
|
||||
of any mounted store.
|
||||
+9
-7
@@ -112,14 +112,16 @@ wipes every namespace.
|
||||
|
||||
Namespaces in current use:
|
||||
|
||||
| Namespace | Key | Value type | Owner |
|
||||
|-----------|----------------------|------------|-------------|
|
||||
| `session` | `device-session-id` | `string` | Phase 7+ |
|
||||
| Namespace | Key | Value type | Owner |
|
||||
|-----------------|---------------------|------------------|-----------------------------|
|
||||
| `session` | `device-session-id` | `string` | Phase 7+ |
|
||||
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
|
||||
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
|
||||
|
||||
Phase 8 onwards will add per-feature namespaces (lobby snapshot,
|
||||
game state, fixtures, etc.). The contract is namespace-strings
|
||||
stay scoped to one feature; cross-feature reads through the cache
|
||||
are by convention disallowed.
|
||||
Later phases will add more per-feature namespaces (fixtures, lobby
|
||||
snapshot, etc.). The contract is namespace-strings stay scoped to
|
||||
one feature; cross-feature reads through the cache are by convention
|
||||
disallowed.
|
||||
|
||||
## Keystore lifecycle
|
||||
|
||||
|
||||
Reference in New Issue
Block a user