diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index fb3b8a0..06867dd 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -694,10 +694,15 @@ The web client renders the report as one section per FBS array foreign ship classes, battles, bombings, approaching groups, my / foreign / uninhabited / unknown planets, ships in production, cargo routes, my fleets, my / foreign / unidentified ship groups). -Empty sections render explicit empty-state copy. Section anchors -are exposed in a sticky table of contents (a ``); позиция скролла сохраняется при переключении активного -представления через SvelteKit `Snapshot` API. +empty-state. Навигация по секциям — sticky icon-popup в правом +верхнем углу колонки отчёта (анкорный popover на десктопе и фикс. +bottom-sheet на мобильном); подпись на кнопке отслеживает раздел, +который сейчас в зоне видимости, выбор пункта меню — скролл к +нужной секции. При возврате в активный вью отчёт перемонтируется, +позиция скролла сбрасывается к началу, а IntersectionObserver +заново рассчитывает подсветку при прокрутке. Секция бомбардировок — это плоская read-only-таблица: одна строка на событие, колонки `attacker`, `attack_power`, признак `wiped` и diff --git a/ui/docs/report-view.md b/ui/docs/report-view.md index da4f074..a756157 100644 --- a/ui/docs/report-view.md +++ b/ui/docs/report-view.md @@ -15,7 +15,7 @@ section column. ``` report.svelte -├── report/report-toc.svelte // anchor list + mobile ` takes its place at the top of the body. - Picking an option scrolls the matching section into view. The - mobile contract intentionally avoids stacking another overlay on - top of the existing layout-owned bottom-tabs. +The trigger uses `position: fixed` instead of `position: sticky`. +Per the CSS sticky spec a sticky element sticks within its nearest +ancestor with non-`visible` overflow — and `.active-view-host` +declares `overflow-y: auto` for the mobile scroll story. On +desktop the host grows with content and the document body becomes +the actual scroll container, so a sticky trigger inside the report +column never receives a scroll event and rides up with the page +content. A fixed trigger sidesteps the chain entirely; the +component is mounted only while the report active view is on +screen, so the fixed element is naturally tied to the view's +lifetime. The desktop offset is `right: calc(18 rem + 1.25 rem)` +to clear the always-on `lib/sidebar/sidebar.svelte`; below +1024 px the sidebar collapses to an overlay drawer, so the +default `right: 1.25 rem` matches the report's right padding. The +report-view itself adds a top padding equal to the trigger's +viewport offset plus its height so the first section's heading +does not render under the trigger at scroll position 0, and every +`
` gets `scroll-margin-top: 7.5rem` so +`scrollIntoView({ block: "start" })` lands the heading below the +trigger after a popover-driven jump. -Both surfaces also expose a "Back to map" affordance -(`report-back-to-map`) at the top. +On viewports below `768.98 px` the popover surface re-styles into +a fixed bottom-sheet anchored above the layout-owned bottom-tabs +bar (mirrors `lib/active-view/map-toggles.svelte`), so the same +trigger and the same menuitem list serve desktop and mobile. + +Open/close state matches the `map-toggles.svelte` precedent: +Escape closes, outside click closes, item-pick closes. The +`restoreFocus` action returns keyboard focus to the trigger on +dismount. The menu is non-modal — no focus trap. The active slug is computed by an `IntersectionObserver` rooted on the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`. The skew biases the active band toward the upper third of the visible area so that scrolling down advances the highlight naturally. The observer is created on mount and torn down on -unmount. +unmount. The TOC itself owns no observer — the orchestrator passes +`activeSlug` in as a prop. The in-game shell (`lib/game/game-shell.svelte`) expands `
` to fit content rather @@ -129,8 +158,10 @@ highlight stays consistent without a separate source of truth. All strings live under `game.report.*`: - `game.report.loading` — section loading placeholder. -- `game.report.back_to_map`, `game.report.toc.title`, - `game.report.toc.mobile_label` — shell-level strings. +- `game.report.toc.title` — `aria-label` on the TOC root; + `game.report.toc.open` / `game.report.toc.close` — `aria-label` + on the trigger button, swapped by the open state (mirrors + `game.map.toggles.open` / `close`). - `game.report.section..title` — section heading. - `game.report.section..empty` — empty-state copy (where applicable). @@ -151,19 +182,26 @@ couple them silently. shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid with conditional row state (`report-section-bombings.test.ts`), per-race sub-table (`report-section-foreign-sciences.test.ts`), - TOC (`report-toc.test.ts`). Each spec mounts the component - against a synthetic `RenderedReportSource`, so the orchestrator - / IntersectionObserver are out of scope. + TOC (`report-toc.test.ts`). The TOC spec exercises the icon-popup + state machine (trigger label, open/close, menuitem list, active + highlight, scroll-on-pick, Escape, outside-click). Each spec + mounts the component against a synthetic `RenderedReportSource`, + so the orchestrator / IntersectionObserver are out of scope. - **Playwright** — `tests/e2e/report-sections.spec.ts` exercises - the full integration: every TOC anchor lands its section in - view, the back-to-map button switches to the map view - (`activeView.select("map")`), and the mobile ` {#each entries as entry (entry.slug)} - + {/each} - - - + + {/if} + diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts index 8e51f42..233dbd9 100644 --- a/ui/frontend/src/lib/i18n/locales/en.ts +++ b/ui/frontend/src/lib/i18n/locales/en.ts @@ -603,9 +603,9 @@ const en = { "game.inspector.planet.ship_groups.race.unknown": "unknown", "game.report.loading": "loading report…", - "game.report.back_to_map": "back to map", "game.report.toc.title": "sections", - "game.report.toc.mobile_label": "jump to section", + "game.report.toc.open": "show section list", + "game.report.toc.close": "hide section list", "game.report.section.galaxy_summary.title": "galaxy summary", "game.report.section.galaxy_summary.field.turn": "turn", "game.report.section.galaxy_summary.field.size": "map size", diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts index a2994e7..729a534 100644 --- a/ui/frontend/src/lib/i18n/locales/ru.ts +++ b/ui/frontend/src/lib/i18n/locales/ru.ts @@ -604,9 +604,9 @@ const ru: Record = { "game.inspector.planet.ship_groups.race.unknown": "неизвестно", "game.report.loading": "загрузка отчёта…", - "game.report.back_to_map": "назад к карте", "game.report.toc.title": "разделы", - "game.report.toc.mobile_label": "перейти к разделу", + "game.report.toc.open": "показать список разделов", + "game.report.toc.close": "скрыть список разделов", "game.report.section.galaxy_summary.title": "общие сведения о галактике", "game.report.section.galaxy_summary.field.turn": "ход", "game.report.section.galaxy_summary.field.size": "размер карты", diff --git a/ui/frontend/tests/e2e/report-sections.spec.ts b/ui/frontend/tests/e2e/report-sections.spec.ts index b66b816..cfd2074 100644 --- a/ui/frontend/tests/e2e/report-sections.spec.ts +++ b/ui/frontend/tests/e2e/report-sections.spec.ts @@ -3,12 +3,16 @@ // the orchestrator's sections render, then drives the page through // the targeted-test contract: // -// 1. Every TOC anchor click scrolls the matching section into view -// and the section is present in the DOM with at least one row -// (or its empty-state copy when it is intentionally empty). -// 2. The "back to map" button switches to the map view. -// 3. The mobile ` into a +// single sticky icon-popup trigger; the in-report "Back to map" +// button was removed (the affordance lives in the app-shell view +// menu, exercised by `game-shell.spec.ts`). import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { expect, test, type Page } from "@playwright/test"; @@ -36,9 +40,9 @@ const BATTLE_ID = "00000000-0000-0000-0000-000000000001"; // SECTIONS lists every TOC slug paired with a row-presence hook. // `expectRow` is null for sections that the seeded report // intentionally leaves empty so the empty-state copy is asserted -// instead. The orchestrator's section order must match this -// list — the spec relies on each slug having a `report-toc-` -// and a `report-section-` testid. +// instead. The orchestrator's section order must match this list — +// the spec relies on each slug having a `report-toc-item-` in +// the popover and a `report-section-` testid in the body. const SECTIONS: ReadonlyArray<{ slug: string; expectRow: string | null }> = [ { slug: "galaxy-summary", expectRow: "galaxy-summary-field-turn" }, { slug: "votes", expectRow: "votes-mine" }, @@ -226,7 +230,7 @@ async function bootSession(page: Page): Promise { } test.describe("Phase 23 report view", () => { - test("every TOC anchor lands its section in view", async ({ + test("every popover menuitem lands its section in view", async ({ page, }, testInfo) => { test.skip( @@ -252,9 +256,16 @@ test.describe("Phase 23 report view", () => { page.getByTestId("galaxy-summary-field-turn"), ).toBeVisible(); + const trigger = page.getByTestId("report-toc-trigger"); + const surface = page.getByTestId("report-toc-surface"); + for (const entry of SECTIONS) { - const anchor = page.getByTestId(`report-toc-${entry.slug}`); - await anchor.click(); + await trigger.click(); + await expect(surface).toBeVisible(); + await page.getByTestId(`report-toc-item-${entry.slug}`).click(); + // The popover closes on selection and the section + // scrolls into view. + await expect(surface).toHaveCount(0); const section = page.getByTestId(`report-section-${entry.slug}`); await expect(section).toBeInViewport(); if (entry.expectRow !== null) { @@ -280,30 +291,12 @@ test.describe("Phase 23 report view", () => { // scroll-restoration the test asserted exist any more. Re-adding that // behaviour would be a production change outside this test migration. - test("back-to-map button switches to the map view", async ({ - page, - }, testInfo) => { - test.skip( - testInfo.project.name.startsWith("chromium-mobile"), - "navigation is identical on mobile", - ); + // F8-09 removed the in-report "Back to map" button — the same + // affordance lives in the app-shell view menu, exercised by + // `game-shell.spec.ts` ("header view-menu navigates to every + // active view"). - await mockGateway(page); - await bootSession(page); - await page.goto("/"); - await page.waitForFunction(() => window.__galaxyNav !== undefined); - await page.evaluate( - (id) => window.__galaxyNav!.enterGame(id, "report", {}), - GAME_ID, - ); - - await page.getByTestId("report-back-to-map").click(); - // The single-URL app-shell keeps the address bar at the app base; - // the active map view is the navigation signal. - await expect(page.getByTestId("active-view-map")).toBeVisible(); - }); - - test("mobile select scrolls to the chosen section", async ({ + test("mobile bottom-sheet popover lands the chosen section", async ({ page, }, testInfo) => { test.skip( @@ -320,12 +313,25 @@ test.describe("Phase 23 report view", () => { GAME_ID, ); - const mobileSelect = page.getByTestId("report-toc-mobile"); - await expect(mobileSelect).toBeVisible(); await expect( page.getByTestId("galaxy-summary-field-turn"), ).toBeVisible(); - await mobileSelect.selectOption("bombings"); + + const trigger = page.getByTestId("report-toc-trigger"); + await expect(trigger).toBeVisible(); + await trigger.click(); + + const surface = page.getByTestId("report-toc-surface"); + await expect(surface).toBeVisible(); + // Below 768 px the surface re-styles into a fixed + // bottom-sheet anchored above the bottom-tabs bar. + const position = await surface.evaluate( + (el) => getComputedStyle(el).position, + ); + expect(position).toBe("fixed"); + + await page.getByTestId("report-toc-item-bombings").click(); + await expect(surface).toHaveCount(0); await expect( page.getByTestId("report-section-bombings"), ).toBeInViewport(); diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts index 6b27a80..40bbd31 100644 --- a/ui/frontend/tests/game-shell-stubs.test.ts +++ b/ui/frontend/tests/game-shell-stubs.test.ts @@ -63,17 +63,19 @@ describe("active-view stubs", () => { expect(node).toHaveTextContent("ship groups"); }); - test("report view mounts with the TOC and the back-to-map link", () => { + test("report view mounts with the icon-popup TOC", () => { // Phase 23 replaces the Phase 10 stub with the full report // orchestrator. The orchestrator mounts the table of contents // regardless of report state; the inner sections render // loading copy until a `RenderedReportSource` lands via // context. This test only smokes the orchestrator scaffold — // per-section assertions live in `report-section-*.test.ts`. + // F8-09 collapsed the TOC into a single sticky icon-popup + // trigger; "Back to map" lives in the app-shell view menu. const r = render(ReportView); expect(r.getByTestId("active-view-report")).toBeInTheDocument(); expect(r.getByTestId("report-toc")).toBeInTheDocument(); - expect(r.getByTestId("report-back-to-map")).toBeInTheDocument(); + expect(r.getByTestId("report-toc-trigger")).toBeInTheDocument(); }); test("mail stub renders its localised title", () => { diff --git a/ui/frontend/tests/report-toc.test.ts b/ui/frontend/tests/report-toc.test.ts index a9d09ab..1b7f3ee 100644 --- a/ui/frontend/tests/report-toc.test.ts +++ b/ui/frontend/tests/report-toc.test.ts @@ -1,9 +1,10 @@ -// Vitest coverage for the Phase 23 Report View's table of contents. -// Smokes the anchor list, the active-link state, the back-to-map -// navigation, and the mobile