- The app root ("/", i.e. /game/) rendered a dev "workspace skeleton"
stub, and the layout guard only redirected anonymous users off it, so
an authenticated visitor stayed on the stub. Redirect "/" to /lobby
(authenticated) and /login (anonymous), and replace the stub with a
minimal loading placeholder. Drop the obsolete landing-stub unit test
(root redirect is covered by the auth-flow e2e).
- Ship a tombstone /service-worker.js on the project site so any old
root-scoped PWA worker (from when the game lived at the origin root)
unregisters itself instead of serving a stale cached page at the
site origin. The game now registers its worker only under /game/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-origin manifest now uses relative URLs (`start_url: "./"`) so
it stays base-agnostic under `/` and `/game/`. Update the PWA spec to
assert the relative value instead of the old absolute `/`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Serve the whole stack behind one host: site at /, game UI at /game/,
gateway REST at /api + /healthz, Connect at /rpc (prefix stripped by the
edge Caddy). The built artifact is domain-agnostic — the UI talks to the
gateway same-origin via relative URLs, so the same bundle runs under any
host with no rebuild and with CORS disabled.
- Rename the Connect proto service galaxy.gateway.v1.EdgeGateway ->
edge.v1.Gateway; regenerate Go + TS; public path /rpc/edge.v1.Gateway.
- Move the game UI under base path /game (env BASE_PATH); make the
manifest, service-worker scope, WASM loader, and all navigation
base-aware via a withBase helper.
- Relative API + /rpc Connect prefix; Vite dev proxy mirrors the strip.
- Rewrite the edge Caddy (dev + prod) for path-based routing; empty CORS
allow-lists (same-origin); single host.
- New VitePress project site (site/): i18n en/ru with switcher, LaTeX
math, minimal monospace theme; built and served at /.
- dev-deploy compose/Makefile + CI (dev-deploy, prod-build, new
site-build) build and seed the site; probes hit /, /game/, /healthz.
- Sync docs (ARCHITECTURE, gateway README/openapi, dev-deploy &
local-dev READMEs, CLAUDE.md, ui/PLAN).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The de-archaeology and the ui/docs index landed in the earlier reorg
(0 "Phase N" refs in ui/docs/; 24 topic docs linked). This finishes the
sync: ui/README.md gains a finalized-web-target summary and links the new
topic docs (design-system, a11y, error-state-ux, pwa-strategy).
docs/ARCHITECTURE.md and docs/FUNCTIONAL.md need no change — they cover
the backend/gateway/cross-service contracts with no stale UI statements;
the finalized UI is client-local UX documented under ui/docs/. Marks F7
done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SvelteKit's automatic SW registration also runs under `vite dev`, where
the worker intercepted/cached the dev-server e2e suite (42 failures).
Disable auto-registration (kit.serviceWorker.register: false) and
register the worker manually from the root layout guarded by `!dev`, so
`vite dev` and the e2e suite run worker-free while the production build —
and the PWA preview test — still install it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native SvelteKit service worker (src/service-worker.ts): a version-keyed
cache precaches the app shell + build artefacts (incl. core.wasm) +
static files; activate purges old caches; the gateway is never
intercepted; navigations fall back to the cached shell offline. Adds
static/manifest.webmanifest, a generated placeholder icon set
(scripts/gen-pwa-icons.mjs — dependency-free pure-Node PNG encoder), and
manifest / theme-color / apple-touch tags in app.html.
Gated by Playwright against a production preview (playwright.pwa.config.ts
+ tests/pwa/pwa.spec.ts via `pnpm test:pwa`, wired into ui-test):
manifest + installable icons, SW registration + a single version-keyed
cache, and offline shell load. Lighthouse is not used — its PWA category
was removed in v12.
Docs: ui/docs/pwa-strategy.md (+ index); F5 marked done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
core.wasm and wasm_exec.js are no longer tracked (untracked + gitignored).
A reusable composite action .gitea/actions/build-wasm installs TinyGo
(actions/cache'd) and runs `make -C ui wasm`; it runs in all three
frontend-building workflows — ui-test (before Playwright; Vitest uses the
fake Core and needs no build), dev-deploy, and prod-build. ui-test gains a
Go setup (TinyGo shells out to Go); the deploy workflows already had one.
Docs: ui/docs/wasm-toolchain.md, ui/README.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The F4 selection ring is a real map primitive. The cargo-route flow has
the source planet selected, so the total primitive count is 8 (7 + the
ring circle), not 7; the line count (3) is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The planet/ship-group sheets stay mounted on desktop but are hidden by a
media query (`display: none`); the document-level tap-outside listener
fired regardless, so the first click after selecting a planet cleared the
selection — breaking every desktop inspector/select flow in CI. Guard the
handler on the sheet's computed display (`offsetParent` is unreliable for
`position: fixed`). The swipe handle is naturally inert when hidden.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lib/error/: classify any caught error into a stable ErrorKind from the
transport signal (HTTP status / Connect Code / fetch TypeError /
navigator.onLine); map to translated error.* messages via reportError
(sticky Retry toast for retryable kinds) or errorMessageKey (inline).
Mail compose now surfaces the translated 403/error inline.
- lib/ui/view-state.svelte: shared loading/empty/error placeholder with
the right live-region role + optional action; entity tables
(races/sciences/ship-classes) migrated, rest adopt incrementally.
- map/selection-ring.ts: accent ring around the selected planet, fed into
the map buildExtras alongside the reach circles.
- lib/ui/sheet-dismiss.ts: tap-outside + drag-handle swipe-down dismissal
for the planet/ship-group bottom-sheets (hand-rolled pointer events).
Tests: error, view-state, selection-ring, sheet-dismiss (761 total).
Docs: ui/docs/error-state-ux.md (+ index); F4 marked done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
An audit found the client already i18n-first: one hard-coded UI string
(the battle-scene aria-label, now keyed) and en/ru already share an
identical 692-key set.
- Persist the locale: i18n.setLocale writes localStorage (galaxy-locale)
and the store boots from stored > browser detection > default, so a
language switch survives reloads.
- tests/i18n-completeness.test.ts: en/ru key-set parity, non-empty
values, and locale persistence.
- Docs: ui/docs/i18n.md; mark F3 done in ui/PLAN-finalize.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the default theme now following the OS, Playwright renders the light
theme, where the previous light `--color-danger` (#c84d4d, ~3.9:1 on a
near-white surface) failed WCAG 1.4.3 on error text — caught by the axe
scan of the science designer's empty-name error. Darken light
`--color-danger` to #c0392b (~5.5:1 on white; white-on-danger fills stay
≥5:1). Dark theme unchanged.
Light has been signed off, so the theme store's default choice is now
`system` (it was `dark` during the incremental migration). This matches
the app.html pre-paint guard, which already resolved an unset choice via
prefers-color-scheme — removing the brief boot-time mismatch where the
store re-pinned dark. Users still pin light/dark via the account-menu
picker. Updates the store default + its test and the design-system /
finalize-plan docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the a11y foundation and bring login, lobby, and the in-game shell to
WCAG 2.2 AA:
- Primitives: .sr-only + .skip-link (base.css), trapFocus (modal focus
trap + restore) and restoreFocus (menu focus restore) actions, the
--color-focus visible ring.
- In-game shell: skip link + focusable main landmark; WAI-ARIA sidebar
tabs (roving tabindex, arrow/Home/End, tabpanel wiring); menu Escape +
focus restore (account / view / turn-navigator / map-toggles /
bottom-tabs); mail compose as a role=dialog modal with a focus trap.
- login / lobby / lobby-create: skip link + main landmark, field labels,
role=alert / role=status live regions.
- Map canvas: aria-label naming it a visual overview, with its data
reachable by keyboard via the sidebar inspector and tables (accessible
alternative; in-canvas keyboard nav deferred).
Gates (chromium-desktop): tests/e2e/a11y-axe.spec.ts scans every
top-level view for WCAG 2.2 AA violations (zero); a11y-keyboard.spec.ts
covers the skip link, menu Escape+restore, and tab roving. Adds
@axe-core/playwright. Docs: ui/docs/a11y.md (+ index). Marks F1 and F2
done in ui/PLAN-finalize.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tokenize every remaining component <style> — calculator, order tab,
inspectors, tables, report sections, lobby, auth, mail, battle viewer,
toasts, map overlays. A scripted pass handled the unambiguous core
palette (text/bg/surface/border/accent/danger/muted), the rest were
mapped to the semantic/grey tokens by role.
Remaining colour literals are the documented exceptions only: the
battle-scene SVG data-visualisation palette (fixed dark, like the WebGL
map canvas), overlay scrims (modal / map-canvas), and directional or
deliberate drop shadows. The default theme stays dark until light
coherence is signed off across the views.
Updates ui/docs/design-system.md (migration status + exceptions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the shared design-token system under
ui/frontend/src/lib/theme/: tokens.css (dark default + light palette,
plus spacing/radii/typography scales), base.css global baseline
(document background, text, token focus ring, selection), and
theme.svelte.ts (system/light/dark choice, persisted to localStorage,
applied via data-theme on <html>). A pre-paint guard in app.html
resolves the theme before the app boots to avoid a flash, and the theme
picker is wired into the previously-disabled account-menu stub.
Migrate the always-visible in-game chrome to the tokens (header, account
menu, sidebar, tab-bar, bottom-tabs, shell background): dark renders as
before, light comes for free. The default stays dark during the
incremental migration; the remaining view bodies migrate in F1b.
Docs: ui/docs/design-system.md (+ index entry). Test: tests/theme.test.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.
- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.
- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.
- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.
- ui/docs/README.md (new): grouped topic-doc index.
- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- custom load capped at cargo capacity (error when exceeded); full load shows the cargo capacity; zero cargo pins load to empty and disables the toggle
- per-input red border + tooltip for every invalid value (blocks, techs, load, MAT, modernization target); no value may be negative; locking a speed is disabled when drive is zero
- display every computed number (results + goal-seek back-solved input) rounded up to 3 decimals via a shared pkg/calc Ceil3 bridged to wasm; engine keeps its own round-to-nearest util.Fixed*
- modernization total upgrade cost spans two columns (single line)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the calculator's inputs into a page-level calculatorState singleton so they survive the sidebar unmounting the tab on a tab switch (the inspector auto-opens on a planet click). ensureGame resets the design when the active game changes.
While on the calculator, a planet click no longer switches to the inspector — the calculator consumes the selection in its planet area / reach circles. Halve the reach-circle stroke width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet.
pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm.
Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store.
Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stage 1 (render-on-demand) removed the idle / whole-system freeze, but
panning a loaded map with "visible hyperspace" on stayed heavy in Safari:
the fog still cut its visibility holes by opaque overpaint — on KNNTS041
that is ~260 near-world-sized opaque circles blended over the fog every
rendered frame, a fill-rate cliff for Safari's WebGPU / Apple's tile-based
GPU.
Replace the overpaint with an INVERSE stencil mask: setVisibilityFog now
draws the FOG_COLOR rectangle(s) into fogLayer and collects the visibility
circles into one Graphics set as fogLayer.setMask({ mask, inverse: true }),
so the fog shows everywhere except the union of the circles. Per-frame cost
drops from dozens of blended opaque circle fills to one rect fill + a
stencil pass (no colour writes), which Apple's TBDR GPU handles cheaply,
and the fog stays fully vector — crisp at any zoom.
fogPaintOps and its unit tests are unchanged (the circle ops now feed the
mask instead of an overpaint). Verified with a high-contrast screenshot
during development (fog field with a correct circle-union hole) plus the
existing fog / render-on-demand e2e green on chromium + webkit.
Docs: renderer.md fog section + PLAN.md Phase 29 decision 9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.
Stage 1 (vector-preserving, no rasterisation):
- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
single Ticker.shared flush gated on viewport.dirty (camera) plus an
internal requestRender() from every content mutation (fog / hide-set /
extras / wrap mode / resize / pick overlay). An idle map now does zero
GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
deterministic e2e assertions.
Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.
If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:
1. On the zero-turn map the bg holes painted ON TOP of the planet
glyphs — every LOCAL planet looked like a hollow circle of
background colour instead of the planet pixel inside an
unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
radius well past the world dimensions the bg circles overlapped
to cover the entire viewport. Combined with the wrong z-order
the result was a uniformly black canvas with every primitive
hidden.
The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.
Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:
- A single `fogLayer: Container` is added to the viewport BEFORE
the nine torus copies. Pixi renders viewport children in order,
so the layer is guaranteed to paint first; every copy renders
on top.
- `fogPaintOps` now emits world-space coordinates with wrap
offsets baked in (9 fog rects + 9 bg circles per visibility
entry in torus mode, 1 + N in no-wrap mode). The renderer
populates `fogLayer` with one `Graphics` per op — no per-copy
iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
Each `setVisibilityFog` flip drops every child of `fogLayer`
and rebuilds it. The dispose path drops the children
eagerly before `app.destroy({children: true})` walks the tree.
The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:
1. With three LOCAL planets close together, only the last planet
glyph stayed visible inside the bg holes — the other two were
obscured. The previous implementation stacked the fog rectangle
plus every bg circle onto a single `Graphics` via repeated
`g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
multi-shape Graphics is supported in theory, but in practice
only the last shape's fill seems to land, dropping the earlier
bg holes (and the planet glyphs on top look like they vanished
along with their hole). Splitting each op onto its own
`Graphics` inside a per-copy `Container` removes the ambiguity
— one shape, one fill, one render pass.
2. A planet near the right world edge produced a "sector" — the
bg circle painted into the area past the seam, but the
neighbouring tile's fog rectangle then overpainted that bleed,
leaving a quarter-circle hole. In torus mode each visibility
circle is now drawn at the nine wrapped positions
(`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
neighbour-tile-aligned positions keep the hole continuous
across the seam. No-wrap mode keeps a single emission per
circle, because wrapped circles would leak into the visible
world rectangle as unwanted holes.
The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifted the Phase 29 fog draw sequence out of `setVisibilityFog`
into a pure `fogPaintOps` helper that returns an ordered list of
fill operations (one fog rect, then one background-coloured
circle per visibility entry). The renderer now dispatches each op
straight onto a Pixi `Graphics`; the indirection lets the layered-
overpaint contract be tested without booting Pixi.
`tests/fog-paint-ops.test.ts` covers: empty input → no ops; single
circle → fog rect + bg circle in that order; multiple circles → N
bg circles after the fog rect; overlapping circles emitted
independently (the rendering order unions them); zero / negative
world dimensions → no ops.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.
Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.
Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous logic re-mounted the renderer whenever
`store.wrapMode` flipped, because the `sameSnapshot` gate
included `handle.getMode() === mode`. Pixi 8 does not reliably
re-initialise an `Application` on the same canvas — the symptom
showed up as the chromium tab silently closing during the
Phase 29 wrap-mode e2e ("Target page, context or browser has
been closed").
The renderer already exposes an in-place `setMode` that swaps
the wrap-clamp / torus-copy visibility synchronously while
preserving the camera; the playground-map.spec.ts wrap toggle
has been driving it for several phases without issue. Drop
mode from the snapshot gate and route the change through
`handle.setMode(mode)` instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run #217 surfaced three independent bugs that survived the first
fixup pass:
1. `visibleHighBitCount` masked the id with `(prim.id >>> 0) & 0xf…`,
but JS bitwise AND always returns a signed int32 — the mask had
to be re-converted with `>>> 0` AFTER the AND, not before. Result
was always 0 on the previous run, masking the next two bugs by
making the persistence test's high-bit-count assertions a
tautology.
2. `applyVisibilityState` was wrapped in `untrack`, so the
`toggles.X` reads inside `computeHiddenIds` / `computeFogCircles`
never landed in the effect's dependency set — toggling fog or any
marker / group / kind flag did not re-run the effect, so the
renderer never received the new hide / fog input. Explicit
`void toggles.X` reads now live at the top of the effect so every
key is tracked synchronously.
3. The wrap-mode radios fired on `onchange`, which Svelte 5
suppresses on a re-activation of an already-checked input — the
Playwright `.click()` flake on the second wrap test reflected the
missed event. Switched to `onclick` and short-circuited when the
target mode is already active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent bugs in `tests/e2e/map-toggles.spec.ts` made the
fresh-Phase-29 suite red on CI #216:
1. `visiblePlanets` filtered on `p.id < 1_000_000`, which JS interprets
in signed space — high-bit-prefix primitives (cargo route 0x80…,
battle 0xa0…, bombing 0xc0…) are stored as negative Numbers and
leaked into the planet list. Filter switched to a `0 < id < 1e7`
window that matches the engine planet-number range exactly.
2. The `visibleHighBitCount` helper now ToUint32-converts the id
before masking so the bitmask comparison works regardless of
whether the id is stored as positive or negative.
3. The fog and wrap-mode tests read the renderer state synchronously
after the click — the Svelte effect re-runs asynchronously, so the
tests saw stale state. Both now `waitForFunction` on the canonical
"settled" signal: empty fog circles for the fog flip, and a new
`getMapMode()` debug accessor for the wrap-mode remount.
Renderer side: registers a `MapModeProvider` next to the existing
camera / fog providers and exposes `getMapMode()` through the debug
surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the gear-icon popover on the map view with per-game persistence
of every category toggle plus the wrap-mode radio. Hide-by-id and
visibility-fog facilities land on the renderer so every flip applies
within one frame without a Pixi remount; the wrap-mode toggle keeps
its existing remount + camera-preserve path. A new server-side turn
force-resets every flag to defaults so a hidden category never makes
the player miss the next turn's news.
Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go
(plus the single Go caller); the TS side keeps duplicating the formula
until a race-level WASM bridge lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.
Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.
The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).
Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.
Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.
Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
interval returns nil, heartbeat fires after silence, real Send
resets the timer, Stop / context-cancel halt the goroutine,
Send errors propagate.
- `server_test.go`: integration tests through the full gateway
pipeline — heartbeat fires after the configured silence window,
zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
dispatched to handlers; leading heartbeat still flips
`connectionStatus` to `connected`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 28 ConnectRPC migration of the battle viewer added a
guard in `lib/active-view/battle.svelte` that waits for the
surrounding layout to publish a `GalaxyClient` before issuing the
fetch. The in-game shell layout deliberately skips
`galaxyClient.set(...)` on the synthetic branch (gateway is not
reachable in synthetic mode), so for any battle opened from a
synthetic-report game the viewer sat on "loading battle…"
forever — `fetchBattle` was never called, so the synthetic-fixture
short-circuit it carries was unreachable.
Let the guard skip synthetic ids: `fetchBattle` already resolves
those through `lookupSyntheticBattle` and never touches the
client, so its signature widens to `GalaxyClient | null` and the
synthetic path passes `null`. The live path still waits for the
handle as before; a `null` client on the live path now fails
fast with a transport-level `BattleFetchError` instead of silently
sitting on `loading`.
Tests:
- Existing "loading placeholder" smoke now uses a non-synthetic
game id so it keeps asserting the live-path wait.
- Two new cases pin the synthetic behaviour: missing fixture →
`battle-not-found`; registered fixture → `BattleViewer` mounts.
Docs:
- `docs/FUNCTIONAL.md` §6.5 still described the pre-Phase-28
raw REST path. Updated to the signed ConnectRPC command and
noted the synthetic short-circuit. Russian mirror updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five connected cleanups across the dev/CI infrastructure:
1. Drop tools/local-ci/. The standalone Gitea + act_runner stack was
the legacy "offline workflow validator"; the per-stage CI gate now
runs on gitea.lan and the directory was only retained as a
fallback. Removing it leaves no operational dependency: backend,
gateway, and game code have no references; documentation that
pointed at it (CLAUDE.md, docs/ARCHITECTURE.md, ui/docs/testing.md,
tools/dev-deploy/README.md, tools/local-dev/README.md) is updated
in this same change. Historical "Verified on local-ci run N"
markers in ui/PLAN.md are preserved unchanged.
2. Lift the pre-production single-migration rule. The rule forced
every schema delta into 00001_init.sql and required a manual
make clean-data wipe on every backward-incompatible change in
tools/dev-deploy/. Future schema deltas now land as additive
sequence-numbered files (00002_*.sql, …) that goose applies
automatically on backend startup; 00001_init.sql becomes an
immutable baseline. Authoring conventions live in
backend/internal/postgres/migrations/README.md. The chain may be
squashed back into a fresh 00001 as a deliberate one-time
operation before the first production deployment.
3. Document the deployment cadence. The dev environment is
single-tenant: pushes to feature/* run the test workflows
(go-unit, ui-test, integration) only; dev-deploy.yaml fires on
push to development. A workflow_dispatch override on
dev-deploy.yaml lets a developer preview a feature branch on the
shared dev environment before merge; the next merge into
development overwrites the manual deploy idempotently.
4. Scope compose-managed resources by an explicit
galaxy.stack=<local-dev|dev-deploy> label. Both compose files
stamp the label on every service, network, and named volume.
Makefiles in tools/local-dev/ and tools/dev-deploy/ filter their
engine-cleanup operations by (stack-label AND engine OCI title)
so they never touch unrelated workloads on the same daemon.
dev-deploy.yaml gains a pre-`compose up` step that reaps stale
exited/dead containers under the dev-deploy stack label.
5. Backend now stamps the same galaxy.stack=<value> label on every
engine container it spawns, sourced from a new BACKEND_STACK_LABEL
env var (empty → label not applied; legacy-safe). Both compose
files set it to their stack name (local-dev / dev-deploy). The
contract is recorded in docs/ARCHITECTURE.md under
"Container labels". A package-level test in
backend/internal/runtime exercises both the label-present and
label-absent paths.
No tests intentionally regressed: go test ./backend/internal/{config,
runtime,dockerclient} is green, both compose files validate cleanly,
and the backend, gateway, and game modules all build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:
1. `/sent` returns one row per recipient for broadcast and admin
fan-outs (so the admin tooling can render the materialised
audience). The list pane fed all rows into the stand-alone
bucket, so the `{#each entries as e (entryKey(e))}` key in
`thread-list.svelte` collapsed to the same `standalone:${id}`
for every recipient and Svelte 5 aborted the render with
`each_key_duplicate`. Dedupe stand-alones by `message_id` in
`buildEntries`.
2. The compose dialog exposed an `admin` kind toggle gated on
"owner of game". That was a Phase 28 plan decision, but admin
compose is an operator tool (server admin), not an in-game
action — every game owner should not be able to broadcast
admin notifications. Drop the admin option, the audience
sub-toggles, and the admin path through `submit`. The
`MailStore.composeAdmin` wrapper and the backend RPC stay so
the future admin UI can call them.
Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two long-standing dev-environment ergonomics had not survived the
move from the bespoke local-dev stack to the CI-driven dev-deploy:
1. `BACKEND_DEV_SANDBOX_EMAIL` defaulted to an empty string in the
dev-deploy compose, so the auto-provisioned "Dev Sandbox" game
never appeared on `https://www.galaxy.lan`. Bake `dev@galaxy.lan`
as the default — matches `.env.example` and lets a developer who
logs in with that email find a ready-to-play game in the lobby.
2. The lobby's synthetic-report loader was gated on
`import.meta.env.DEV`, which is true only for `vite dev` (the
tools/local-dev path). The long-lived dev environment builds
with `vite build` (production mode), so the section was always
stripped from its bundle. Gate it on an explicit
`VITE_GALAXY_DEV_AFFORDANCES` flag instead and set it both in
`.env.development` (preserves `pnpm dev` behaviour) and in the
`dev-deploy.yaml` build step. The `prod-build.yaml` build path
leaves the flag unset, so production stays clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 28 moved the battle fetch off the REST passthrough onto the
signed envelope, so the Playwright spec's `page.route(...)` against
the old REST path no longer intercepts anything and the viewer
times out waiting for data. Update the spec to:
- Build a FlatBuffers `BattleReport` payload in
`fixtures/battle-fbs.ts` (mirrors `report-fbs.ts`'s pattern).
- Add a `user.games.battle` case to the ExecuteCommand mock that
decodes the FBS `GameBattleRequest`, returns the encoded report
when the battle_id matches the seeded one, and surfaces a
canonical `not_found` resultCode otherwise.
- Drop the obsolete REST route stubs.
- Drive the negative-path test with a real UUID that does not match
the seeded one, so the gateway-side switch is the source of the
404 (the old `missing-uuid` literal was no longer a valid wire
shape for the UUID decoder).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Phase 27 BattleViewer was the last UI surface still issuing raw
fetch() against the backend REST contract (`/api/v1/user/games/...
/battles/...`). The dev-deploy gateway never proxied that path, so
the viewer worked only in tools/local-dev/. Move it onto the signed
ConnectRPC channel every other authenticated surface already uses.
Wire pieces:
- FBS GameBattleRequest in pkg/schema/fbs/battle.fbs, regenerated
Go + TS bindings.
- MessageTypeUserGamesBattle constant + GameBattleRequest struct in
pkg/model/report/messages.go.
- pkg/transcoder/battle.go gains GameBattleRequestToPayload and
PayloadToGameBattleRequest helpers.
- gateway games_commands.go switches on the new message type and
GETs /api/v1/user/games/{id}/battles/{turn}/{battle_id}; the JSON
response is re-encoded as a FlatBuffers BattleReport before being
returned. 404 from backend surfaces as the canonical `not_found`
gateway error.
- ui/frontend/src/api/battle-fetch.ts now builds the FBS request,
calls GalaxyClient.executeCommand, and decodes the FBS response
into the existing UI shape (Record<string,string> race/ship maps,
string-form UUID). BattleFetchError carries an HTTP-style status
derived from the result code so the active-view's not_found branch
keeps working.
- battle.svelte pulls the GalaxyClient from the in-game shell
context. While the layout's boot Promise.all is in flight the
effect stays in `loading` until the client handle becomes
non-null.
- ui/Makefile FBS_INPUTS gains battle.fbs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`tests/mail-store.test.ts` exercises the `entries` derived rune
with handcrafted inbox + sent fixtures:
- personal messages exchanged with one race collapse into a
per-race thread with messages sorted oldest → newest;
- system mail (`sender_kind=system`) and admin notifications
(`sender_kind=admin`) surface as stand-alone items even when a
race-name snapshot is present;
- the caller's own paid-tier broadcasts (`broadcast_scope=
game_broadcast`) render as stand-alone outgoing items;
- `unreadCount` counts inbox rows with `readAt === null`.
The store fields are mutated directly to avoid wiring a fake
`GalaxyClient`; the underlying `$derived` rune fires whenever
those fields change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
surface, recipient-by-race-name decision, threading model,
translation toggle, push events, badge, layout, and
accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
UI's per-race threading rule, the absent read-receipt UX, and
the recipient-by-race-name compose path. Mirrored verbatim into
`docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
stage" block matching the implementation plan, and the artifact
list updated to the actual file set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 7 — header view-menu badge.
`view-menu.svelte` reads `mailStore.unreadCount` and renders an
inline pill next to the "diplomatic mail" entry whenever the
counter is non-zero. The badge styling matches the per-row dot in
`thread-list.svelte` so the two surfaces feel consistent.
Step 8 — push event handler + MailStore init in the in-game layout.
`routes/games/[id]/+layout.svelte`:
- registers a `diplomail.message.received` handler alongside the
existing `game.turn.ready` / `game.paused` ones, parses the
signed payload, calls `mailStore.applyPushEvent` to refresh the
inbox for the matching game, and raises a toast with a "view"
deep-link that navigates to `/games/:id/mail`;
- adds `mailStore.init({ client, cache, gameId })` to the boot
`Promise.all` so the inbox + sent lists are warm by the time the
view mounts, and the badge counter is populated before any user
interaction;
- disposes the new subscription in the `onDestroy` block so a game
switch does not leak handlers across navigations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 — mail active view + subcomponents.
- `lib/active-view/mail.svelte` replaces the Phase 10 stub with the
list / detail layout: two-pane on desktop, one-pane stack on
mobile (CSS media query, no separate route).
- `lib/active-view/mail/thread-list.svelte` renders per-race
threads collapsed to their last message plus stand-alone
system / admin / outgoing-broadcast items, with unread badges.
- `lib/active-view/mail/thread-pane.svelte` is the chat-style
transcript for one race; bodies render through `textContent`,
per-message Show original / translation toggles flip the
rendering when a translated body is present, and a persistent
reply box at the bottom calls `mailStore.composePersonal`.
- `lib/active-view/mail/system-item-pane.svelte` renders one
stand-alone item read-only with the same translation toggle.
- `lib/active-view/mail/compose.svelte` is the compose dialog:
recipient race picker fed from `report.races[]`, kind toggle
(personal / broadcast / admin), admin sub-toggle for target
user / all and recipient-scope picker. Server-side enforces
paid-tier and owner gating; the UI surfaces 403 inline.
- `lib/active-view/mail/system-titles.ts` keeps the keyword →
i18n-title mapping for lifecycle-hook system mail so both the
list and the detail pane pick the same canonical title.
Step 9 — i18n strings (en + ru).
`game.mail.*`, `game.view.mail.badge`, `game.events.mail_new.*`,
`game.mail.system.*` keys added in lockstep across both locales
covering compose labels / validation copy / per-system titles /
translation toggle / reply / delete affordances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `src/lib/mail-store.svelte.ts` — the reactive store that
coordinates the in-game mail view. Responsibilities:
- holds the inbox and sent listings for the current game and fires
the initial parallel fetch (`fetchInbox` + `fetchSent`) on
`setGame`;
- exposes a `entries` derived rune that builds the unified list
pane: per-race threads merged from incoming + outgoing personal
messages, plus stand-alone items for system / admin / own
paid-tier broadcasts. Thread messages are sorted oldest → newest
for chat-style rendering; the list itself sorts newest-first by
the most-recent entry timestamp;
- derives `unreadCount` from `readAt === null` rows for the header
view-menu badge;
- imperative `markRead` / `softDelete` actions with optimistic
state flips and roll-back on RPC failure;
- compose actions for personal / paid-tier broadcast / owner-admin
sends;
- `applyPushEvent(gameId)` hook called by the layout when a
`diplomail.message.received` push frame arrives; refetches the
inbox without trusting the preview payload;
- persists the most recent message id under
`cache.diplomail/${gameId}/last-seen` so a returning session can
pre-paint the badge without a network round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds typed wrappers around `GalaxyClient.executeCommand` for the
eight Phase 28 mail RPCs. Each wrapper builds the matching
FlatBuffers request, decodes the response, and surfaces backend
errors through a dedicated `MailError` (mirroring `LobbyError`).
The compose helpers accept the recipient race name directly so the
UI can feed it straight from `report.races[].name` without a
membership lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the wire schema for the eight `user.games.mail.*` ConnectRPC
commands together with the shared payload types (`MailMessage`,
`MailRecipientState`, `MailBroadcastReceipt`). Send-request tables
carry the optional `recipient_race_name` introduced in Step 1.
Drops:
- `pkg/schema/fbs/diplomail.fbs` — schema sources;
- `pkg/schema/fbs/diplomail/*.go` — generated Go bindings (flatc
`--go --go-module-name galaxy/schema/fbs`);
- `pkg/model/diplomail/diplomail.go` — message-type catalog used by
the gateway router;
- `ui/frontend/src/proto/galaxy/fbs/diplomail/*.ts` — generated TS
bindings consumed by the upcoming UI client wrapper;
- `ui/Makefile` `FBS_INPUTS` extended to pick the new schema up on
the next `make -C ui fbs-ts` run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Under host-mode runner the default 6 workers + 1 retry consistently
land on ~7 flakies and an occasional hard fail per ui-test run
(ui-test #59 most recently). Workers share CPU and the host Docker
daemon with gitea, the long-lived dev stack, and the user's host
Caddy; the extra wall time from contention pushes individual
expectations past their timeouts.
Lower the worker cap to 4 to keep parallelism but give each worker
real CPU headroom, and raise retries to 4 so the rare slow page is
absorbed without surfacing as failure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the project guides with the branching/CI/environment changes
landed in the previous commits:
- CLAUDE.md: per-stage CI gate now closes against gitea.lan; describes
the main/development/feature/* flow and the workflow surface
- docs/ARCHITECTURE.md: new section 18 "CI and Environments" covering
branches, workflows, and the local-dev / dev-deploy / local-ci
triad; section numbering shifted accordingly
- tools/local-ci/README.md: marked as fallback (offline / runner
isolation only)
- tools/local-dev/README.md and ui/README.md: cross-link to
tools/dev-deploy/ for production-shaped testing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>