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
+46 -29
View File
@@ -15,7 +15,7 @@ section column.
```
report.svelte
├── report/report-toc.svelte // anchor list + mobile <select>
├── report/report-toc.svelte // sticky icon-popup section menu
├── report/section-galaxy-summary.svelte
├── report/section-votes.svelte
├── report/section-player-status.svelte
@@ -85,27 +85,35 @@ the body — both surfaces stay in sync by construction.
## Table of contents and active highlight
`report/report-toc.svelte` renders two surfaces driven by the same
entry list:
`report/report-toc.svelte` renders a single sticky icon-popup
trigger in the top-right corner of the report column. The trigger
shows `≡` followed by the title of the currently-active section
(CSS-clamped with `text-overflow: ellipsis` so a long RU title
cannot bloat the button). Clicking opens an anchored popover
(`role="menu"`) that lists every section as a `role="menuitem"`
button; the active item gets `aria-current="location"` and the
`.active` class. A click on an item closes the popover and scrolls
the matching `<section id="report-<slug>">` into view via
`scrollIntoView` (with `prefers-reduced-motion` falling back to
`behavior: "auto"`).
- **Desktop / tablet sidebar** — sticky `<aside>` with vertical
anchor list. The anchor for the currently-visible section gets
`aria-current="location"` and an `.active` CSS class.
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS
and a sticky `<select>` 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.
On viewports below `768.98 px` the same 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.
Both surfaces also expose a "Back to map" affordance
(`report-back-to-map`) at the top.
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 `<main class="active-view-host">` to fit content rather
@@ -129,8 +137,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.<slug>.title` — section heading.
- `game.report.section.<slug>.empty` — empty-state copy (where
applicable).
@@ -151,19 +161,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 `<select>` scrolls
to the chosen section on a narrow viewport. The spec drives the
app-shell through `window.__galaxyNav` (the dev-only nav surface)
instead of `page.goto` per-view URLs. The old "scroll position
survives a `/map` round-trip via SvelteKit `Snapshot`" case was
dropped — see the [scroll position](#scroll-position) note.
the full integration: every popover menuitem click lands its
section in view and closes the popover, and on the
`chromium-mobile` project the same trigger surfaces a fixed
bottom-sheet popover instead of the anchored desktop variant.
The spec drives the app-shell through `window.__galaxyNav` (the
dev-only nav surface) instead of `page.goto` per-view URLs. The
old "scroll position survives a `/map` round-trip via SvelteKit
`Snapshot`" case was dropped — see the
[scroll position](#scroll-position) note. F8-09 also removed the
in-report "Back to map" test; the same affordance is exercised
through the app-shell view menu by `tests/e2e/game-shell.spec.ts`
("header view-menu navigates to every active view").
Test IDs follow the pattern `report-section-<slug>` for section
roots, `report-toc-<slug>` for TOC anchors, and per-section row
identifiers (e.g. `report-bombing-row`, `my-planets-row`).
roots, `report-toc-trigger` / `report-toc-surface` for the popup
shell, `report-toc-item-<slug>` for each menuitem, and per-section
row identifiers (e.g. `report-bombing-row`, `my-planets-row`).