cfbe052242
- 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>
187 lines
9.3 KiB
Markdown
187 lines
9.3 KiB
Markdown
# 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 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"`).
|
|
|
|
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.
|
|
|
|
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.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).
|
|
- `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.
|
|
- **Playwright** — `tests/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](#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`).
|