Files
Ilia Denisov c58027c034 ui/phase-23: turn-report view with twenty sections and TOC
Replaces the Phase 10 report stub with a scrollable orchestrator that
renders every FBS array as a dedicated section (galaxy summary, votes,
player status, my/foreign sciences, my/foreign ship classes, battles,
bombings, approaching groups, my/foreign/uninhabited/unknown planets,
ships in production, cargo routes, my fleets, my/foreign/unidentified
ship groups). A sticky table of contents (a <select> on mobile),
"back to map" affordance, IntersectionObserver-driven active-section
highlight, and SvelteKit Snapshot-based scroll save/restore round out
the view.

GameReport gains six new fields (players, otherScience, otherShipClass,
battleIds, bombings, shipProductions); decodeReport, the synthetic-
report loader, the e2e fixture builder, and EMPTY_SHIP_GROUPS extend
in lockstep. ~90 new i18n keys land in en + ru together.

The legacy-report parser is extended to populate the new sections from
the dg/gplus text formats (Your Sciences, <Race> Sciences, <Race> Ship
Types, Bombings, Ships In Production). Ships-in-production prod_used
is derived through a new pkg/calc.ShipBuildCost helper; the engine's
controller.ProduceShip refactors to call the same helper without any
behaviour change (engine tests stay unchanged and green). Battles
remain in the parser's Skipped list — the legacy text carries no
stable per-battle UUID.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:33:56 +02:00

8.4 KiB

Report view — Phase 23

The Phase 23 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           // anchor list + mobile <select>
├── 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 two surfaces driven by the same entry list:

  • 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.

Both surfaces also expose a "Back to map" affordance (report-back-to-map) at the top.

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 in-game shell layout (routes/games/[id]/+layout.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 save / restore

routes/games/[id]/report/+page.svelte exports a SvelteKit Snapshot<{ scrollY: number }>:

  • capture() reads window.scrollY when SvelteKit's beforeNavigate cycle runs.
  • restore(value) schedules a short requestAnimationFrame poll that waits for document.documentElement.scrollHeight to grow tall enough to honour the saved offset, then calls window.scrollTo(0, value). The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough body never pins a frame loop.

The capture / restore pair is keyed by route, so:

  • Forward navigation from /report to /map lands /map at scrollY 0 (no snapshot for /map to restore from).
  • History-back from /map to /report restores the previously captured scrollY — the user returns to the same section.

The Snapshot API does not capture the active sidebar slug; the IntersectionObserver re-derives it from the restored scroll position on the next animation frame, which keeps the TOC highlight consistent without a second source of truth.

i18n namespace

All Phase 23 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.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). 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 TOC anchor lands its section in view, the snapshot mechanism preserves window.scrollY on history navigation, the back-to-map button reaches /map, the mobile <select> scrolls to the chosen section on a narrow viewport.

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).