4ad96b0ef7
Tokenize every remaining component <style> — calculator, order tab, inspectors, tables, report sections, lobby, auth, mail, battle viewer, toasts, map overlays. A scripted pass handled the unambiguous core palette (text/bg/surface/border/accent/danger/muted), the rest were mapped to the semantic/grey tokens by role. Remaining colour literals are the documented exceptions only: the battle-scene SVG data-visualisation palette (fixed dark, like the WebGL map canvas), overlay scrims (modal / map-canvas), and directional or deliberate drop shadows. The default theme stays dark until light coherence is signed off across the views. Updates ui/docs/design-system.md (migration status + exceptions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1252 lines
38 KiB
Svelte
1252 lines
38 KiB
Svelte
<!--
|
|
Phase 20 ship-group action panel.
|
|
|
|
Mounted by `lib/inspectors/ship-group.svelte` for `selection.variant
|
|
=== "local"`. Renders eight inline forms — Split, Send, Load, Unload,
|
|
Modernize, Dismantle, Transfer, Join Fleet — each gated by the same
|
|
state-and-context predicates that the engine enforces server-side. All
|
|
real actions either append a single command to the local order draft
|
|
or, when the player chose fewer ships than the group holds, prepend
|
|
an implicit `breakShipGroup` and route the action at the freshly-
|
|
minted sub-group ID.
|
|
|
|
The component owns the open/closed state for each form (so opening
|
|
one closes the others), the ephemeral form fields, and the live
|
|
modernize cost preview backed by `core.blockUpgradeCost`.
|
|
-->
|
|
<script lang="ts">
|
|
import { getContext } from "svelte";
|
|
|
|
import type {
|
|
ReportLocalFleet,
|
|
ReportLocalShipGroup,
|
|
ReportPlanet,
|
|
ShipClassSummary,
|
|
} from "../../../api/game-state";
|
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
|
import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
|
|
import {
|
|
MAP_PICK_CONTEXT_KEY,
|
|
type MapPickService,
|
|
} from "$lib/map-pick.svelte";
|
|
import { torusShortestDelta } from "../../../map/math";
|
|
import {
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
OrderDraftStore,
|
|
} from "../../../sync/order-draft.svelte";
|
|
import {
|
|
SHIP_GROUP_CARGO_VALUES,
|
|
SHIP_GROUP_UPGRADE_TECH_VALUES,
|
|
type OrderCommand,
|
|
type ShipGroupCargo,
|
|
type ShipGroupUpgradeTech,
|
|
} from "../../../sync/order-types";
|
|
import { validateEntityName } from "$lib/util/entity-name";
|
|
|
|
type Props = {
|
|
group: ReportLocalShipGroup;
|
|
planets: ReportPlanet[];
|
|
localShipClass: ShipClassSummary[];
|
|
localFleets: ReportLocalFleet[];
|
|
otherRaces: string[];
|
|
mapWidth: number;
|
|
mapHeight: number;
|
|
localPlayerDrive: number;
|
|
localPlayerWeapons: number;
|
|
localPlayerShields: number;
|
|
localPlayerCargo: number;
|
|
};
|
|
let {
|
|
group,
|
|
planets,
|
|
localShipClass,
|
|
localFleets,
|
|
otherRaces,
|
|
mapWidth,
|
|
mapHeight,
|
|
localPlayerDrive,
|
|
localPlayerWeapons,
|
|
localPlayerShields,
|
|
localPlayerCargo,
|
|
}: Props = $props();
|
|
|
|
const draft = getContext<OrderDraftStore | undefined>(
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
);
|
|
const pick = getContext<MapPickService | undefined>(MAP_PICK_CONTEXT_KEY);
|
|
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
|
|
|
type FormName =
|
|
| "split"
|
|
| "send"
|
|
| "load"
|
|
| "unload"
|
|
| "modernize"
|
|
| "dismantle"
|
|
| "transfer"
|
|
| "joinFleet";
|
|
|
|
let openForm: FormName | null = $state(null);
|
|
|
|
// Per-form ephemeral state. Resets via the form-open helpers below
|
|
// so a stale value from a previous selection cannot leak into the
|
|
// next one.
|
|
let splitShips = $state(1);
|
|
let sendShips = $state(1);
|
|
let sendDestination: number | null = $state(null);
|
|
let sendPicking = $state(false);
|
|
let loadShips = $state(1);
|
|
let loadCargo: ShipGroupCargo = $state("COL");
|
|
let loadQuantity = $state(0);
|
|
let unloadShips = $state(1);
|
|
let unloadQuantity = $state(0);
|
|
let modernizeShips = $state(1);
|
|
let modernizeTech: ShipGroupUpgradeTech = $state("ALL");
|
|
let modernizeLevel = $state(0);
|
|
let dismantleShips = $state(1);
|
|
let dismantleConfirmed = $state(false);
|
|
let transferShips = $state(1);
|
|
let transferAcceptor = $state("");
|
|
let joinFleetMode: "existing" | "new" = $state("new");
|
|
let joinFleetExisting = $state("");
|
|
let joinFleetNew = $state("");
|
|
|
|
// Reset all per-form state whenever the selected group changes so a
|
|
// stale form does not leak across selection switches.
|
|
$effect(() => {
|
|
void group.id;
|
|
openForm = null;
|
|
});
|
|
|
|
// Close any open form the moment the group becomes locked
|
|
// (a destructive command landed in the draft from elsewhere —
|
|
// e.g. another browser tab editing the same draft, or a
|
|
// concurrent action on this inspector). Without this guard a
|
|
// pending form would still allow Confirm despite the locked
|
|
// banner above it.
|
|
$effect(() => {
|
|
if (pendingLockingCommand !== null) {
|
|
if (sendPicking) {
|
|
pick?.cancel();
|
|
sendPicking = false;
|
|
}
|
|
openForm = null;
|
|
}
|
|
});
|
|
|
|
const inOrbit = $derived(group.state === "In_Orbit");
|
|
const orbitPlanet = $derived(
|
|
inOrbit ? (planets.find((p) => p.number === group.destination) ?? null) : null,
|
|
);
|
|
const shipClass = $derived(
|
|
localShipClass.find((sc) => sc.name === group.class) ?? null,
|
|
);
|
|
const ownPlanet = $derived(orbitPlanet?.kind === "local");
|
|
const uninhabitedPlanet = $derived(orbitPlanet?.kind === "uninhabited");
|
|
const friendlyPlanet = $derived(ownPlanet || uninhabitedPlanet);
|
|
const hasCargoBlock = $derived(shipClass !== null && shipClass.cargo > 0);
|
|
const hasDriveBlock = $derived(shipClass !== null && shipClass.drive > 0);
|
|
const reach = $derived(40 * localPlayerDrive);
|
|
const cargoLoaded = $derived(group.cargo !== "NONE" && group.load > 0);
|
|
const carryingColonists = $derived(group.cargo === "COL" && group.load > 0);
|
|
|
|
const reachableSet = $derived.by(() => {
|
|
const ids = new Set<number>();
|
|
if (!inOrbit || !hasDriveBlock || reach <= 0 || orbitPlanet === null)
|
|
return ids;
|
|
for (const candidate of planets) {
|
|
if (candidate.number === orbitPlanet.number) continue;
|
|
const dx = torusShortestDelta(orbitPlanet.x, candidate.x, mapWidth);
|
|
const dy = torusShortestDelta(orbitPlanet.y, candidate.y, mapHeight);
|
|
if (Math.hypot(dx, dy) <= reach) ids.add(candidate.number);
|
|
}
|
|
return ids;
|
|
});
|
|
|
|
const techHeadroom = $derived.by(() => {
|
|
// At least one block must have race tech > group tech AND a non-
|
|
// zero block mass for modernize to do anything (engine refuses
|
|
// when the requested block has zero mass — see
|
|
// controller/ship_group_upgrade.go:54).
|
|
if (shipClass === null) return false;
|
|
const blocks: Array<[number, number, number]> = [
|
|
[shipClass.drive, group.tech.drive, localPlayerDrive],
|
|
[shipClass.shields, group.tech.shields, localPlayerShields],
|
|
[shipClass.cargo, group.tech.cargo, localPlayerCargo],
|
|
];
|
|
// Weapons block mass requires the bridge.
|
|
const core = coreHandle?.core ?? null;
|
|
if (core !== null) {
|
|
const wm = core.weaponsBlockMass({
|
|
weapons: shipClass.weapons,
|
|
armament: shipClass.armament,
|
|
});
|
|
if (wm !== null) blocks.push([wm, group.tech.weapons, localPlayerWeapons]);
|
|
} else if (shipClass.weapons > 0) {
|
|
// Without Core, fall back to a weak heuristic: if weapons > 0
|
|
// the block exists. The engine still re-validates server-
|
|
// side so a false-positive only delays the rejection.
|
|
blocks.push([1, group.tech.weapons, localPlayerWeapons]);
|
|
}
|
|
return blocks.some(
|
|
([mass, current, race]) => mass > 0 && race > current,
|
|
);
|
|
});
|
|
|
|
const planetStock = $derived.by(() => {
|
|
if (orbitPlanet === null || orbitPlanet.kind === "unidentified") {
|
|
return { COL: 0, CAP: 0, MAT: 0 };
|
|
}
|
|
return {
|
|
COL: orbitPlanet.colonists ?? 0,
|
|
CAP: orbitPlanet.industryStockpile ?? 0,
|
|
MAT: orbitPlanet.materialsStockpile ?? 0,
|
|
};
|
|
});
|
|
|
|
const cargoCapacityPerShip = $derived.by(() => {
|
|
const core = coreHandle?.core ?? null;
|
|
if (core === null || shipClass === null) return null;
|
|
return core.cargoCapacity({
|
|
cargo: shipClass.cargo,
|
|
cargoTech: localPlayerCargo,
|
|
});
|
|
});
|
|
|
|
const groupCapacity = $derived(
|
|
cargoCapacityPerShip === null
|
|
? null
|
|
: cargoCapacityPerShip * group.count,
|
|
);
|
|
const freeCapacity = $derived(
|
|
groupCapacity === null ? null : Math.max(0, groupCapacity - group.load),
|
|
);
|
|
|
|
const fleetsOnSamePlanet = $derived(
|
|
localFleets.filter(
|
|
(f) =>
|
|
f.state === "In_Orbit" &&
|
|
f.destination === group.destination &&
|
|
f.origin === null,
|
|
),
|
|
);
|
|
|
|
function disabledStateTooltip(): string | null {
|
|
if (!inOrbit) {
|
|
return i18n.t(
|
|
"game.inspector.ship_group.action.disabled.not_in_orbit",
|
|
{ state: group.state },
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function reasonTooltip(reason: TranslationKey | null): string | null {
|
|
return reason === null ? null : i18n.t(reason);
|
|
}
|
|
|
|
// pendingLockingCommand watches the order draft for any send /
|
|
// modernize / dismantle / transfer command targeting this group.
|
|
// Once the player queues one of those four, every action on the
|
|
// group is disabled until the draft entry is removed: each is
|
|
// state-changing at turn cutoff (Send → state Launched, Modernize
|
|
// → state Upgrade, Transfer → state Transfer, Dismantle → group
|
|
// removed), so a follow-up action would race the engine's
|
|
// pre-condition check and noisy-fail server-side. The lock
|
|
// surfaces the commitment up-front and points the player at the
|
|
// order list as the way to release it. Load / Unload / Split /
|
|
// Join Fleet stay non-locking — the engine accepts them stacked,
|
|
// and Send + the three destructive variants are the only commands
|
|
// that flip the group out of `In_Orbit` on the next tick.
|
|
const pendingLockingCommand = $derived.by(() => {
|
|
if (draft === undefined) return null;
|
|
for (const cmd of draft.commands) {
|
|
if (
|
|
cmd.kind !== "sendShipGroup" &&
|
|
cmd.kind !== "upgradeShipGroup" &&
|
|
cmd.kind !== "dismantleShipGroup" &&
|
|
cmd.kind !== "transferShipGroup"
|
|
)
|
|
continue;
|
|
if (cmd.groupId !== group.id) continue;
|
|
const status = draft.statuses[cmd.id];
|
|
if (status === "rejected" || status === "invalid") continue;
|
|
return cmd;
|
|
}
|
|
return null;
|
|
});
|
|
const lockedReason: TranslationKey | null = $derived(
|
|
pendingLockingCommand === null
|
|
? null
|
|
: "game.inspector.ship_group.action.disabled.locked",
|
|
);
|
|
const lockedKindLabel = $derived.by(() => {
|
|
const cmd = pendingLockingCommand;
|
|
if (cmd === null) return "";
|
|
switch (cmd.kind) {
|
|
case "sendShipGroup":
|
|
return i18n.t("game.inspector.ship_group.action.locked.kind.send");
|
|
case "upgradeShipGroup":
|
|
return i18n.t("game.inspector.ship_group.action.locked.kind.modernize");
|
|
case "dismantleShipGroup":
|
|
return i18n.t("game.inspector.ship_group.action.locked.kind.dismantle");
|
|
case "transferShipGroup":
|
|
return i18n.t("game.inspector.ship_group.action.locked.kind.transfer");
|
|
}
|
|
});
|
|
|
|
const splitDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
if (group.count < 2) return "game.inspector.ship_group.action.invalid.ship_count";
|
|
return null;
|
|
});
|
|
const sendDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
if (!hasDriveBlock) return "game.inspector.ship_group.action.disabled.no_drive";
|
|
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
|
if (reachableSet.size === 0) return "game.inspector.ship_group.action.disabled.no_reach";
|
|
return null;
|
|
});
|
|
const loadDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
|
|
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
|
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
|
|
if (freeCapacity !== null && freeCapacity <= 0)
|
|
return "game.inspector.ship_group.action.disabled.full_load";
|
|
const stock = planetStock;
|
|
if (stock.COL <= 0 && stock.CAP <= 0 && stock.MAT <= 0)
|
|
return "game.inspector.ship_group.action.disabled.no_planet_stock";
|
|
return null;
|
|
});
|
|
const unloadDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
if (!hasCargoBlock) return "game.inspector.ship_group.action.disabled.no_cargo_block";
|
|
if (!cargoLoaded) return "game.inspector.ship_group.action.disabled.empty_cargo";
|
|
if (group.cargo === "COL" && !ownPlanet && !uninhabitedPlanet)
|
|
return "game.inspector.ship_group.action.disabled.foreign_unload_col";
|
|
return null;
|
|
});
|
|
const modernizeDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
if (orbitPlanet === null) return "game.inspector.ship_group.action.disabled.no_planet";
|
|
if (!friendlyPlanet) return "game.inspector.ship_group.action.disabled.foreign_planet";
|
|
if (shipClass === null) return "game.inspector.ship_group.action.disabled.unknown_class";
|
|
if (!techHeadroom) return "game.inspector.ship_group.action.disabled.no_headroom";
|
|
return null;
|
|
});
|
|
const dismantleDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
return null;
|
|
});
|
|
const transferDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
if (otherRaces.length === 0)
|
|
return "game.inspector.ship_group.action.disabled.no_other_races";
|
|
return null;
|
|
});
|
|
const joinFleetDisabledReason = $derived.by((): TranslationKey | null => {
|
|
if (lockedReason !== null) return lockedReason;
|
|
if (!inOrbit) return "game.inspector.ship_group.action.disabled.not_in_orbit";
|
|
return null;
|
|
});
|
|
|
|
function maxShips(): number {
|
|
return group.count;
|
|
}
|
|
|
|
function clampShips(value: number): number {
|
|
if (!Number.isFinite(value)) return 1;
|
|
const v = Math.floor(value);
|
|
if (v < 1) return 1;
|
|
if (v > group.count) return group.count;
|
|
return v;
|
|
}
|
|
|
|
function openSplit(): void {
|
|
closeOthers("split");
|
|
splitShips = Math.max(1, Math.min(Math.floor(group.count / 2), group.count - 1));
|
|
}
|
|
async function openSend(): Promise<void> {
|
|
// "Send" is a two-stage flow: the click drops the inspector
|
|
// straight into map-pick mode, and the form (ship count +
|
|
// confirm) only appears after the player chooses a destination
|
|
// or cancels. The destination is therefore never an editable
|
|
// control inside the form — picking is the entry point, not a
|
|
// sub-step. Re-clicking the action while picking cancels the
|
|
// session; re-clicking while the form is open closes it.
|
|
if (sendPicking) {
|
|
pick?.cancel();
|
|
sendPicking = false;
|
|
openForm = null;
|
|
return;
|
|
}
|
|
if (openForm === "send") {
|
|
openForm = null;
|
|
return;
|
|
}
|
|
openForm = null;
|
|
if (pick === undefined || draft === undefined) return;
|
|
if (orbitPlanet === null || reachableSet.size === 0) return;
|
|
sendShips = group.count;
|
|
sendDestination = null;
|
|
sendPicking = true;
|
|
let picked: number | null = null;
|
|
try {
|
|
picked = await pick.pick({
|
|
sourcePlanetNumber: orbitPlanet.number,
|
|
reachableIds: reachableSet,
|
|
});
|
|
} finally {
|
|
sendPicking = false;
|
|
}
|
|
if (picked === null) return;
|
|
sendDestination = picked;
|
|
openForm = "send";
|
|
}
|
|
function openLoad(): void {
|
|
closeOthers("load");
|
|
loadShips = group.count;
|
|
// Initialise cargo type to whatever the group already carries
|
|
// so the validator does not require an extra click; if empty,
|
|
// default to colonists (the highest priority cargo on routes).
|
|
loadCargo = group.cargo === "NONE" ? "COL" : (group.cargo as ShipGroupCargo);
|
|
loadQuantity = 0;
|
|
}
|
|
function openUnload(): void {
|
|
closeOthers("unload");
|
|
unloadShips = group.count;
|
|
unloadQuantity = group.load;
|
|
}
|
|
function openModernize(): void {
|
|
closeOthers("modernize");
|
|
modernizeShips = group.count;
|
|
modernizeTech = "ALL";
|
|
modernizeLevel = 0;
|
|
}
|
|
function openDismantle(): void {
|
|
closeOthers("dismantle");
|
|
dismantleShips = group.count;
|
|
dismantleConfirmed = false;
|
|
}
|
|
function openTransfer(): void {
|
|
closeOthers("transfer");
|
|
transferShips = group.count;
|
|
transferAcceptor = otherRaces[0] ?? "";
|
|
}
|
|
function openJoinFleet(): void {
|
|
closeOthers("joinFleet");
|
|
const candidates = fleetsOnSamePlanet;
|
|
joinFleetMode = candidates.length > 0 ? "existing" : "new";
|
|
joinFleetExisting = candidates[0]?.name ?? "";
|
|
joinFleetNew = "";
|
|
}
|
|
|
|
function closeOthers(target: FormName): void {
|
|
if (openForm === target) {
|
|
openForm = null;
|
|
return;
|
|
}
|
|
openForm = target;
|
|
}
|
|
|
|
function cancelForm(): void {
|
|
// Cancel any in-flight pick session so the renderer drops the
|
|
// dim mask before the form re-opens for a different action.
|
|
if (sendPicking) {
|
|
pick?.cancel();
|
|
sendPicking = false;
|
|
}
|
|
openForm = null;
|
|
}
|
|
|
|
function actionGroupId(targetShips: number): {
|
|
groupId: string;
|
|
breakCommand: OrderCommand | null;
|
|
} {
|
|
// When the action targets fewer ships than the group holds, the
|
|
// engine needs a fresh sub-group; we mint its UUID up front and
|
|
// emit a `breakShipGroup` command that carves it out before the
|
|
// real action runs against the new id.
|
|
if (targetShips >= group.count) {
|
|
return { groupId: group.id, breakCommand: null };
|
|
}
|
|
const newGroupId = crypto.randomUUID();
|
|
return {
|
|
groupId: newGroupId,
|
|
breakCommand: {
|
|
kind: "breakShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId: group.id,
|
|
newGroupId,
|
|
quantity: targetShips,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function emit(
|
|
breakCommand: OrderCommand | null,
|
|
action: OrderCommand,
|
|
): Promise<void> {
|
|
if (draft === undefined) return;
|
|
if (breakCommand !== null) await draft.add(breakCommand);
|
|
await draft.add(action);
|
|
}
|
|
|
|
async function confirmSplit(): Promise<void> {
|
|
const ships = clampShips(splitShips);
|
|
if (ships < 1 || ships >= group.count || draft === undefined) return;
|
|
await draft.add({
|
|
kind: "breakShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId: group.id,
|
|
newGroupId: crypto.randomUUID(),
|
|
quantity: ships,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmSend(): Promise<void> {
|
|
if (sendDestination === null || draft === undefined) return;
|
|
const ships = clampShips(sendShips);
|
|
const { groupId, breakCommand } = actionGroupId(ships);
|
|
await emit(breakCommand, {
|
|
kind: "sendShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
destinationPlanetNumber: sendDestination,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmLoad(): Promise<void> {
|
|
const ships = clampShips(loadShips);
|
|
const quantity = Number(loadQuantity);
|
|
if (!(quantity > 0) || draft === undefined) return;
|
|
const { groupId, breakCommand } = actionGroupId(ships);
|
|
await emit(breakCommand, {
|
|
kind: "loadShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
cargo: loadCargo,
|
|
quantity,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmUnload(): Promise<void> {
|
|
const ships = clampShips(unloadShips);
|
|
const quantity = Number(unloadQuantity);
|
|
if (!(quantity > 0) || draft === undefined) return;
|
|
const { groupId, breakCommand } = actionGroupId(ships);
|
|
await emit(breakCommand, {
|
|
kind: "unloadShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
quantity,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmModernize(): Promise<void> {
|
|
if (draft === undefined) return;
|
|
const ships = clampShips(modernizeShips);
|
|
const tech = modernizeTech;
|
|
const level = tech === "ALL" ? 0 : Number(modernizeLevel);
|
|
if (tech !== "ALL" && !(level > 0)) return;
|
|
const { groupId, breakCommand } = actionGroupId(ships);
|
|
await emit(breakCommand, {
|
|
kind: "upgradeShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
tech,
|
|
level,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmDismantle(): Promise<void> {
|
|
if (draft === undefined) return;
|
|
const ships = clampShips(dismantleShips);
|
|
const subset = ships < group.count;
|
|
const colonistsOverForeign = !ownPlanet && !uninhabitedPlanet && subset
|
|
? group.cargo === "COL" && group.load > 0
|
|
: !ownPlanet && !uninhabitedPlanet && carryingColonists;
|
|
if (colonistsOverForeign && !dismantleConfirmed) {
|
|
dismantleConfirmed = true;
|
|
return;
|
|
}
|
|
const { groupId, breakCommand } = actionGroupId(ships);
|
|
await emit(breakCommand, {
|
|
kind: "dismantleShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmTransfer(): Promise<void> {
|
|
if (draft === undefined) return;
|
|
const ships = clampShips(transferShips);
|
|
const acceptor = transferAcceptor.trim();
|
|
if (acceptor === "") return;
|
|
const { groupId, breakCommand } = actionGroupId(ships);
|
|
await emit(breakCommand, {
|
|
kind: "transferShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId,
|
|
acceptor,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
async function confirmJoinFleet(): Promise<void> {
|
|
if (draft === undefined) return;
|
|
const name = joinFleetMode === "existing" ? joinFleetExisting : joinFleetNew;
|
|
if (!validateEntityName(name).ok) return;
|
|
await draft.add({
|
|
kind: "joinFleetShipGroup",
|
|
id: crypto.randomUUID(),
|
|
groupId: group.id,
|
|
name,
|
|
});
|
|
openForm = null;
|
|
}
|
|
|
|
const modernizeCostPreview = $derived.by(() => {
|
|
const core = coreHandle?.core ?? null;
|
|
if (core === null || shipClass === null) return null;
|
|
const ships = clampShips(modernizeShips);
|
|
const targets: Record<"DRIVE" | "WEAPONS" | "SHIELDS" | "CARGO", number> =
|
|
modernizeTech === "ALL"
|
|
? {
|
|
DRIVE: localPlayerDrive,
|
|
WEAPONS: localPlayerWeapons,
|
|
SHIELDS: localPlayerShields,
|
|
CARGO: localPlayerCargo,
|
|
}
|
|
: {
|
|
DRIVE: 0,
|
|
WEAPONS: 0,
|
|
SHIELDS: 0,
|
|
CARGO: 0,
|
|
[modernizeTech]: Number(modernizeLevel) || 0,
|
|
};
|
|
const weapons = core.weaponsBlockMass({
|
|
weapons: shipClass.weapons,
|
|
armament: shipClass.armament,
|
|
});
|
|
const blocks: Array<[number, number, number]> = [
|
|
[shipClass.drive, group.tech.drive, targets.DRIVE],
|
|
[weapons ?? 0, group.tech.weapons, targets.WEAPONS],
|
|
[shipClass.shields, group.tech.shields, targets.SHIELDS],
|
|
[shipClass.cargo, group.tech.cargo, targets.CARGO],
|
|
];
|
|
let perShip = 0;
|
|
for (const [mass, current, target] of blocks) {
|
|
if (target <= 0) continue;
|
|
perShip += core.blockUpgradeCost({
|
|
blockMass: mass,
|
|
currentTech: current,
|
|
targetTech: target,
|
|
});
|
|
}
|
|
if (perShip <= 0) return 0;
|
|
return perShip * ships;
|
|
});
|
|
|
|
const cargoLabelKey: Record<ShipGroupCargo, TranslationKey> = {
|
|
COL: "game.inspector.ship_group.cargo.col",
|
|
CAP: "game.inspector.ship_group.cargo.cap",
|
|
MAT: "game.inspector.ship_group.cargo.mat",
|
|
};
|
|
const techLabelKey: Record<ShipGroupUpgradeTech, TranslationKey> = {
|
|
ALL: "game.inspector.ship_group.action.tech.all",
|
|
DRIVE: "game.inspector.ship_group.action.tech.drive",
|
|
WEAPONS: "game.inspector.ship_group.action.tech.weapons",
|
|
SHIELDS: "game.inspector.ship_group.action.tech.shields",
|
|
CARGO: "game.inspector.ship_group.action.tech.cargo",
|
|
};
|
|
|
|
function formatNumber(value: number): string {
|
|
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
}
|
|
</script>
|
|
|
|
<section class="actions" data-testid="inspector-ship-group-actions">
|
|
{#if pendingLockingCommand !== null}
|
|
<p class="locked" data-testid="inspector-ship-group-actions-locked">
|
|
{i18n.t("game.inspector.ship_group.action.locked.banner", {
|
|
command: lockedKindLabel,
|
|
})}
|
|
</p>
|
|
{/if}
|
|
<div class="row">
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-split"
|
|
disabled={splitDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(splitDisabledReason) ?? ""}
|
|
onclick={openSplit}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.split")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-send"
|
|
disabled={sendDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(sendDisabledReason) ?? ""}
|
|
onclick={() => void openSend()}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.send")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-load"
|
|
disabled={loadDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(loadDisabledReason) ?? ""}
|
|
onclick={openLoad}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.load")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-unload"
|
|
disabled={unloadDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(unloadDisabledReason) ?? ""}
|
|
onclick={openUnload}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.unload")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-modernize"
|
|
disabled={modernizeDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(modernizeDisabledReason) ?? ""}
|
|
onclick={openModernize}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.modernize")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-dismantle"
|
|
disabled={dismantleDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(dismantleDisabledReason) ?? ""}
|
|
onclick={openDismantle}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.dismantle")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-transfer"
|
|
disabled={transferDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(transferDisabledReason) ?? ""}
|
|
onclick={openTransfer}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.transfer")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="action"
|
|
data-testid="inspector-ship-group-action-join-fleet"
|
|
disabled={joinFleetDisabledReason !== null || draft === undefined}
|
|
title={reasonTooltip(joinFleetDisabledReason) ?? ""}
|
|
onclick={openJoinFleet}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.join_fleet")}
|
|
</button>
|
|
</div>
|
|
|
|
{#if openForm === "split"}
|
|
<form class="form" data-testid="inspector-ship-group-form-split" onsubmit={(e) => { e.preventDefault(); void confirmSplit(); }}>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count - 1)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-split-ships"
|
|
bind:value={splitShips}
|
|
/>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-split-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-split-confirm"
|
|
disabled={splitShips < 1 || splitShips >= group.count}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if openForm === "send" && sendDestination !== null}
|
|
<form class="form" data-testid="inspector-ship-group-form-send" onsubmit={(e) => { e.preventDefault(); void confirmSend(); }}>
|
|
<p class="destination-readonly">
|
|
<span class="label">{i18n.t("game.inspector.ship_group.action.field.destination")}</span>
|
|
<span data-testid="inspector-ship-group-form-send-destination">
|
|
{planets.find((p) => p.number === sendDestination)?.name ?? `#${sendDestination}`}
|
|
</span>
|
|
</p>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-send-ships"
|
|
bind:value={sendShips}
|
|
/>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-send-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-send-confirm"
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if sendPicking}
|
|
<p class="hint" data-testid="inspector-ship-group-form-send-pick-prompt">
|
|
{i18n.t("game.inspector.ship_group.action.send.pick_prompt")}
|
|
</p>
|
|
{/if}
|
|
|
|
{#if openForm === "load"}
|
|
<form class="form" data-testid="inspector-ship-group-form-load" onsubmit={(e) => { e.preventDefault(); void confirmLoad(); }}>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-load-ships"
|
|
bind:value={loadShips}
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.cargo")}
|
|
<select
|
|
data-testid="inspector-ship-group-form-load-cargo"
|
|
bind:value={loadCargo}
|
|
disabled={group.cargo !== "NONE"}
|
|
>
|
|
{#each SHIP_GROUP_CARGO_VALUES as value (value)}
|
|
<option {value} disabled={planetStock[value] <= 0}>
|
|
{i18n.t(cargoLabelKey[value])}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.quantity")}
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
data-testid="inspector-ship-group-form-load-quantity"
|
|
bind:value={loadQuantity}
|
|
/>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-load-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-load-confirm"
|
|
disabled={!(loadQuantity > 0)}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if openForm === "unload"}
|
|
<form class="form" data-testid="inspector-ship-group-form-unload" onsubmit={(e) => { e.preventDefault(); void confirmUnload(); }}>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-unload-ships"
|
|
bind:value={unloadShips}
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.quantity")}
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
data-testid="inspector-ship-group-form-unload-quantity"
|
|
bind:value={unloadQuantity}
|
|
/>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-unload-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-unload-confirm"
|
|
disabled={!(unloadQuantity > 0)}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if openForm === "modernize"}
|
|
<form class="form" data-testid="inspector-ship-group-form-modernize" onsubmit={(e) => { e.preventDefault(); void confirmModernize(); }}>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-modernize-ships"
|
|
bind:value={modernizeShips}
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.tech")}
|
|
<select
|
|
data-testid="inspector-ship-group-form-modernize-tech"
|
|
bind:value={modernizeTech}
|
|
>
|
|
{#each SHIP_GROUP_UPGRADE_TECH_VALUES as value (value)}
|
|
<option {value}>{i18n.t(techLabelKey[value])}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
{#if modernizeTech !== "ALL"}
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.level")}
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
data-testid="inspector-ship-group-form-modernize-level"
|
|
bind:value={modernizeLevel}
|
|
/>
|
|
</label>
|
|
{/if}
|
|
<p class="preview" data-testid="inspector-ship-group-form-modernize-cost">
|
|
{#if modernizeCostPreview === null}
|
|
{i18n.t("game.inspector.ship_group.action.modernize.cost_unavailable")}
|
|
{:else}
|
|
{i18n.t("game.inspector.ship_group.action.modernize.cost", {
|
|
cost: formatNumber(modernizeCostPreview),
|
|
})}
|
|
{/if}
|
|
</p>
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-modernize-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-modernize-confirm"
|
|
disabled={modernizeTech !== "ALL" && !(modernizeLevel > 0)}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if openForm === "dismantle"}
|
|
<form class="form" data-testid="inspector-ship-group-form-dismantle" onsubmit={(e) => { e.preventDefault(); void confirmDismantle(); }}>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-dismantle-ships"
|
|
bind:value={dismantleShips}
|
|
/>
|
|
</label>
|
|
{#if !ownPlanet && !uninhabitedPlanet && carryingColonists}
|
|
<p class="warning" data-testid="inspector-ship-group-form-dismantle-warning">
|
|
{i18n.t("game.inspector.ship_group.action.dismantle.warning")}
|
|
</p>
|
|
{/if}
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-dismantle-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-dismantle-confirm"
|
|
>
|
|
{#if !ownPlanet && !uninhabitedPlanet && carryingColonists && !dismantleConfirmed}
|
|
{i18n.t("game.inspector.ship_group.action.confirm_destroy")}
|
|
{:else}
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if openForm === "transfer"}
|
|
<form class="form" data-testid="inspector-ship-group-form-transfer" onsubmit={(e) => { e.preventDefault(); void confirmTransfer(); }}>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.ships", { max: String(maxShips()) })}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={String(group.count)}
|
|
step="1"
|
|
data-testid="inspector-ship-group-form-transfer-ships"
|
|
bind:value={transferShips}
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("game.inspector.ship_group.action.field.acceptor")}
|
|
<select
|
|
data-testid="inspector-ship-group-form-transfer-acceptor"
|
|
bind:value={transferAcceptor}
|
|
>
|
|
{#each otherRaces as race (race)}
|
|
<option value={race}>{race}</option>
|
|
{/each}
|
|
</select>
|
|
</label>
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-transfer-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-transfer-confirm"
|
|
disabled={transferAcceptor === ""}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if openForm === "joinFleet"}
|
|
<form class="form" data-testid="inspector-ship-group-form-join-fleet" onsubmit={(e) => { e.preventDefault(); void confirmJoinFleet(); }}>
|
|
{#if fleetsOnSamePlanet.length > 0}
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="fleet-mode"
|
|
value="existing"
|
|
bind:group={joinFleetMode}
|
|
data-testid="inspector-ship-group-form-join-fleet-mode-existing"
|
|
/>
|
|
{i18n.t("game.inspector.ship_group.action.field.fleet")}
|
|
</label>
|
|
{#if joinFleetMode === "existing"}
|
|
<select
|
|
data-testid="inspector-ship-group-form-join-fleet-existing"
|
|
bind:value={joinFleetExisting}
|
|
>
|
|
{#each fleetsOnSamePlanet as fleet (fleet.name)}
|
|
<option value={fleet.name}>{fleet.name}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
{/if}
|
|
<label>
|
|
<input
|
|
type="radio"
|
|
name="fleet-mode"
|
|
value="new"
|
|
bind:group={joinFleetMode}
|
|
data-testid="inspector-ship-group-form-join-fleet-mode-new"
|
|
/>
|
|
{i18n.t("game.inspector.ship_group.action.fleet.create_new")}
|
|
</label>
|
|
{#if joinFleetMode === "new"}
|
|
<input
|
|
type="text"
|
|
data-testid="inspector-ship-group-form-join-fleet-new"
|
|
bind:value={joinFleetNew}
|
|
placeholder={i18n.t("game.inspector.ship_group.action.field.fleet")}
|
|
/>
|
|
{/if}
|
|
<div class="form-actions">
|
|
<button type="button" data-testid="inspector-ship-group-form-join-fleet-cancel" onclick={cancelForm}>
|
|
{i18n.t("game.inspector.ship_group.action.cancel")}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
data-testid="inspector-ship-group-form-join-fleet-confirm"
|
|
disabled={(joinFleetMode === "existing" ? joinFleetExisting : joinFleetNew) === ""}
|
|
>
|
|
{i18n.t("game.inspector.ship_group.action.confirm")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
{#if disabledStateTooltip() !== null && openForm === null}
|
|
<p class="hint" data-testid="inspector-ship-group-actions-state-hint">
|
|
{disabledStateTooltip()}
|
|
</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.35rem;
|
|
}
|
|
.action {
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
padding: 0.2rem 0.55rem;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
.action:not(:disabled):hover {
|
|
color: var(--color-text);
|
|
border-color: var(--color-accent);
|
|
}
|
|
.action:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.4;
|
|
}
|
|
.form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
padding: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
background: var(--color-surface-raised);
|
|
}
|
|
.form label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.form input[type="number"],
|
|
.form input[type="text"],
|
|
.form select {
|
|
font: inherit;
|
|
padding: 0.25rem 0.4rem;
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
}
|
|
.form .destination-readonly {
|
|
margin: 0;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: baseline;
|
|
gap: 0.4rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
.form .destination-readonly .label {
|
|
color: var(--color-text-muted);
|
|
}
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.form-actions button {
|
|
font: inherit;
|
|
font-size: 0.85rem;
|
|
padding: 0.25rem 0.65rem;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
.form-actions button.primary {
|
|
color: var(--color-text);
|
|
border-color: var(--color-accent);
|
|
}
|
|
.form-actions button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
.preview {
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.warning {
|
|
margin: 0;
|
|
font-size: 0.85rem;
|
|
color: var(--color-warning);
|
|
}
|
|
.locked {
|
|
margin: 0;
|
|
padding: 0.4rem 0.55rem;
|
|
font-size: 0.85rem;
|
|
color: var(--color-text-muted);
|
|
background: var(--color-surface-overlay);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 3px;
|
|
}
|
|
.hint {
|
|
margin: 0;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
</style>
|