feat(ui): map canvas follows light/dark theme; fix invisible gear control
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s

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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-24 08:49:37 +02:00
parent d44ad9b6eb
commit f6e4a4f6bd
27 changed files with 631 additions and 230 deletions
@@ -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;
+43 -6
View File
@@ -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<number>,
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<Record<string, string>>,
palette: Theme,
): Promise<void> {
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<GameStateStore["report"]>,
mode: "torus" | "no-wrap",
palette: Theme,
): Promise<void> {
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;
+1 -1
View File
@@ -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.
-->
<script lang="ts">
import { onMount } from "svelte";
+1 -1
View File
@@ -14,7 +14,7 @@
// The locale state is exposed through a Svelte 5 runes singleton
// (`i18n`) so components stay reactive without ceremony:
// `<p>{i18n.t('login.title')}</p>` re-renders whenever
// `i18n.locale` changes. Phase 35 will swap this primitive for a
// `i18n.locale` changes. A later pass can swap this primitive for a
// fuller solution once message-format pluralisation and lazy
// loading become necessary.
@@ -8,7 +8,7 @@ does not stack on top of the calc / order overlays).
Phase 13 ships the minimal dismissal surface: a close button (`✕`)
that clears the selection. Swipe-to-dismiss and tap-outside-to-
dismiss from the IA section §6 land in Phase 35 polish.
dismiss from the IA section §6 are deferred to a later polish pass.
-->
<script lang="ts">
import type {
@@ -8,10 +8,10 @@ or the header view-menu naturally drops the overlay.
More opens a drawer with the same destination list as the header
view-menu, each entry mutating `activeView` directly (the single-URL
app-shell has no per-view routes). Phase 35 polish narrows it to the
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout)
once History exists; until then the convenience of one source of
truth for destinations beats the duplication.
app-shell has no per-view routes). A later polish pass narrows it to
the IA-spec subset (Mail, Battle log, Tables, History, Settings,
Logout) once History exists; until then the convenience of one source
of truth for destinations beats the duplication.
-->
<script lang="ts">
import { onMount } from "svelte";