From 8e552f556d939309601229b2f227fc6d28022b60 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 27 May 2026 21:18:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20F8-10=20owner-feedback=20=E2=80=94?= =?UTF-8?q?=20persistent=20filters,=20camera,=20disabled=20visual,=20dropd?= =?UTF-8?q?own=20narrowing=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polish pass after the first F8-10 walkthrough: - table-planets: moved the `foreign` chip to the end of the row and hid the race dropdown until `foreign` is on (it never made sense to pick a race while the bucket itself was off). - persistent per-table filter / sort state — extracted to `table-{planets,ship-groups,fleets}-state.svelte.ts` singletons so a row click → map → back to the table restores the prior chip / dropdown / sort state. Held in memory only; an F5 still resets. - table-ship-groups: the planet and class dropdowns now narrow to the slice surviving the owner checkboxes, so toggling `foreign` off removes planets / classes touched only by foreign rows. - map.svelte: camera (centre + zoom) is captured on every dispose path into a new `GameStateStore.lastCamera` and consumed on the next mount, so leaving the map for any other active view and coming back restores the prior pan / zoom. A pending focus from the tables still wins for the centre point. - table-ship-classes: `:disabled` now reads as disabled (muted colour, no hover ring, not-allowed cursor) — the click was already a no-op, only the visual was lying. Co-Authored-By: Claude Opus 4.7 --- ui/frontend/src/lib/active-view/map.svelte | 25 ++++-- .../active-view/table-fleets-state.svelte.ts | 24 +++++ .../src/lib/active-view/table-fleets.svelte | 42 ++++----- .../active-view/table-planets-state.svelte.ts | 48 ++++++++++ .../src/lib/active-view/table-planets.svelte | 88 ++++++++----------- .../lib/active-view/table-ship-classes.svelte | 7 +- .../table-ship-groups-state.svelte.ts | 40 +++++++++ .../lib/active-view/table-ship-groups.svelte | 77 ++++++++-------- ui/frontend/src/lib/game-state.svelte.ts | 11 +++ ui/frontend/tests/table-fleets.test.ts | 2 + ui/frontend/tests/table-planets.test.ts | 21 +++++ ui/frontend/tests/table-ship-groups.test.ts | 33 +++++++ 12 files changed, 302 insertions(+), 116 deletions(-) create mode 100644 ui/frontend/src/lib/active-view/table-fleets-state.svelte.ts create mode 100644 ui/frontend/src/lib/active-view/table-planets-state.svelte.ts create mode 100644 ui/frontend/src/lib/active-view/table-ship-groups-state.svelte.ts diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 431c007..c965df2 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -467,15 +467,23 @@ preference the store already manages. if (canvasEl === null || containerEl === null) return; // Capture camera state before disposing so a remount inside // the same game (e.g. cargo-route overlay change) keeps the - // user's pan/zoom. A new game / first mount has no prior - // camera, so `previousCamera` stays null and the default - // centring path runs. + // user's pan / zoom. On a cold mount with no live `handle` we + // fall back to the per-game `store.lastCamera` snapshot, so + // leaving the map for a table / report and coming back also + // restores the prior view. A new game / first mount has no + // prior camera in either source, so `previousCamera` stays + // null and the default centring path runs. const previousGameId = mountedGameId; const targetGameId = store?.gameId ?? ""; - const previousCamera = - handle !== null && previousGameId === targetGameId - ? handle.getCamera() - : null; + let previousCamera: ReturnType | null = null; + if (handle !== null && previousGameId === targetGameId) { + previousCamera = handle.getCamera(); + } else if (handle === null && store?.lastCamera) { + previousCamera = store.lastCamera; + } + if (handle !== null && store !== undefined) { + store.lastCamera = handle.getCamera(); + } if (detachClick !== null) { detachClick(); detachClick = null; @@ -809,6 +817,9 @@ preference the store already manages. detachDebugSurface = null; } if (handle !== null) { + // Persist the camera snapshot to the per-game store so the + // next mount (active-view switch back to map) restores it. + if (store !== undefined) store.lastCamera = handle.getCamera(); handle.dispose(); handle = null; } diff --git a/ui/frontend/src/lib/active-view/table-fleets-state.svelte.ts b/ui/frontend/src/lib/active-view/table-fleets-state.svelte.ts new file mode 100644 index 0000000..dad2623 --- /dev/null +++ b/ui/frontend/src/lib/active-view/table-fleets-state.svelte.ts @@ -0,0 +1,24 @@ +// F8-10 fleets table — module-level filter / sort rune. +// Mirrors `table-planets-state.svelte.ts`. See that file for the +// rationale. + +export type FleetsSortColumn = "name" | "groupCount" | "state" | "location" | "speed"; +export type FleetsSortDirection = "asc" | "desc"; + +export interface FleetsTableState { + sortColumn: FleetsSortColumn; + sortDirection: FleetsSortDirection; + planetFilter: string; +} + +const DEFAULT_STATE: FleetsTableState = { + sortColumn: "name", + sortDirection: "asc", + planetFilter: "", +}; + +export const fleetsTableState: FleetsTableState = $state({ ...DEFAULT_STATE }); + +export function resetFleetsTableState(): void { + Object.assign(fleetsTableState, DEFAULT_STATE); +} diff --git a/ui/frontend/src/lib/active-view/table-fleets.svelte b/ui/frontend/src/lib/active-view/table-fleets.svelte index 9c2ec1d..ce16617 100644 --- a/ui/frontend/src/lib/active-view/table-fleets.svelte +++ b/ui/frontend/src/lib/active-view/table-fleets.svelte @@ -30,14 +30,10 @@ Click semantics: } from "$lib/selection.svelte"; import ViewState from "$lib/ui/view-state.svelte"; import { formatFloat, formatInt } from "$lib/util/number-format"; - - type SortColumn = - | "name" - | "groupCount" - | "state" - | "location" - | "speed"; - type SortDirection = "asc" | "desc"; + import { + fleetsTableState as persistent, + type FleetsSortColumn as SortColumn, + } from "./table-fleets-state.svelte"; const COLUMN_LABELS: Record = { name: "game.table.fleets.column.name", @@ -60,9 +56,8 @@ Click semantics: ); const selection = getContext(SELECTION_CONTEXT_KEY); - let sortColumn: SortColumn = $state("name"); - let sortDirection: SortDirection = $state("asc"); - let planetFilter: string = $state(""); + // `persistent` (module-level rune above) drives the dropdown + // selection so the user's planet filter survives navigation. const reportLoaded = $derived( rendered?.report !== null && rendered?.report !== undefined, @@ -85,7 +80,8 @@ Click semantics: }); const filtered = $derived.by(() => { - const planet = planetFilter === "" ? null : Number(planetFilter); + const planet = + persistent.planetFilter === "" ? null : Number(persistent.planetFilter); return fleets.filter((f) => { if (planet === null) return true; return f.destination === planet || f.origin === planet; @@ -94,8 +90,8 @@ Click semantics: const sorted = $derived.by(() => { const list = [...filtered]; - const dir = sortDirection === "asc" ? 1 : -1; - list.sort((a, b) => compare(a, b, sortColumn) * dir); + const dir = persistent.sortDirection === "asc" ? 1 : -1; + list.sort((a, b) => compare(a, b, persistent.sortColumn) * dir); return list; }); @@ -123,17 +119,17 @@ Click semantics: } function toggleSort(column: SortColumn): void { - if (sortColumn === column) { - sortDirection = sortDirection === "asc" ? "desc" : "asc"; + if (persistent.sortColumn === column) { + persistent.sortDirection = persistent.sortDirection === "asc" ? "desc" : "asc"; return; } - sortColumn = column; - sortDirection = "asc"; + persistent.sortColumn = column; + persistent.sortDirection = "asc"; } function ariaSort(column: SortColumn): "ascending" | "descending" | "none" { - if (sortColumn !== column) return "none"; - return sortDirection === "asc" ? "ascending" : "descending"; + if (persistent.sortColumn !== column) return "none"; + return persistent.sortDirection === "asc" ? "ascending" : "descending"; } function isInSpace(f: ReportLocalFleet): boolean { @@ -189,7 +185,7 @@ Click semantics: {i18n.t("game.table.fleets.filter.planet")} {i18n.t("game.table.planets.kind.own")} - @@ -203,16 +185,24 @@ fetching here. {i18n.t("game.table.planets.kind.unknown")} - {#if owners.length > 0} + + {#if persistent.showOther && owners.length > 0} @@ -271,7 +276,7 @@ categories. {i18n.t("game.table.ship_groups.owner.foreign")} @@ -280,7 +285,7 @@ categories. {i18n.t("game.table.ship_groups.filter.planet")} toggleSort(column)} > {i18n.t(COLUMN_LABELS[column])} - {#if sortColumn === column} + {#if persistent.sortColumn === column} {/if} diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts index dd55432..5ac54e7 100644 --- a/ui/frontend/src/lib/game-state.svelte.ts +++ b/ui/frontend/src/lib/game-state.svelte.ts @@ -117,6 +117,17 @@ export class GameStateStore { * `RendererHandle.setHiddenPrimitiveIds`. */ mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES }); + /** + * lastCamera is the most recent map camera snapshot (centre + zoom) + * captured before the renderer was disposed — either because the + * map view unmounted (active-view switch) or because it remounted + * inside the same session. `map.svelte` reads it on cold mount so + * leaving the map for a table / report and coming back keeps the + * user's prior pan / zoom. Resetting to null falls back to the + * default world-centre + minScale fit. Held in memory only; an F5 + * re-loads the report and the default fit takes over. + */ + lastCamera: { centerX: number; centerY: number; scale: number } | null = $state(null); error: string | null = $state(null); /** * notFound is the distinct "this game is not in the player's list" diff --git a/ui/frontend/tests/table-fleets.test.ts b/ui/frontend/tests/table-fleets.test.ts index b79beb5..b34f409 100644 --- a/ui/frontend/tests/table-fleets.test.ts +++ b/ui/frontend/tests/table-fleets.test.ts @@ -20,6 +20,7 @@ import { SelectionStore, } from "../src/lib/selection.svelte"; import { activeView } from "../src/lib/app-nav.svelte"; +import { resetFleetsTableState } from "../src/lib/active-view/table-fleets-state.svelte"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; const pageMock = vi.hoisted(() => ({ @@ -49,6 +50,7 @@ beforeEach(() => { pageMock.params = { id: "g1" }; gotoMock.mockClear(); activeView.reset(); + resetFleetsTableState(); }); afterEach(() => { diff --git a/ui/frontend/tests/table-planets.test.ts b/ui/frontend/tests/table-planets.test.ts index c6f5e64..66046af 100644 --- a/ui/frontend/tests/table-planets.test.ts +++ b/ui/frontend/tests/table-planets.test.ts @@ -16,6 +16,7 @@ import { SelectionStore, } from "../src/lib/selection.svelte"; import { activeView } from "../src/lib/app-nav.svelte"; +import { resetPlanetsTableState } from "../src/lib/active-view/table-planets-state.svelte"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; const pageMock = vi.hoisted(() => ({ @@ -45,6 +46,7 @@ beforeEach(() => { pageMock.params = { id: "g1" }; gotoMock.mockClear(); activeView.reset(); + resetPlanetsTableState(); }); afterEach(() => { @@ -189,6 +191,25 @@ describe("planets table", () => { expect(activeView.view).toBe("map"); }); + test("filter checkboxes survive an unmount/remount cycle", async () => { + const report = makeReport([ + planet({ number: 1, kind: "local" }), + planet({ number: 2, kind: "other", owner: "Klingon" }), + ]); + const first = mount(report); + await fireEvent.click(first.getByTestId("planets-filter-own")); + first.unmount(); + const second = mount(report); + const ownCheckbox = second.getByTestId( + "planets-filter-own", + ) as HTMLInputElement; + expect(ownCheckbox.checked).toBe(false); + const kinds = second + .getAllByTestId("planets-row") + .map((r) => r.getAttribute("data-kind")); + expect(kinds).toEqual(["other"]); + }); + test("number column sorts ascending then descending", async () => { const ui = mount( makeReport([ diff --git a/ui/frontend/tests/table-ship-groups.test.ts b/ui/frontend/tests/table-ship-groups.test.ts index a8208d8..825e644 100644 --- a/ui/frontend/tests/table-ship-groups.test.ts +++ b/ui/frontend/tests/table-ship-groups.test.ts @@ -21,6 +21,7 @@ import { SelectionStore, } from "../src/lib/selection.svelte"; import { activeView } from "../src/lib/app-nav.svelte"; +import { resetShipGroupsTableState } from "../src/lib/active-view/table-ship-groups-state.svelte"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; const pageMock = vi.hoisted(() => ({ @@ -50,6 +51,7 @@ beforeEach(() => { pageMock.params = { id: "g1" }; gotoMock.mockClear(); activeView.reset(); + resetShipGroupsTableState(); }); afterEach(() => { @@ -287,6 +289,37 @@ describe("ship-groups table", () => { expect(activeView.view).toBe("map"); }); + test("planet/class dropdowns narrow to the owner-checkbox cut", async () => { + const ui = mount( + makeReport({ + planets: [planet(1), planet(2), planet(3)], + local: [localGroup({ id: "L1", class: "Cruiser", destination: 1 })], + other: [ + otherGroup({ class: "Hunter", destination: 3, race: "Klingon" }), + ], + }), + ); + // All four planet options available when both owner kinds are on + const planetSelect = ui.getByTestId( + "ship-groups-filter-planet", + ) as HTMLSelectElement; + const valuesFull = Array.from(planetSelect.options).map((o) => o.value); + expect(valuesFull).toContain("1"); + expect(valuesFull).toContain("3"); + // Hide foreign rows; planet 3 (only touched by Klingon) disappears + await fireEvent.click(ui.getByTestId("ship-groups-filter-foreign")); + const valuesNarrowed = Array.from(planetSelect.options).map((o) => o.value); + expect(valuesNarrowed).toContain("1"); + expect(valuesNarrowed).not.toContain("3"); + // Class dropdown narrows too: Hunter disappears + const classSelect = ui.getByTestId( + "ship-groups-filter-class", + ) as HTMLSelectElement; + const classValues = Array.from(classSelect.options).map((o) => o.value); + expect(classValues).not.toContain("Hunter"); + expect(classValues).toContain("Cruiser"); + }); + test("click on in-space foreign group focuses other variant by index", async () => { const ui = mount( makeReport({