diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte
index 67158e6..3898d0a 100644
--- a/ui/frontend/src/lib/active-view/map-toggles.svelte
+++ b/ui/frontend/src/lib/active-view/map-toggles.svelte
@@ -34,7 +34,15 @@ bottom-tabs bar.
void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]);
}
+ /**
+ * setWrap is wired to the radios' `onclick`, not `onchange`, so the
+ * Playwright `.click()` action on the input fires the callback even
+ * when the input is already checked (the `change` event suppresses
+ * the second activation, which made the wrap-mode e2e flake).
+ * `onclick` also fires reliably on touch / pointer activation.
+ */
function setWrap(mode: WrapMode): void {
+ if (store.wrapMode === mode) return;
void store.setWrapMode(mode);
}
@@ -192,7 +200,7 @@ bottom-tabs bar.
data-testid="map-toggles-wrap-torus"
value="torus"
checked={store.wrapMode === "torus"}
- onchange={() => setWrap("torus")}
+ onclick={() => setWrap("torus")}
/>
{i18n.t("game.map.toggles.wrap.torus")}
@@ -203,7 +211,7 @@ bottom-tabs bar.
data-testid="map-toggles-wrap-no-wrap"
value="no-wrap"
checked={store.wrapMode === "no-wrap"}
- onchange={() => setWrap("no-wrap")}
+ onclick={() => setWrap("no-wrap")}
/>
{i18n.t("game.map.toggles.wrap.no_wrap")}
diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte
index 9e211b4..e0de5b2 100644
--- a/ui/frontend/src/lib/active-view/map.svelte
+++ b/ui/frontend/src/lib/active-view/map.svelte
@@ -177,6 +177,24 @@ preference the store already manages.
if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report || toggles === undefined) return;
+ // Explicit reads of every toggle key — Svelte 5's deep proxy
+ // tracks per-property access, and the actual consumers
+ // (computeHiddenIds, computeFogCircles, buildExtras) run
+ // inside `untrack` blocks or async continuations where the
+ // tracking would otherwise be lost. Touching every key here
+ // synchronously guarantees a flip triggers the effect.
+ void toggles.hyperspaceGroups;
+ void toggles.incomingGroups;
+ void toggles.unidentifiedGroups;
+ void toggles.foreignPlanets;
+ void toggles.uninhabitedPlanets;
+ void toggles.unidentifiedPlanets;
+ void toggles.unreachablePlanets;
+ void toggles.cargoRoutes;
+ void toggles.battleMarkers;
+ void toggles.bombingMarkers;
+ void toggles.visibilityFog;
+
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
// cascade-filtering happens here so the extras list shrinks
diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts
index 857ec6a..090125b 100644
--- a/ui/frontend/tests/e2e/map-toggles.spec.ts
+++ b/ui/frontend/tests/e2e/map-toggles.spec.ts
@@ -238,14 +238,17 @@ async function visibleHighBitCount(
page: Page,
prefix: number,
): Promise {
- // Convert ids to uint32 before masking so the comparison works
- // for ids stored as signed-negative numbers (JS bitwise ops force
- // ToInt32). `prefix >>> 0` keeps the literal in uint32 space too.
+ // JS bitwise `&` always returns a signed int32. Convert both
+ // sides to uint32 via `>>> 0` AFTER the mask so the comparison
+ // is well-defined for high-bit-prefix ids that arrive as
+ // negative Numbers (cargo route 0x80…, battle 0xa0…, bombing
+ // 0xc0…) as well as for the positive `prefix` literal passed in.
return await page.evaluate((p: number) => {
const prims = window.__galaxyDebug!.getMapPrimitives!() as readonly PrimitiveLite[];
+ const expected = p >>> 0;
return prims.filter(
(prim) =>
- prim.visible && ((prim.id >>> 0) & 0xf0000000) === (p >>> 0),
+ prim.visible && ((prim.id & 0xf0000000) >>> 0) === expected,
).length;
}, prefix);
}