feat(ui): F8-09 — turn report sticky icon-popup section menu (#52)
Tests · UI / test (push) Successful in 2m45s
Tests · UI / test (pull_request) Successful in 2m52s

- Replace the 14 rem sticky sidebar (and its mobile <select> twin)
  with a single sticky icon-popup trigger pinned to the top-right
  corner of the report column. Trigger shows `≡` followed by the
  currently active section title (CSS-clamped with text-overflow:
  ellipsis so long RU titles cannot bloat the button). Click opens
  an anchored popover on desktop and a fixed bottom-sheet on
  <768.98 px (mirrors lib/active-view/map-toggles.svelte).
- Each menuitem closes the popover and scrolls the matching
  `<section id="report-<slug>">` into view. The scroll is deferred
  one animation frame so the surface unmount + restoreFocus's
  focus restoration on the (sticky) trigger commit first; otherwise
  the focus call could cancel the just-started smooth/instant
  scroll under desktop Chromium and WebKit.
- Drop the in-report "Back to map" button — the same affordance
  lives in the app-shell view menu (tests/e2e/game-shell.spec.ts
  covers it).
- Tighten the report grid to a single flex column so the section
  body now occupies the full container width.
- i18n: remove game.report.back_to_map and
  game.report.toc.mobile_label; add game.report.toc.open and
  game.report.toc.close (mirrors game.map.toggles.open/close).
- Tests: Vitest report-toc.test.ts rewritten for the new icon-popup
  contract; Playwright report-sections.spec.ts switches the anchor
  loop to trigger → menuitem and adds a mobile bottom-sheet
  assertion; game-shell-stubs.test.ts no longer asserts the
  back-to-map button on the report orchestrator.
- Docs: ui/docs/report-view.md (TOC + i18n + test seams) and
  docs/FUNCTIONAL{,_ru}.md §6.4 updated. The stale SvelteKit
  Snapshot reference (the route file was removed by the single-URL
  app-shell) is dropped at the same time.

Refs: #52 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-27 18:11:00 +02:00
parent 147c7d0a6a
commit cfbe052242
10 changed files with 383 additions and 321 deletions
+44 -38
View File
@@ -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 <select> fallback scrolls a section into view on
// a narrow viewport.
// 1. Every TOC menuitem 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. On a narrow viewport the same trigger surfaces a bottom-sheet
// popover and the same menuitem flow lands the chosen section.
//
// F8-09 collapsed the desktop sidebar and mobile `<select>` 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-<slug>`
// and a `report-section-<slug>` testid.
// instead. The orchestrator's section order must match this list —
// the spec relies on each slug having a `report-toc-item-<slug>` in
// the popover and a `report-section-<slug>` 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<void> {
}
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();