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();