ui: plan 01-27 done #1
Reference in New Issue
Block a user
Delete Branch "ai/ui-client"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the login form, the layout blocker page, and the lobby placeholder. SUPPORTED_LOCALES drives both the picker and the runtime lookup; adding a language is a two-step change inside `src/lib/i18n/`. Login form gains a globe-icon language dropdown (English / Русский in their native names), defaulting to navigator.languages with `en` as the fallback. Switching the locale re-renders the form in place; on submit, the locale rides in the JSON body of `send-email-code` because Safari/WebKit silently drops JS-set Accept-Language. Gateway gains a body `locale` field that takes priority over the request header for preferred-language resolution. Email and code inputs disable browser autofill / suggestions (`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` + `spellcheck=false`) so Keychain / address-book pickers and remembered-value dropdowns no longer fire on focus. Cross-cuts: - backend & gateway openapi: clarify that body `locale` is honored. - docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority. - gateway tests: body `locale` overrides Accept-Language; blank body `locale` falls back to header. - new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games list, my-applications/invites lists, game-create, application-submit, invite-redeem/decline. Mirror the matching transcoder pairs and Go fixture round-trip tests. - Wire the seven new lobby message types through gateway/internal/backendclient/{routes,lobby_commands}.go with per-command REST helpers, JSON-tolerant decoding of backend wire shapes, and httptest-based unit coverage for success / 4xx / 5xx / 503 across each command. - Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a `make fbs-ts` target driving flatc, and the generated bindings under ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode now uses these bindings as well, closing the JSON.parse vs FlatBuffers gap that would have failed against a real local stack. - Replace the placeholder lobby with five sections (my games, pending invitations, my applications, public games, create new game) and the /lobby/create form. Submit-application uses an inline race-name form on the public-game card; create-game keeps name / description / turn_schedule / enrollment_ends_at always visible and the rest under an Advanced toggle with TS-side defaults. - Update lobby/+page.svelte to throw LobbyError on non-ok result codes; GalaxyClient.executeCommand now returns { resultCode, payloadBytes }. - Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page + lobby-create component tests, Playwright lobby-flow.spec covering create / submit / accept across all four projects. Phase 7 e2e was migrated to the FlatBuffers fixtures and to click+fill against the Safari-autofill readonly inputs. - Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into Phase 7, append the new lobby commands to gateway/README.md and docs/ARCHITECTURE.md, add ui/docs/lobby.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`time.LoadLocation` is called from backend/internal/server/handlers_public_auth.go:108 (confirm-email-code) and backend/internal/user/account.go:218 (user.settings.update). Both runtime images shipped today have no tzdata — production backend/Dockerfile uses gcr.io/distroless/static-debian12:nonroot, and local-dev tools/local-dev/backend.Dockerfile uses alpine:3.20 without the optional tzdata apk — so the container-side binary resolves only the no-data fallback (UTC and fixed offsets) and rejects every real IANA zone with HTTP 400 `invalid_request: time_zone must be a valid IANA zone`. Adding `import _ "time/tzdata"` to backend's main is the idiomatic Go fix: the binary embeds the IANA database, time.LoadLocation works on every base image, no Dockerfile changes needed. Cost is ~800 KB of binary growth — invisible next to the existing /usr/local/bin/backend size and well below any container layer threshold. The OpenAPI spec already documents the field as "IANA time-zone identifier" (gateway/openapi.yaml:205, backend/openapi.yaml:2334) and the UI sends Intl.DateTimeFormat().resolvedOptions().timeZone, so neither the contract nor the client needs a change. Why this slipped through: backend unit tests run as a host Go test process (developer's tzdata covers them), Playwright tests mock the gateway (backend never reached), and the integration suite — the only layer that exercises the real backend container — uses RegisterSession which hardcoded `time_zone="UTC"`. Switching that default to "Europe/Berlin" makes every integration scenario that enrols a pilot exercise the tzdata path, so the next regression surfaces in the integration run instead of escaping into manual smoke. (The integration suite is not in the per-PR workflow yet; that gap is tracked separately.) Verified end-to-end against `tools/local-dev`: - Europe/Amsterdam, Asia/Tokyo, America/Los_Angeles → 200 + device_session_id (was 400 before this patch). - Mars/Olympus still → 400 (validation behaviour unchanged). Host tests: backend/internal/{auth,user,config} green. UI: pnpm test 14/14, CI=1 pnpm exec playwright test 44/44. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Wraps every in-game route under `/games/:id/*` in a responsive shell with a header (race / turn placeholders, view-menu dropdown or mobile hamburger, account menu), a three-tab sidebar (Calculator, Inspector, Order), an active-view slot, and a mobile-only bottom-tabs row `[Map, Calc, Order, More]`. Every view in the IA section (`map`, `table/:entity`, `report`, `battle/:battleId?`, `mail`, `designer/{ship-class,science}/:id?`) ships as a thin SvelteKit route that mounts a `lib/active-view/<name>.svelte` stub rendering a localised `coming soon` body. The lobby's `gotoGame` path now actually lands on a rendered shell instead of a 404. The "view router" mentioned in the plan is implemented as the file system plus two-line route wrappers — no separate dispatch component. Sidebar tab state lives as a `$state` rune inside `sidebar.svelte`, which sits in the layout that SvelteKit keeps mounted across child route swaps, so tab choice survives every active-view navigation for free. A `?sidebar=calc|inspector|order` URL param seeds the initial tab on first mount; the mobile bottom-tabs use a layout-owned `mobileTool` rune with a URL-gated `effectiveTool` derivation so the Calc / Order tool overlay only applies on `/map` and naturally drops when the user navigates elsewhere. Tablet ships with a click-toggle drawer for the sidebar rather than the IA section's swipe-from-right gesture; the structural breakpoint satisfies Phase 10's acceptance criterion and Phase 35 polish lands the swipe. The mobile More drawer mirrors the header view-menu content; the IA's narrower More list (Mail, Battle, Tables, History, Settings, Logout) is also a Phase 35 polish target once History exists. Topic doc `ui/docs/navigation.md` captures the active-view model, the sidebar state-preservation rule, the `?sidebar=` and `mobileTool` conventions, and the transient map-overlay back-stack concept (with the implementation deferred to Phase 34 alongside its first user). i18n catalogues for `en` and `ru` add the full `game.shell.*`, `game.view.*`, `game.sidebar.*`, `game.bottom_tabs.*` namespaces. Tests: Vitest covers the header view-menu (every IA destination including the Tables sub-list), the account-menu Logout / Language wiring, the sidebar default tab / switching / `?sidebar=` seed / close button, and every active-view stub. Playwright e2e boots an authenticated session via `__galaxyDebug.setDeviceSessionId` (no gateway calls — the shell makes none in Phase 10), exercises every view through both the desktop dropdown and the mobile More drawer, verifies sidebar tab survival across navigation, and uses `setViewportSize` to validate the breakpoint switches at 768 px and 1024 px. Phase 10 status stays `pending` in `ui/PLAN.md` until the local-ci run lands green; flipping to `done` follows in the next commit per the per-stage CI gate in `CLAUDE.md`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>The renderer's torus mode laid out the world in a 3×3 grid of wrap copies (TORUS_OFFSETS) so the user could pan past an edge without seeing a void. Below `minScale = max(viewport/world)` the world shrinks below the viewport along at least one axis and the wrap copies become visible side-by-side — the user reported a 9-tile mosaic that pans and zooms as one rigid unit. The doc explicitly deferred the fix ("if profiling ever reveals that users do this"); real usage is the trigger. Apply `clampZoom({ minScale })` in both modes; torus still keeps free pan (no `clamp({ direction: "all" })`) so the wrap copies fill the cross-edge slack as designed. Resize re-evaluates the clamp so a window resize does not strand the camera below the new floor. Documentation in `ui/docs/renderer.md` updated to describe the new shared invariant. Regression test in `tests/e2e/playground-map.spec.ts` wheels out aggressively in torus mode and asserts `camera.scale >= minScale` across all four Playwright projects. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Wires the first end-to-end command through the full pipeline: inspector rename action → local order draft → user.games.order submit → optimistic overlay on map / inspector → server hydration on cache miss via the new user.games.order.get message type. Backend: GET /api/v1/user/games/{id}/orders forwards to engine GET /api/v1/order. Gateway parses the engine PUT response into the extended UserGamesOrderResponse FBS envelope and adds executeUserGamesOrderGet for the read-back path. Frontend ports ValidateTypeName to TS, lands the inline rename editor + Submit button, and exposes a renderedReport context so consumers see the overlay-applied snapshot. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>The owner reported two symptoms after pulling the Phase 14 stack: 1. user.games.order.get answered with `unimplemented: message_type is not routed`. The gateway/backend code was correct, but the local-dev compose images were stale — `make rebuild` picked up the new routes table and the symptom went away. To prevent this class of regression from depending on docker-image freshness, gateway/internal/backendclient/routes_test.go now asserts that every authenticated MessageType constant declared in pkg/model/{user,lobby,order,report} is registered, and verifies that user.games.order.get specifically resolves to the game command client. 2. The inspector kept the un-renamed name after a successful submit. ui/frontend/tests/inspector-overlay.test.ts mounts the inspector tab against a real OrderDraftStore + a stubbed GameStateStore and walks the full happy path (add planetRename → markSubmitting → applied → simulate refresh) plus the integration scenario driven through the order-tab Submit button. Both cases pass — the underlying overlay path is reactive and resilient to a refresh that returns the un-renamed snapshot. The original in-browser symptom was the rebuilt-image freshness issue from point 1; this test pins the reactive contract for future refactors. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Wires pkg/calc/ship.go into the WASM Core boundary as seven thin wrappers (DriveEffective, EmptyMass, WeaponsBlockMass, FullMass, Speed, CargoCapacity, CarryingMass). The ship-class designer reads Core through a new CORE_CONTEXT_KEY populated by the in-game layout and renders a five-row preview pane (mass, full-load mass, max speed, range at full load, cargo capacity) that updates reactively on every form edit and on the player's localPlayer{Drive,Weapons, Shields,Cargo} tech levels — three of which are now decoded from the report's Player block alongside the existing localPlayerDrive. CarryingMass is the seventh wrapper added to the original six-function list so that "full-load mass" composes through pkg/calc/ functions without putting math in TypeScript.The parity rule from ui/PLAN.md says every UI phase that decodes a new Report field must extend the legacy converter in lockstep. Phase 19 brings ship groups (LocalGroup / OtherGroup / UnidentifiedGroup / IncomingGroup) and LocalFleet onto the wire- compatible UI surface; this commit teaches tools/local-dev/legacy-report to populate the three sections that exist in the legacy text format: - "Your Groups" → []LocalGroup. Cargo type, load, fleet name, state, on-planet vs hyperspace position (origin / range) all decoded; LocalGroup.ID is synthesised deterministically from the per-report group index so re-running the converter produces byte-identical JSON. Speed is left zero — the legacy table doesn't expose it. - "Your Fleets" → []LocalFleet. Origin / range / state mirror the row layout used by Killer / Tancordia variants; gplus's state-less rows still resolve. - "Incoming Groups" → []IncomingGroup. Origin / destination names — and `#NN` by-id references — resolve against the parsed planet tables. Because the section can land before "Your Planets" in some engines, group / fleet / incoming rows are buffered and resolved in `parser.finish` after every planet is known. Battles, OtherGroup (only ever in battle rosters), and UnidentifiedGroup stay out of scope — README.md spells out what remains not-derivable. Adds Killer031–033 / TSERCON_Z032–033 / Tancordia036–039 fixtures to the dg directory and exercises three of them through new TestParseDg{Killer031,Tancordia037,KNNTS041} smoke tests, plus inline tests for each new section parser. Drops the stale KNNTS039.json artefact left over from Phase 18 development. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Wires Phase 19's data and rendering layers without yet adding the inspector UI: - game-state.ts grows ReportLocalShipGroup / ReportOtherShipGroup / ReportIncomingShipGroup / ReportUnidentifiedShipGroup / ReportLocalFleet types and walks the matching FlatBuffers vectors (LocalGroup, OtherGroup, IncomingGroup, UnidentifiedGroup, LocalFleet) inside decodeReport. The Tech map is folded into the fixed-shape ShipGroupTech struct; cargo strings normalise to the closed CargoLoadType | "NONE" union; UUIDs come back as canonical 36-char strings. - synthetic-report.ts mirrors the new fields so the DEV-only lobby loader can feed JSON produced by legacy-report-to-json straight into the live UI surface. - selection.svelte.ts widens its discriminated union with a `kind: "shipGroup"` branch carrying a ShipGroupRef (local UUID / other / incoming / unidentified by index). - world.ts adds Style.strokeDashPx and render.ts.drawLine honours it via manual segmentation (PixiJS v8 has no native dash API). Ignored on points and circles. - state-binding.ts now returns { world, hitLookup }: the hit-lookup map keys every primitive id back to a concrete HitTarget so the click handler can dispatch to selectPlanet or selectShipGroup. Ship-group primitives live in a separate ship-groups.ts that emits one point per local / other / unidentified group, plus a dashed origin→destination line + clickable point per incoming group. Position is interpolated along the trajectory for in-hyperspace groups. - map.svelte threads the hitLookup into handleMapClick. Vitest: - tests/helpers/empty-ship-groups.ts exposes EMPTY_SHIP_GROUPS so existing fixtures can spread the new five empty arrays without enumerating every field. - state-binding-groups.test.ts covers each group variant's primitive geometry and lookup correctness. - All previously-existing fixture builders pick up the spread so GameReport stays a complete object. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Closes Phase 19's UI surface. The inspector dispatches on the selection variant: local / other groups render class, count, the four tech levels, mass, cargo (type + amount when loaded), location (planet name on-orbit, from/to/distance in hyperspace), and — for local groups only — fleet membership + state. Incoming groups surface origin / destination / distance / speed and the inline ETA = ceil(distance / speed); zero speed collapses to the designer's existing "—" placeholder. Unidentified groups render just the (x, y) coordinates and the no-data hint, mirroring the unidentified planet treatment. Layout / inspector-tab plumbing: - inspector-tab.svelte derives selectedShipGroup against the rendered report and mounts <ShipGroup /> when the planet branch doesn't match. Stale refs (an index that no longer resolves after a turn refresh) collapse cleanly to the empty state. - +layout.svelte mounts <ShipGroupSheet /> alongside the existing planet sheet on mobile; both share the `effectiveTool === "map"` guard and clear-on-close. i18n: en + ru both grow ~30 keys under `game.inspector.ship_group.*`. Adding a key to one without the other is a TS error (TranslationKey is `keyof typeof en`), so the Russian mirror stays mandatory. Tests: - inspector-ship-group.test.ts exercises every variant — on-planet local, in-hyperspace local, cargo-loaded local, foreign, incoming with ETA, incoming with zero speed, unidentified, plus the missing-planet `#NN` fallback. - tests/e2e/inspector-ship-group.spec.ts is a smoke spec that drives the DEV-only synthetic-report loader from /lobby through navigation to /games/synthetic-XXX/map. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Two follow-up fixes after the initial Phase 19 landing: 1. The IncomingGroup dashed trajectory was drawn between raw (x1, y1) and (x2, y2) world coordinates. On torus wrap mode this took the long way around when origin and destination sat near opposite seams. The line now picks endpoints via `torusShortestDelta` so the segment crosses the seam when that's the shorter visual path. The interpolated clickable point follows the same unwrapped vector. The same helper fixes the in-hyperspace position for local / foreign groups. 2. On-planet local and foreign groups previously rendered as small offset points next to every populated planet, which turned the canvas into noise as soon as a player held more than a handful of planets. The map no longer renders any in-orbit group; the planet inspector grows a compact "stationed ship groups" subsection (`lib/inspectors/planet/ship-groups.svelte`) that lists each in-orbit group as a row of `<race> · <class> · <count> ships · <mass>`. Race attribution: LocalGroup → the player's race, OtherGroup on a foreign-owned planet → the planet's owner, OtherGroup elsewhere → "foreign" placeholder. Rows are non-interactive in Phase 19; Phase 21+ will deep-link into the ship-groups table view with a (planet, race) filter. Tests: - `state-binding-groups.test.ts` swaps the on-planet rendering expectation for the new "no map primitive" rule, and adds a regression that asserts the incoming line crosses the torus seam via `torusShortestDelta`. - new `inspector-planet-ship-groups.test.ts` covers row composition, the destination-mismatch filter, the in-hyperspace exclusion, the foreign-planet owner fallback, and the empty-state collapse. - `inspector-planet.test.ts` and `inspector-ship-group.spec.ts` pick up the new prop chain (`localShipGroups`, `otherShipGroups`, `localRace`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>The renderer-mount effect in `lib/active-view/map.svelte` reads `mounted` to gate the runSerializedMount call, but the variable was declared as a plain `let`, not `$state`. On the first navigation to /map this is benign: the effect's first pass returns early (gameState still hydrating, `report` null), and once `report` arrives the effect re-fires — by which point `onMount` has already flipped `mounted = true`. On every subsequent return to /map the report is already loaded by the long-lived gameState in the layout. The effect therefore makes exactly one pass on the freshly-mounted component, gates on `mounted === false` (the brand-new instance has not run `onMount` yet), and never wakes up again because no tracked state changes afterwards. Symptom: black canvas — fresh DOM, no mount-error overlay, but Pixi never rebuilt the world on the new canvas. Convert `mounted` to `$state(false)` so flipping it true inside `onMount` triggers the effect's second pass, which now finds all preconditions satisfied and proceeds to `runSerializedMount`. The detailed lifecycle reasoning is preserved as a code comment so the next reader can see why this one variable must be reactive. Add tests/e2e/map-roundtrip.spec.ts: navigates /map → {report, ship-class designer, science designer, mail} → /map for each non-map view, then asserts the renderer republished primitives onto the DEV `__galaxyDebug.getMapPrimitives()` surface. The pre-fix build failed every variant; the patch lands all four green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>The compose stack hard-coded host ports (postgres 5433, redis 6380, mailpit 8025, gateway REST 8080, gateway gRPC 9090) — fine for a clean dev machine, painful when those ports collide with other services on the same host (e.g. a `crowdsec` sitting on 127.0.0.1:8080 or a Prometheus instance on :9090). Every host-port mapping is now `${LOCAL_DEV_*_PORT:-<old-default>}`, so the defaults match prior behaviour for everyone and a per-host override is a single environment variable away. `.env` carries the overrides as commented-out lines so the customisation surface is discoverable without grepping the compose file. README's "Port 8080 already in use" troubleshooting entry now points at the new variables and the optional `docker-compose.override.yml` workflow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Wires the gateway's signed SubscribeEvents stream end-to-end: - backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every current_turn advance, addressed to every active membership, push-only channel, idempotency key turn-ready:<game_id>:<turn>; - ui: single EventStream singleton replaces revocation-watcher.ts and carries both per-event dispatch and revocation detection; toast primitive (store + host) lives in lib/; GameStateStore gains pendingTurn/markPendingTurn/advanceToPending and a persisted lastViewedTurn so a return after multiple turns surfaces the same "view now" affordance as a live push event; - mandatory event-signature verification through ui/core (verifyPayloadHash + verifyEvent), full-jitter exponential backoff 1s -> 30s on transient failure, signOut("revoked") on Unauthenticated or clean end-of-stream; - catalog and migration accept the new kind; tests cover producer (testcontainers + capturing publisher), consumer (Vitest event stream, toast, game-state extensions), and a Playwright e2e delivering a signed frame to the live UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>TestBuildClientPushEventCoversCatalog required every catalog kind to encode through a FlatBuffers `preMarshaledEvent`. game.turn.ready intentionally rides on the JSON fallback because its payload is just `{game_id, turn}` and the only consumer (Phase 24 UI handler) parses JSON inline. Make the policy explicit through a jsonFriendlyKinds allow-list so the test still asserts each kind is covered and a future producer that picks the wrong encoding fails loudly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>Engine wire change: Report.battle switched from []uuid.UUID to []BattleSummary{id, planet, shots} so the map can place battle markers without N extra fetches. FBS schema + generated Go/TS regenerated; transcoder + report controller updated; openapi adds the BattleSummary schema with a freeze test. Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as /api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler plus engineclient.FetchBattle, contract test stub, openapi spec). UI: - BattleViewer (lib/battle-player/) is a logically isolated SVG radial scene that consumes a BattleReport prop. Planet at the centre, races on the outer ring at equal angular spacing, race clusters by (race, className) with <class>:<numLeft> labels; observer groups (inBattle: false) are not drawn; eliminated races drop out and survivors re-distribute on the next frame. - Shot line per frame: red on destroyed, green otherwise; erased on the next frame. Playback controls: play/pause + step ± + rewind + 1x/2x/4x speed (400/200/100 ms per frame). - Page wrapper (lib/active-view/battle.svelte) loads BattleReport via api/battle-fetch.ts; synthetic-gameId prefix routes to a fixture loader, otherwise REST through the gateway. Always- visible <ol> text protocol satisfies the accessibility ask. - section-battles.svelte links every battle UUID into the viewer. - map/battle-markers.ts: yellow X cross of 2 LinePrim through the corners of the planet's circumscribed square (stroke width clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing marker is a stroke-only ring (yellow when damaged, red when wiped). Wired into state-binding.ts; click handler dispatches battle clicks to the viewer and bombing clicks to the matching Reports row. - i18n keys for the viewer in en + ru. Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push event, richer class visuals, animated re-distribution). Tests: Vitest unit (radial layout + timeline frame builder + marker stroke formula + marker primitives), Playwright e2e for the viewer (Reports link → viewer, playback step, not-found), backend engineclient FetchBattle (200 / 404 / bad input), engine openapi freezes (BattleReport, BattleReportGroup, BattleActionReport, BattleSummary, Report.battle items). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Side activity on top of Phase 27: the legacy-report tool now extracts the "Battle at (#N) Name" / "Battle Protocol" blocks the parser used to skip. Both the per-battle summary (Report.Battle: []BattleSummary) and the full BattleReport (rosters + protocol) flow through. Parser: - new sectionBattle / sectionBattleProtocol states, with handle() trapping the per-race "<Race> Groups" sub-headers so the roster stays attributed to the right race; - parseBattleHeader extracts (planet, planetName) from "Battle at (#NN) <Name>"; - parseBattleRosterRow maps the 10-token row into BattleReportGroup; column 8 ("L") is NumberLeft, confirmed against KNNTS fixtures; - parseBattleProtocolLine counts shots and builds BattleActionReport entries from the 8-token "X Y fires on A B : Destroyed|Shields" lines; - flushPendingBattle finalises a battle on next "Battle at" or any top-level section change and appends both the summary and the full report; - syntheticBattleID(idx) + syntheticBattleRaceID(name) synthesise stable UUIDs in dedicated namespaces so re-runs produce byte-identical JSON. Parse() signature widens to (Report, []BattleReport, error); the single caller — the CLI — is updated. CLI emits a v1 envelope: { "version": 1, "report": <Report>, "battles": { <uuid>: <BR>, ... } } Bare-Report JSONs still load on the UI side for backward compat. UI synthetic loader: loadSyntheticReportFromJSON detects the v1 envelope, decodes the report as before, and forwards every battle through registerSyntheticBattle so the Battle Viewer resolves any UUID offline. Pre-envelope JSON files (no `version` field) still load — the battle registry stays empty for them. Docs: legacy-report README moves Battles from "Skipped" to in-scope, documents the envelope and UUID namespaces; docs/FUNCTIONAL.md §6.5 (and the ru mirror) note that synthetic mode is now end-to-end via the envelope. Tests: - TestParseBattles covers two battles with full rosters, per-shot destroyed/shielded mapping, NumberLeft from column 8, deterministic UUIDs across re-parses, and proves a trailing top-level section still parses (battle state closes cleanly); - smokeWant gains a battles count; runSmoke cross-checks BattleSummary ↔ BattleReport alignment (id/planet/shots); - all six real-fixture smoke tests pinned to their `Battle at` counts (28, 79, 56, 30, 83, 57); - Vitest covers the synthetic-report envelope path (battles forwarded, missing-battles tolerated, bare-Report backward compat); - KNNTS041.json regenerated against the new parser (existing diff was stale w.r.t. Phase 23 anyway; this commit brings it in line with the v1 envelope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Three Phase-27 BattleViewer refinements on top of the radial scene: 1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it never pushes the in-game shell past the viewport. `.active-view` gains `overflow: hidden` + flex column; `.viewer` becomes a `flex: 1` child; the always-visible text log shrinks to a 30 dvh ceiling with its own scroll. A global `body { margin: 0 }` reset (added to `app.html`) plugs the 16 px the browser's default body margin used to leak. 2. Mass-based ship-class circles. New `lib/battle-player/mass.ts` carries the radius formula and the per-battle FullMass compute: `MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`, clamped to `[6, 24] px`. FullMass goes through the existing wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no new wire fields. The viewer page resolves a `(race, className) → ShipClassRef` lookup from the parent GameReport's `localShipClass` + `otherShipClass` tables and passes it to the viewer via context. Unknown class or degenerate (weapons/armament) params fall back to MAX_RADIUS so the bucket stays visible. 3. Cloud cluster layout. Cluster key shifts from per-group `g.key` to `(raceId, className)` so tech-variants of the same hull collapse into one visual bucket. The horizontal classCircleX row is replaced by a Vogel sunflower spiral in the local `(u, v)` basis — `u` points from the race anchor to the planet, `v` is `u` rotated 90° clockwise. Buckets are sorted by NumberLeft desc; the cluster anchor is pushed inward by a quarter step so rank-0 sits closest to the planet. The step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`) so clusters with many classes do not spill into neighbours. Tests: - Vitest: `radiusForMass` covering zero / max / quarter-mass / out-of-range cases (6 cases). - Playwright: new `battle-viewer.spec.ts` case asserts `document.documentElement.scrollHeight - window.innerHeight ≤ 4` at a 1280×720 desktop viewport. The existing fixture gains `localShipClass` + `otherShipClass` so the lookup has data to render proportional circles. Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene" section (cloud layout, mass-based radius, height fit) and adds a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru mirror) get the one-line story about per-mass sizing, cluster aggregation, and the viewport-locked layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>