From 0e65565e25e1abb6fa7a2d30f6b205034dffcd46 Mon Sep 17 00:00:00 2001
From: "isaprettycoolguy@protonmail.com"
<6576516+nyxsys@users.noreply.github.com>
Date: Sun, 21 Jun 2026 11:09:12 -0400
Subject: [PATCH] fix: coord labels no longer slip up-left during zoom bake
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bug the user has been chasing for several sessions. Sandbox
repro at dev/coord-slip-sandbox.html, snapshot-coord-slip.mjs
captures the half-baked frame so we can SEE the slip.
Root cause: bakeZoom mutates the viewport font-size mid-function,
which triggers a layout reflow growing every em-based hex
dimension. The path-overlay SVG holds coord labels at pixel
positions computed against the PRE-bake hex sizes, so any paint
frame between the font-size change and the SVG rebuild shows the
labels at where the hexes USED to be — visibly shifted up-left of
the now-larger hexes. updatePathOverlay() further down in bakeZoom
eventually fixes it ("snap back into place"), but the intermediate
slip-then-snap is what the user reported across multiple zoom
sessions.
Fix: tear down the path / faction / region SVGs at the START of
bakeZoom, before the font-size mutation. The CSS rule
`.duckmage-svg-labels-active .duckmage-hex-label { visibility:
hidden }` is keyed off a class on the viewport — that class gets
removed alongside the SVG, so the intermediate paint falls back to
the HTML `.duckmage-hex-label` spans. Those live inside each hex's
own DOM tree, so they always track the hex's layout perfectly
across font-size changes. No slip.
The overlays are then re-rendered later in the function via the
existing updatePathOverlay() / updateFactionOverlay() /
updateRegionOverlay() / updateTokenLayer() calls. Net result: zero
extra rebuild work, just earlier tear-down.
Sandbox confirms:
buggy-mid: SVG labels visibly shifted up-left of small hexes
fixed-mid: HTML labels inside hexes, tracking correctly
351 unit tests / 18 e2e pass.
---
dev/coord-slip-sandbox.html | 197 ++++++++++++++++++++++++++++++++++++
dev/snapshot-coord-slip.mjs | 46 +++++++++
src/hex-map/HexMapView.ts | 21 ++++
3 files changed, 264 insertions(+)
create mode 100644 dev/coord-slip-sandbox.html
create mode 100644 dev/snapshot-coord-slip.mjs
diff --git a/dev/coord-slip-sandbox.html b/dev/coord-slip-sandbox.html
new file mode 100644
index 0000000..cbb6901
--- /dev/null
+++ b/dev/coord-slip-sandbox.html
@@ -0,0 +1,197 @@
+
+
+
+
+Coord-slip during bakeZoom — repro
+
+
+
+
Coord-slip during bakeZoom — buggy vs fixed order
+
+
+
+
+
+
click "Bake" then look at the screenshot for the half-baked frame
+
+
+
+
+
+
+
+
+
+
diff --git a/dev/snapshot-coord-slip.mjs b/dev/snapshot-coord-slip.mjs
new file mode 100644
index 0000000..700cd44
--- /dev/null
+++ b/dev/snapshot-coord-slip.mjs
@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+/** Capture the half-baked frame between font-size change and SVG rebuild
+ * in both the buggy order (SVG torn down last) and the fixed order
+ * (SVG torn down first). The 300ms setTimeout in the sandbox is the
+ * paint window — snapshot DURING that window, while the SVG is in its
+ * stale-vs-absent state, to see which one shows misplaced labels. */
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import fs from "node:fs/promises";
+import { withBrowser } from "/mnt/c/Users/markr/work/tools/chromium-driver/lib/browser.mjs";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const sandbox = path.join(here, "coord-slip-sandbox.html");
+const outDir = path.join(here, "out");
+await fs.mkdir(outDir, { recursive: true });
+
+await withBrowser(
+ async ({ page }) => {
+ await page.goto(pathToFileURL(sandbox).href, { waitUntil: "networkidle0" });
+ const snap = async (name) => {
+ const el = await page.$(".viewport");
+ await el.screenshot({ path: path.join(outDir, `${name}.png`) });
+ console.log(path.join(outDir, `${name}.png`));
+ };
+
+ await snap("slip-1-baseline");
+
+ await page.click("#reset");
+ await new Promise((r) => setTimeout(r, 100));
+ await page.click("#buggy-bake");
+ // Capture mid-transition — during the 300ms SVG-rebuild delay
+ await new Promise((r) => setTimeout(r, 100));
+ await snap("slip-2-buggy-mid");
+ await new Promise((r) => setTimeout(r, 400));
+ await snap("slip-3-buggy-final");
+
+ await page.click("#reset");
+ await new Promise((r) => setTimeout(r, 100));
+ await page.click("#fixed-bake");
+ await new Promise((r) => setTimeout(r, 100));
+ await snap("slip-4-fixed-mid");
+ await new Promise((r) => setTimeout(r, 400));
+ await snap("slip-5-fixed-final");
+ },
+ { viewport: { width: 900, height: 600 } },
+);
diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts
index 09f18d8..25ec5fa 100644
--- a/src/hex-map/HexMapView.ts
+++ b/src/hex-map/HexMapView.ts
@@ -2533,6 +2533,27 @@ export class HexMapView extends ItemView {
if (this.bgCalibrating) return;
const F = this.zoom;
const currentFs = parseFloat(getComputedStyle(this.viewportEl).fontSize);
+
+ // Tear down the path SVG FIRST, before the font-size mutation below.
+ // The path SVG holds coord labels at pixel positions computed against
+ // the pre-bake hex sizes. After font-size grows the hexes, those
+ // pixel positions point to where the hexes USED to be, so any paint
+ // frame between the font-size change and the SVG rebuild shows
+ // labels visibly shifted up-left from the hexes. By removing the
+ // SVG (and its `duckmage-svg-labels-active` class) here, the
+ // intermediate frame falls back to the HTML `.duckmage-hex-label`
+ // spans — those live inside the hex DOM and track the hex layout
+ // perfectly across font-size changes. The new path SVG is rebuilt
+ // via `updatePathOverlay()` further down. Sandbox repro of the
+ // pre-fix slip: dev/coord-slip-sandbox.html.
+ this.viewportEl.querySelector("svg.duckmage-path-svg")?.remove();
+ this.viewportEl.removeClass("duckmage-svg-labels-active");
+ // Same for the faction/region overlays — they also hold pre-bake
+ // pixel positions and would visibly mis-align during the transition.
+ this.viewportEl.querySelector("svg.duckmage-faction-svg")?.remove();
+ this.viewportEl.querySelector(".duckmage-faction-legend")?.remove();
+ this.viewportEl.querySelector("svg.duckmage-region-svg")?.remove();
+
this.viewportEl.style.fontSize = `${currentFs * F}px`;
this.zoom = 1;
this.applyTransform();