Files
galaxy-game/ui/docs/report-view.md
T
Ilia Denisov 1b2c13ecd6
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 2m44s
fix(ui): F8-09 owner-feedback — fixed TOC trigger + heading offset (#52)
Owner-reported regressions in Firefox + Safari on desktop after the
initial F8-09 patch landed:

1. The TOC trigger rode up with the page during scroll instead of
   staying pinned to the viewport (mobile worked, desktop did not).
2. Clicking a popover item scrolled the matching section so its
   heading went up under the chrome — only the table body was visible.

Root cause for (1): the in-game shell declares `overflow-y: auto`
on `.active-view-host` so mobile (where `.game-shell` is fixed at
`inset: 0`) has an internal scroll region. On desktop the host
grows with content, no overflow ever engages, and the document
body becomes the actual scroll container. Per CSS spec the host
remains the "scrollport" for any `position: sticky` descendant, so
the trigger inside the report column never sees the scroll event
and rides up with the body content.

Fix:

- Swap the trigger from `position: sticky` to `position: fixed`.
  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. Anchor at `top: 4rem` (below the in-game header), and
  on `min-width: 1024px` shift `right` by 18 rem to clear the
  always-on sidebar; below 1024 px the sidebar is an overlay so
  the default `right: 1.25rem` matches the report's right padding.
- Add `padding-top: 4.5rem` to `.report-view` (4rem mobile) so the
  first section heading does not land under the trigger at scroll
  position 0.
- Add `scroll-margin-top: 7.5rem` to every `<section
  id="report-…">` so `scrollIntoView({ block: "start" })` lands
  the heading below the trigger after a popover-driven jump.
- Sync `ui/docs/report-view.md` §"Table of contents and active
  highlight" with the new positioning rationale.

Tests: `pnpm check`, `pnpm test` (821), `pnpm test:e2e
report-sections` (4 projects) all green.

Refs: #52 (#43 umbrella).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:45:44 +02:00

10 KiB

Report view

The in-game "turn report" view is a single scrollable layout with twenty sections, one per array on the FBS Report table. The route file is the standard two-line wrapper; the orchestrator and the per-section components live under ui/frontend/src/lib/active-view/report/.

Component layout

lib/active-view/report.svelte is the orchestrator. It owns the section list, instantiates IntersectionObserver to track which section is active, and renders the table of contents alongside the section column.

report.svelte
├── report/report-toc.svelte           // sticky icon-popup section menu
├── report/section-galaxy-summary.svelte
├── report/section-votes.svelte
├── report/section-player-status.svelte
├── report/section-my-sciences.svelte
├── report/section-foreign-sciences.svelte
├── report/section-my-ship-classes.svelte
├── report/section-foreign-ship-classes.svelte
├── report/section-battles.svelte
├── report/section-bombings.svelte
├── report/section-approaching-groups.svelte
├── report/section-my-planets.svelte
├── report/section-ships-in-production.svelte
├── report/section-cargo-routes.svelte
├── report/section-foreign-planets.svelte
├── report/section-uninhabited-planets.svelte
├── report/section-unknown-planets.svelte
├── report/section-my-fleets.svelte
├── report/section-my-ship-groups.svelte
├── report/section-foreign-ship-groups.svelte
└── report/section-unidentified-groups.svelte

Each section component is self-contained:

  • reads RenderedReportSource from context;
  • renders the loading copy when rendered.report === null;
  • renders the empty-state copy when its array is empty;
  • otherwise emits a <section id="report-<slug>" data-testid="report-section-<slug>"> containing the relevant grid / list / kv-list.

No shared <Section> wrapper exists. The visual scaffolding (dark grid CSS, header style, status paragraph) is inlined per component. The CLAUDE.md "wait for the third real caller before extracting an abstraction" rule applies; with one shape per section, the per-section inline CSS is the smallest correct solution.

Shared formatters live in report/format.ts (formatPercent, formatCount, formatFloat, formatVotes, planetLabel).

Section order, data sources, empty copy

# Slug Data Empty copy (en)
1 galaxy-summary header turn / size / planet count / race never empty
2 votes myVotes, myVoteFor, races[].votesReceived "no votes cast yet"
3 player-status players[] (full roster, self + extinct) never empty
4 my-sciences localScience[] "no sciences defined yet"
5 foreign-sciences otherScience[], one sub-table per race "no foreign sciences observed yet"
6 my-ship-classes localShipClass[] "no ship classes designed yet"
7 foreign-ship-classes otherShipClass[], one sub-table per race "no foreign ship classes observed yet"
8 battles battleIds[] (inactive monospace spans) "no battles last turn"
9 bombings bombings[], wiped rows visually distinct "no bombings last turn"
10 approaching-groups incomingShipGroups[] "no approaching groups"
11 my-planets planets.filter(kind==="local") "no planets owned yet"
12 ships-in-production shipProductions[] "no ships in production"
13 cargo-routes routes[] (flattened to one row per entry) "no cargo routes set"
14 foreign-planets planets.filter(kind==="other") "no foreign planets observed"
15 uninhabited-planets planets.filter(kind==="uninhabited") "no uninhabited planets observed"
16 unknown-planets planets.filter(kind==="unidentified") "no unknown planets"
17 my-fleets localFleets[] "no fleets created yet"
18 my-ship-groups localShipGroups[] "no ship groups yet"
19 foreign-ship-groups otherShipGroups[] "no foreign ship groups observed"
20 unidentified-groups unidentifiedShipGroups[] "no unidentified groups"

The orchestrator iterates this list once for the TOC and once for the body — both surfaces stay in sync by construction.

Table of contents and active highlight

report/report-toc.svelte renders a single icon-popup trigger pinned to 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").

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 <section id="report-…"> gets scroll-margin-top: 7.5rem so scrollIntoView({ block: "start" }) lands the heading below the trigger after a popover-driven jump.

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. 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 than constraining it, so the document body is the actual scroll container — not the host. The IntersectionObserver root is null to match.

Scroll position

The report is the report active view; switching to another view is an in-memory activeView state change, not a navigation, and the report component is remounted when the user returns to it. The single-URL app-shell therefore does not carry SvelteKit's route-keyed Snapshot scroll save/restore — that mechanism was tied to the old /games/:id/report route and was removed with it. A re-entered report opens at the top; the IntersectionObserver re-derives the active TOC slug from the scroll position on the next animation frame, so the highlight stays consistent without a separate source of truth.

i18n namespace

All strings live under game.report.*:

  • game.report.loading — section loading placeholder.
  • game.report.toc.titlearia-label on the TOC root; game.report.toc.open / game.report.toc.closearia-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).
  • game.report.section.<slug>.column.<column> — column headings.
  • A small number of section-specific keys (bombings.wiped, player_status.local_marker, player_status.extinct_marker, foreign_sciences.race_header, foreign_ship_classes.race_header, battles.id_label, votes.target_none).

The namespace is intentionally separate from game.table.* even where the data shape overlaps (e.g. sciences, ship classes); the two surfaces evolve independently and a shared key set would couple them silently.

Test seams

  • Vitest — four representative specs cover the four section 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). 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.
  • Playwrighttests/e2e/report-sections.spec.ts exercises 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 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-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).