From f6e4a4f6bd1b1950a4564b0381044015b9a5c8f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 24 May 2026 08:49:37 +0200 Subject: [PATCH] feat(ui): map canvas follows light/dark theme; fix invisible gear control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map view now selects a DARK_THEME or LIGHT_THEME palette from the resolved app theme and threads it through every primitive builder, so the canvas, planets, ship groups, cargo routes, battle/bombing markers, fog, reach + selection rings, pending-Send tracks, and the pick overlay all switch with the rest of the chrome. A theme flip remounts the renderer preserving the camera — Pixi bakes the background at init and every primitive bakes its colour at build, so a live re-tint is not possible on the same instance. This also fixes the reported bug: the gear-popover trigger and the loading overlay hardcoded a dark navy background, so in light theme the gear was invisible (dark icon on dark chip) until hover flipped it to a white chip. Both now use the --color-surface-overlay token and read correctly in both themes. The light palette mirrors the dark one role-for-role, darkened / saturated for contrast on a light background while keeping the incoming, battle, and bombing accents vivid. The values are a first pass meant to be refined during the F8 manual-QA loop. Removes the now-dead "Phase 35" references from the code and lifts the map-recoloring prohibition from the design-system / renderer docs; the battle scene stays a fixed-palette data-viz surface. Co-Authored-By: Claude Opus 4.7 --- docs/FUNCTIONAL.md | 5 +- docs/FUNCTIONAL_ru.md | 5 +- ui/docs/design-system.md | 23 ++- ui/docs/renderer.md | 28 +++- .../src/lib/active-view/map-toggles.svelte | 2 +- ui/frontend/src/lib/active-view/map.svelte | 49 +++++- ui/frontend/src/lib/header/view-menu.svelte | 2 +- ui/frontend/src/lib/i18n/index.svelte.ts | 2 +- .../src/lib/inspectors/planet-sheet.svelte | 2 +- .../src/lib/sidebar/bottom-tabs.svelte | 8 +- ui/frontend/src/map/battle-markers.ts | 25 ++- ui/frontend/src/map/cargo-routes.ts | 53 +++---- ui/frontend/src/map/index.ts | 1 + ui/frontend/src/map/pending-send-routes.ts | 20 +-- ui/frontend/src/map/pick-mode.ts | 29 ++-- ui/frontend/src/map/reach-circles.ts | 9 +- ui/frontend/src/map/render.ts | 16 +- ui/frontend/src/map/selection-ring.ts | 9 +- ui/frontend/src/map/ship-groups.ts | 104 +++++++------ ui/frontend/src/map/state-binding.ts | 71 +++++---- ui/frontend/src/map/world.ts | 109 ++++++++++++- ui/frontend/tests/battle-markers.test.ts | 67 +++++++- ui/frontend/tests/fog-paint-ops.test.ts | 9 +- ui/frontend/tests/map-cargo-routes.test.ts | 28 +++- ui/frontend/tests/map-theme.test.ts | 144 ++++++++++++++++++ ui/frontend/tests/pending-send-routes.test.ts | 25 ++- ui/frontend/tests/selection-ring.test.ts | 16 +- 27 files changed, 631 insertions(+), 230 deletions(-) create mode 100644 ui/frontend/tests/map-theme.test.ts diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 116c0fc..be48821 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -802,8 +802,9 @@ every change applies within one frame (no Pixi remount): off, hides every non-LOCAL planet that sits beyond `FlightDistance(localPlayerDrive)` of every LOCAL planet (torus-aware metric). -- **View** — "visible hyperspace" toggle (slightly lighter - overlay outside the union of +- **View** — "visible hyperspace" toggle (a faint overlay, + tinted to contrast with the active theme's map background, + outside the union of `VisibilityDistance(localPlayerDrive)` circles around LOCAL planets; LOCAL planets are always exempt — the toggle is named after the visible part of the map rather than the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 6b2d9ab..82d6d6c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -823,8 +823,9 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий- не-LOCAL планету, отстоящую дальше `FlightDistance(localPlayerDrive)` от любой LOCAL-планеты (метрика учитывает торическую развёртку). -- **Вид** — переключатель «видимое гиперпространство» (чуть - более светлая заливка вне объединения окружностей +- **Вид** — переключатель «видимое гиперпространство» (лёгкая + заливка, подобранная под фон карты активной темы, вне + объединения окружностей `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; LOCAL-планеты всегда вне фильтра — тоггл назван по видимой области карты, а не по затемнённой) плюс радиогруппа diff --git a/ui/docs/design-system.md b/ui/docs/design-system.md index 736c743..264f01a 100644 --- a/ui/docs/design-system.md +++ b/ui/docs/design-system.md @@ -94,12 +94,20 @@ them in sync. stay literal `rgba(…)`. They sit over arbitrary content, not a themed surface, so a surface token would be wrong; there is no `--color-scrim` until a third caller justifies one. -- Data-visualisation surfaces keep a fixed palette. The battle scene +- Data-visualisation surfaces define their palette in code, not via CSS + tokens, because they paint to a canvas / SVG instead of themed DOM. + The WebGL map canvas ships two palettes — `DARK_THEME` and + `LIGHT_THEME` in [`src/map/world.ts`](../frontend/src/map/world.ts) — + and follows the resolved app theme like the rest of the chrome: + `active-view/map.svelte` selects the palette from `theme.resolved` and + remounts the renderer on a theme flip (Pixi bakes the background and + every primitive colour at build time, so a live re-tint is not + possible; the remount preserves the camera). The battle scene (`battle-player/battle-scene.svelte`) is a self-contained SVG - visualisation — like the WebGL map canvas — and stays dark in both - themes; its only themed neighbours are the surrounding chrome - (`battle-viewer.svelte`). Re-theming a viz surface for light is a - dedicated design task, not a token swap. + visualisation that still keeps a single fixed dark palette in both + themes — re-theming it for light is a separate design task — and its + only themed neighbours are the surrounding chrome + (`battle-viewer.svelte`). - Spacing-scale adoption is gradual — colour tokens are the priority; existing one-off paddings are migrated opportunistically, not churned en masse. @@ -113,8 +121,9 @@ battle, mail, toasts). The whole app switches coherently between light and dark from a single token change. The only remaining literal colours are the documented exceptions above: -the battle-scene data-viz palette, the overlay scrims, and the -directional / deliberate drop shadows. +the canvas data-viz palettes (the theme-aware map palette and the fixed +battle-scene palette, both defined in code rather than as tokens), the +overlay scrims, and the directional / deliberate drop shadows. The default theme is **`system`** — it follows the OS light/dark preference; users can pin light or dark via the account-menu picker. diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index 3e210b2..dbcfb47 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -75,11 +75,23 @@ overrides them. ## Theme -A single dark theme is implemented. The theme is a record of default -colours; primitives whose `style` omits a colour fall back to the -theme. Runtime theme switching is not implemented — light/dark and -the materialise-on-theme-change cycle are deferred to the -finalization plan ([../PLAN-finalize.md](../PLAN-finalize.md)). +A `Theme` is the renderer's full colour palette: the canvas background +and fog veil, the generic fallbacks for primitives whose `style` omits +a colour, and the semantic colours every primitive builder paints with +(planets, ship groups, cargo routes, battle / bombing markers, reach + +selection rings, pending-Send tracks, and the pick-mode overlay). Two +palettes ship in `src/map/world.ts` — `DARK_THEME` and `LIGHT_THEME` — +and the builders take the active palette so the whole canvas follows +the user's light / dark choice. + +`active-view/map.svelte` selects the palette from `theme.resolved` +(`$lib/theme/theme.svelte.ts`) and threads it into `reportToWorld`, the +overlay builders, and `createRenderer({ theme })`. A theme flip is +handled by a remount that preserves the camera: Pixi bakes the +background at `Application.init` and every primitive bakes its colour +at build time, so the palette cannot be swapped live on an existing +instance. The debug playground (`routes/__debug/map`) omits the option +and keeps the `DARK_THEME` default. ## Hit-test @@ -335,9 +347,9 @@ scanner / visibility coverage: - An empty list destroys the existing fog rectangles and mask. - A non-empty list rebuilds a single viewport-level `fogLayer` (a sibling below the nine torus copies). `fogPaintOps` returns an - ordered op list — one world-sized rectangle filled with `FOG_COLOR` - (two shades lighter than the dark theme background) plus one circle - per visibility circle. The renderer draws the rectangle ops into + ordered op list — one world-sized rectangle filled with the active + palette's `fog` colour (a faint shade off the theme background) plus + one circle per visibility circle. The renderer draws the rectangle ops into `fogLayer` and collects the circle ops into a single `Graphics` set as `fogLayer`'s **inverse stencil mask** (`setMask({ mask, inverse: true })`), so the fog shows everywhere diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte index 2b61bda..4ce09c1 100644 --- a/ui/frontend/src/lib/active-view/map-toggles.svelte +++ b/ui/frontend/src/lib/active-view/map-toggles.svelte @@ -243,7 +243,7 @@ bottom-tabs bar. font: inherit; font-size: 1.4rem; padding: 0.25rem 0.5rem; - background: rgba(20, 24, 42, 0.85); + background: var(--color-surface-overlay); color: var(--color-text); border: 1px solid var(--color-border); border-radius: 6px; diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index c19a7ec..1cd1524 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -33,6 +33,7 @@ preference the store already manages. import { computeReachCircles } from "../../map/reach-circles"; import { computeSelectionRing } from "../../map/selection-ring"; import { reachStore } from "$lib/calculator/reach.svelte"; + import { theme as themeStore } from "$lib/theme/theme.svelte"; import { reportToWorld, type HitTarget, @@ -44,7 +45,12 @@ preference the store already manages. computeHiddenPlanetNumbers, fingerprintHiddenPlanets, } from "../../map/visibility"; - import type { PrimitiveID } from "../../map/world"; + import { + DARK_THEME, + LIGHT_THEME, + type PrimitiveID, + type Theme, + } from "../../map/world"; import { ORDER_DRAFT_CONTEXT_KEY, OrderDraftStore, @@ -132,6 +138,13 @@ preference the store already manages. }> = []; let mountedTurn: number | null = null; let mountedGameId: string | null = null; + // The palette the current renderer was mounted with (DARK_THEME or + // LIGHT_THEME). A theme flip changes the singleton reference, which + // the effect detects to drive a camera-preserving remount — Pixi + // bakes the background colour at init and every primitive bakes its + // colour at build, so a live re-tint is not possible on the same + // instance. + let mountedPalette: Theme | null = null; let onResize: (() => void) | null = null; let detachClick: (() => void) | null = null; let detachDebugProviders: (() => void) | null = null; @@ -177,6 +190,11 @@ preference the store already manages. // extras filter all derive from this rune. const toggles = store?.mapToggles; const gameId = store?.gameId ?? ""; + // Track the resolved app theme so the canvas follows the user's + // light / dark choice. A flip re-keys the snapshot below and + // triggers a camera-preserving remount with the new palette. + const palette: Theme = + themeStore.resolved === "light" ? LIGHT_THEME : DARK_THEME; if (!mounted || canvasEl === null || containerEl === null) return; if (status !== "ready" || !report || toggles === undefined) return; @@ -245,6 +263,7 @@ preference the store already manages. const sameSnapshot = mountedTurn === report.turn && mountedGameId === gameId && + mountedPalette === palette && handle !== null; if (sameSnapshot) { // Apply wrap-mode flips in-place via the renderer's own @@ -274,6 +293,7 @@ preference the store already manages. toggles, hiddenPlanetNumbers, mode, + palette, ), ); }); @@ -297,6 +317,7 @@ preference the store already manages. extrasFingerprint, draftCommands, draftStatuses, + palette, ); }); }); @@ -308,16 +329,22 @@ preference the store already manages. toggles: MapToggles, hiddenPlanetNumbers: ReadonlySet, mode: "torus" | "no-wrap", + palette: Theme, ): import("../../map/world").Primitive[] { const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined; const cargo = toggles.cargoRoutes - ? buildCargoRouteLines(report, skip ? { skipPlanets: skip } : undefined) + ? buildCargoRouteLines( + report, + skip ? { skipPlanets: skip } : undefined, + palette, + ) : []; const pending = buildPendingSendLines( report, draftCommands, draftStatuses, skip ? { skipPlanets: skip } : undefined, + palette, ); // Reach circles published by the ship-class calculator. Empty // when no own planet is selected or the design is invalid, so @@ -331,11 +358,16 @@ preference the store already manages. report.mapWidth, report.mapHeight, mode, + palette, ) : []; const selectedPlanetId = selection?.selected?.kind === "planet" ? selection.selected.id : null; - const selectionRing = computeSelectionRing(report.planets, selectedPlanetId); + const selectionRing = computeSelectionRing( + report.planets, + selectedPlanetId, + palette, + ); return [ ...cargo, ...pending, @@ -370,10 +402,11 @@ preference the store already manages. extrasFingerprint: string, draftCommands: readonly OrderCommand[], draftStatuses: Readonly>, + palette: Theme, ): Promise { mountInProgress = true; try { - await mountRenderer(report, mode); + await mountRenderer(report, mode, palette); if (handle === null) return; applyVisibilityState(report, toggles, hiddenPlanetNumbers); handle.setExtraPrimitives( @@ -384,6 +417,7 @@ preference the store already manages. toggles, hiddenPlanetNumbers, mode, + palette, ), ); lastExtrasFingerprint = extrasFingerprint; @@ -426,6 +460,7 @@ preference the store already manages. async function mountRenderer( report: NonNullable, mode: "torus" | "no-wrap", + palette: Theme, ): Promise { if (canvasEl === null || containerEl === null) return; // Capture camera state before disposing so a remount inside @@ -462,7 +497,7 @@ preference the store already manages. hitLookup: nextHitLookup, categories, planetDependents, - } = reportToWorld(report); + } = reportToWorld(report, palette); hitLookup = nextHitLookup; currentCategories = categories; currentPlanetDependents = planetDependents; @@ -471,6 +506,7 @@ preference the store already manages. world, mode, preference: ["webgpu", "webgl"], + theme: palette, }); const minScale = minScaleNoWrap( { @@ -592,6 +628,7 @@ preference the store already manages. }; mountedTurn = report.turn; mountedGameId = targetGameId; + mountedPalette = palette; // runSerializedMount immediately pushes the visibility // state + extras after this resolves; clearing the // fingerprint here is defensive in case the post-mount @@ -773,7 +810,7 @@ preference the store already manages. left: 50%; transform: translateX(-50%); padding: 0.4rem 0.9rem; - background: rgba(20, 24, 42, 0.85); + background: var(--color-surface-overlay); color: var(--color-text); border: 1px solid var(--color-border); border-radius: 6px; diff --git a/ui/frontend/src/lib/header/view-menu.svelte b/ui/frontend/src/lib/header/view-menu.svelte index f86922f..86ee032 100644 --- a/ui/frontend/src/lib/header/view-menu.svelte +++ b/ui/frontend/src/lib/header/view-menu.svelte @@ -10,7 +10,7 @@ entities), report, battle, mail, ship-class designer, science designer. Each entry mutates `activeView` (the single-URL app-shell has no per-view routes) and closes the menu. Closes on Escape, on outside click, and after a selection. Phase 26 introduces the -history-mode entry; Phase 35 polishes microcopy. +history-mode entry; microcopy is refined in a later polish pass. -->