1b2c13ecd6
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>
208 lines
10 KiB
Markdown
208 lines
10 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 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.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`).
|