2294d8b3d9
stabilise report-sections e2e Owner review on PR #58: - shrink the order-card body to 0.8rem (matching the calculator's body text scale) so the order list reads as part of the sidebar's density, not its own larger surface; - shrink the delete ✕ to 0.95rem and glue it flush to the card's top-right corner (no offset, sized to fit the corner padding-space); - tighten the card padding to match the smaller text. Independently — the same review asked to fix `report-sections › every TOC anchor lands its section in view`, which had been a long-standing e2e flake (run #366 on `development` already failed it twice before passing on retry; my PR's run #367 simply exhausted all five retries). The root cause is the smooth `scrollIntoView` settling slower than Playwright's 5 s viewport wait under heavy CI load. The production TOC already honours `prefers-reduced-motion: reduce` and swaps to an instant scroll there; switching the Playwright config to that media mode makes every spec deterministic without touching production code.
399 lines
11 KiB
Svelte
399 lines
11 KiB
Svelte
<!--
|
|
Order composer tool. Resolves the per-game `OrderDraftStore` from
|
|
context (set by `routes/games/[id]/+layout.svelte`) and renders the
|
|
local draft as a vertical list with per-row status and a delete
|
|
button.
|
|
|
|
Phase 14 wires the auto-sync pipeline directly into the draft
|
|
store: every successful `add` / `remove` / `move` triggers a
|
|
`submitOrder` call so the server always mirrors the local draft.
|
|
Per-command status (`valid`, `submitting`, `applied`, `rejected`,
|
|
`conflict`, `invalid`) is conveyed by the card's background tint
|
|
(`status-{status}` class → `--color-{success,danger,warning}-subtle`),
|
|
while the textual status name stays in the DOM as an `sr-only`
|
|
node so screen readers and the existing tests still observe it.
|
|
A small status bar at the bottom surfaces the latest sync result.
|
|
The earlier explicit Submit button is gone — there is no separate
|
|
"send" step anymore.
|
|
|
|
Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
|
(Playwright) and via direct store / mocked-client construction
|
|
(Vitest).
|
|
-->
|
|
<script lang="ts">
|
|
import { getContext } from "svelte";
|
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
|
import { productionDisplayFromCommand } from "../../api/game-state";
|
|
import {
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
OrderDraftStore,
|
|
} from "../../sync/order-draft.svelte";
|
|
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
|
|
|
|
const draft = getContext<OrderDraftStore | undefined>(
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
);
|
|
|
|
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
|
|
draft: "game.sidebar.order.status.draft",
|
|
valid: "game.sidebar.order.status.valid",
|
|
invalid: "game.sidebar.order.status.invalid",
|
|
submitting: "game.sidebar.order.status.submitting",
|
|
applied: "game.sidebar.order.status.applied",
|
|
rejected: "game.sidebar.order.status.rejected",
|
|
conflict: "game.sidebar.order.status.conflict",
|
|
};
|
|
|
|
function describe(cmd: OrderCommand): string {
|
|
switch (cmd.kind) {
|
|
case "placeholder":
|
|
return i18n.t("game.sidebar.order.label.placeholder", {
|
|
label: cmd.label,
|
|
});
|
|
case "planetRename":
|
|
return i18n.t("game.sidebar.order.label.planet_rename", {
|
|
planet: String(cmd.planetNumber),
|
|
name: cmd.name,
|
|
});
|
|
case "setProductionType":
|
|
return i18n.t("game.sidebar.order.label.planet_production", {
|
|
planet: String(cmd.planetNumber),
|
|
target: productionDisplayFromCommand(
|
|
cmd.productionType,
|
|
cmd.subject,
|
|
),
|
|
});
|
|
case "setCargoRoute":
|
|
return i18n.t("game.sidebar.order.label.cargo_route_set", {
|
|
loadType: cmd.loadType,
|
|
source: String(cmd.sourcePlanetNumber),
|
|
destination: String(cmd.destinationPlanetNumber),
|
|
});
|
|
case "removeCargoRoute":
|
|
return i18n.t("game.sidebar.order.label.cargo_route_remove", {
|
|
loadType: cmd.loadType,
|
|
source: String(cmd.sourcePlanetNumber),
|
|
});
|
|
case "createShipClass":
|
|
return i18n.t("game.sidebar.order.label.ship_class_create", {
|
|
name: cmd.name,
|
|
});
|
|
case "removeShipClass":
|
|
return i18n.t("game.sidebar.order.label.ship_class_remove", {
|
|
name: cmd.name,
|
|
});
|
|
case "createScience":
|
|
return i18n.t("game.sidebar.order.label.science_create", {
|
|
name: cmd.name,
|
|
});
|
|
case "removeScience":
|
|
return i18n.t("game.sidebar.order.label.science_remove", {
|
|
name: cmd.name,
|
|
});
|
|
case "breakShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_break", {
|
|
group: shortGroupId(cmd.groupId),
|
|
quantity: String(cmd.quantity),
|
|
});
|
|
case "sendShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_send", {
|
|
group: shortGroupId(cmd.groupId),
|
|
destination: String(cmd.destinationPlanetNumber),
|
|
});
|
|
case "loadShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_load", {
|
|
group: shortGroupId(cmd.groupId),
|
|
cargo: cmd.cargo,
|
|
quantity: String(cmd.quantity),
|
|
});
|
|
case "unloadShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_unload", {
|
|
group: shortGroupId(cmd.groupId),
|
|
quantity: String(cmd.quantity),
|
|
});
|
|
case "upgradeShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_upgrade", {
|
|
group: shortGroupId(cmd.groupId),
|
|
tech: cmd.tech,
|
|
level: String(cmd.level),
|
|
});
|
|
case "dismantleShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_dismantle", {
|
|
group: shortGroupId(cmd.groupId),
|
|
});
|
|
case "transferShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_transfer", {
|
|
group: shortGroupId(cmd.groupId),
|
|
acceptor: cmd.acceptor,
|
|
});
|
|
case "joinFleetShipGroup":
|
|
return i18n.t("game.sidebar.order.label.ship_group_join_fleet", {
|
|
group: shortGroupId(cmd.groupId),
|
|
fleet: cmd.name,
|
|
});
|
|
case "setDiplomaticStance":
|
|
return i18n.t("game.sidebar.order.label.race_relation", {
|
|
relation: cmd.relation,
|
|
acceptor: cmd.acceptor,
|
|
});
|
|
case "setVoteRecipient":
|
|
return i18n.t("game.sidebar.order.label.race_vote", {
|
|
acceptor: cmd.acceptor,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Short identifier for the order-tab so the human-readable label
|
|
// stays glanceable; the full UUID is still in the underlying
|
|
// command and visible in the inspector overlay.
|
|
function shortGroupId(uuid: string): string {
|
|
return uuid.length > 8 ? uuid.slice(0, 8) : uuid;
|
|
}
|
|
|
|
function statusOf(cmd: OrderCommand): CommandStatus {
|
|
return draft?.statuses[cmd.id] ?? "draft";
|
|
}
|
|
</script>
|
|
|
|
<section class="tool" data-testid="sidebar-tool-order">
|
|
<h3>{i18n.t("game.sidebar.tab.order")}</h3>
|
|
{#if draft !== undefined && draft.pausedBanner !== null}
|
|
<div
|
|
class="banner banner-paused"
|
|
data-testid="order-paused-banner"
|
|
data-paused-reason={draft.pausedBanner.reason}
|
|
role="status"
|
|
>
|
|
{i18n.t("game.sidebar.order.paused.banner")}
|
|
</div>
|
|
{/if}
|
|
{#if draft !== undefined && draft.conflictBanner !== null}
|
|
<div
|
|
class="banner banner-conflict"
|
|
data-testid="order-conflict-banner"
|
|
data-conflict-turn={draft.conflictBanner.turn ?? ""}
|
|
role="status"
|
|
>
|
|
{#if draft.conflictBanner.turn !== null}
|
|
{i18n.t("game.sidebar.order.conflict.banner", {
|
|
turn: String(draft.conflictBanner.turn),
|
|
})}
|
|
{:else}
|
|
{i18n.t("game.sidebar.order.conflict.banner_no_turn")}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{#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)}
|
|
{@const status = statusOf(cmd)}
|
|
<li
|
|
class="command status-{status}"
|
|
data-testid="order-command-{index}"
|
|
data-command-status={status}
|
|
>
|
|
<span class="index" aria-hidden="true">{index + 1}.</span>
|
|
<span class="label" data-testid="order-command-label-{index}">
|
|
{describe(cmd)}
|
|
</span>
|
|
<span
|
|
class="sr-only"
|
|
data-testid="order-command-status-{index}"
|
|
>
|
|
{i18n.t(statusKeyMap[status])}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
class="delete"
|
|
data-testid="order-command-delete-{index}"
|
|
aria-label={i18n.t("game.sidebar.order.command_delete")}
|
|
title={i18n.t("game.sidebar.order.command_delete")}
|
|
onclick={() => draft?.remove(cmd.id)}
|
|
>
|
|
<span aria-hidden="true">✕</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
{/if}
|
|
{#if draft !== undefined}
|
|
<div
|
|
class="sync sync-{draft.syncStatus}"
|
|
data-testid="order-sync"
|
|
data-sync-status={draft.syncStatus}
|
|
>
|
|
<span class="sync-label">
|
|
{#if draft.syncStatus === "syncing"}
|
|
{i18n.t("game.sidebar.order.sync.in_flight")}
|
|
{:else if draft.syncStatus === "synced"}
|
|
{i18n.t("game.sidebar.order.sync.synced")}
|
|
{:else if draft.syncStatus === "error"}
|
|
{i18n.t("game.sidebar.order.sync.error", {
|
|
message: draft.syncError ?? "",
|
|
})}
|
|
{:else if draft.syncStatus === "offline"}
|
|
{i18n.t("game.sidebar.order.sync.offline")}
|
|
{:else if draft.syncStatus === "conflict"}
|
|
{i18n.t("game.sidebar.order.sync.conflict")}
|
|
{:else if draft.syncStatus === "paused"}
|
|
{i18n.t("game.sidebar.order.sync.paused")}
|
|
{:else}
|
|
{i18n.t("game.sidebar.order.sync.idle")}
|
|
{/if}
|
|
</span>
|
|
{#if draft.syncStatus === "error"}
|
|
<button
|
|
type="button"
|
|
class="sync-retry"
|
|
data-testid="order-sync-retry"
|
|
onclick={() => draft.forceSync()}
|
|
>
|
|
{i18n.t("game.sidebar.order.sync.retry")}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.tool {
|
|
padding: 1rem;
|
|
font-family: system-ui, sans-serif;
|
|
}
|
|
.tool h3 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1rem;
|
|
}
|
|
.empty {
|
|
margin: 0;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.commands {
|
|
list-style: none;
|
|
margin: 0 0 0.75rem;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
.command {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
align-items: baseline;
|
|
gap: 0.4rem;
|
|
padding: 0.3rem 1.1rem 0.3rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
line-height: 1.3;
|
|
background: var(--color-surface-overlay);
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: 4px;
|
|
}
|
|
.command.status-applied {
|
|
background: var(--color-success-subtle);
|
|
}
|
|
.command.status-invalid,
|
|
.command.status-rejected,
|
|
.command.status-conflict {
|
|
background: var(--color-danger-subtle);
|
|
}
|
|
.command.status-draft,
|
|
.command.status-valid,
|
|
.command.status-submitting {
|
|
background: var(--color-warning-subtle);
|
|
}
|
|
.index {
|
|
color: var(--color-text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.label {
|
|
min-width: 0;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.banner {
|
|
margin: 0 0 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
line-height: 1.3;
|
|
}
|
|
.banner-conflict {
|
|
color: var(--color-warning);
|
|
background: var(--color-warning-subtle);
|
|
border: 1px solid var(--color-warning);
|
|
}
|
|
.banner-paused {
|
|
color: var(--color-text-muted);
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
.delete {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 0.95rem;
|
|
height: 0.95rem;
|
|
padding: 0;
|
|
font: inherit;
|
|
font-size: 0.65rem;
|
|
line-height: 1;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border-subtle);
|
|
border-radius: 0 3px 0 3px;
|
|
cursor: pointer;
|
|
}
|
|
.delete:hover,
|
|
.delete:focus-visible {
|
|
color: var(--color-text);
|
|
border-color: var(--color-accent);
|
|
}
|
|
.sync {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.sync-error {
|
|
color: var(--color-danger);
|
|
}
|
|
.sync-synced {
|
|
color: var(--color-success);
|
|
}
|
|
.sync-syncing {
|
|
color: var(--color-accent);
|
|
}
|
|
.sync-offline {
|
|
color: var(--color-warning);
|
|
}
|
|
.sync-conflict {
|
|
color: var(--color-warning);
|
|
}
|
|
.sync-paused {
|
|
color: var(--color-text-muted);
|
|
}
|
|
.sync-retry {
|
|
font: inherit;
|
|
font-size: 0.8rem;
|
|
padding: 0.15rem 0.5rem;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
.sync-retry:hover {
|
|
color: var(--color-text);
|
|
border-color: var(--color-accent);
|
|
}
|
|
</style>
|