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