fix: coord labels no longer slip up-left during zoom bake

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.
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-21 11:09:12 -04:00
parent c82bbb3fdb
commit 0e65565e25
3 changed files with 264 additions and 0 deletions

197
dev/coord-slip-sandbox.html Normal file
View file

@ -0,0 +1,197 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Coord-slip during bakeZoom — repro</title>
<style>
:root { --background-secondary: #2a2a2a; }
body { background: #1e1e1e; color: #ddd; font-family: system-ui, sans-serif; margin: 0; padding: 8px; }
h1 { font-size: 0.95em; margin: 0 0 8px; }
.controls { display: flex; gap: 8px; margin: 0 0 8px; }
.controls button { background: #3b82f6; color: white; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
#log { font-family: monospace; font-size: 0.85em; color: #999; padding: 6px; background: #0c111a; border-radius: 3px; margin: 0 0 8px; min-height: 36px; white-space: pre; }
.viewport {
position: relative;
width: 700px;
height: 480px;
background: #0c111a;
overflow: hidden;
}
.grid-wrap {
position: absolute;
top: 0;
left: 0;
padding: 1em;
transform-origin: 0 0;
transform: scale(var(--vp-scale, 1));
}
.grid {
position: relative;
display: flex;
flex-direction: column;
}
.row { display: flex; flex-wrap: nowrap; margin-bottom: calc(var(--hex-size, 2.2em) * -0.5); }
.row.odd { margin-left: calc(var(--hex-size, 2.2em) * 1.732 / 2); }
.hex {
--hex-size: 2.2em;
width: calc(var(--hex-size) * 1.732);
height: calc(var(--hex-size) * 2);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
background: rgba(120, 200, 80, 0.5);
margin: 0.15em;
position: relative;
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #1a1a1a;
}
.hex-label {
pointer-events: none;
font-weight: 600;
/* Hidden when SVG labels are active (path SVG present) — same rule
as the plugin uses. */
}
.grid-wrap.svg-active .hex-label { visibility: hidden; }
.path-svg {
position: absolute;
top: 0; left: 0;
pointer-events: none;
z-index: 5;
}
.svg-coord {
fill: #fff;
stroke: rgba(0,0,0,0.85);
stroke-width: 2;
paint-order: stroke;
font-size: 11px;
font-weight: 600;
font-family: system-ui, sans-serif;
}
</style>
</head>
<body>
<h1>Coord-slip during bakeZoom — buggy vs fixed order</h1>
<div class="controls">
<button id="buggy-bake">Bake (buggy: SVG torn down LAST)</button>
<button id="fixed-bake">Bake (fixed: SVG torn down FIRST)</button>
<button id="reset">Reset</button>
</div>
<div id="log">click "Bake" then look at the screenshot for the half-baked frame</div>
<div class="viewport" id="viewport">
<div class="grid-wrap svg-active" id="gridWrap">
<div class="grid" id="grid"></div>
</div>
</div>
<script>
const ROWS = 4, COLS = 5;
let zoom = 2; // start "zoomed in"
let fontSize = 16; // pre-bake font-size
const grid = document.getElementById("grid");
const gridWrap = document.getElementById("gridWrap");
const viewport = document.getElementById("viewport");
const log = document.getElementById("log");
function buildGrid() {
grid.replaceChildren();
for (let r = 0; r < ROWS; r++) {
const row = document.createElement("div");
row.className = "row" + (r % 2 === 1 ? " odd" : "");
for (let c = 0; c < COLS; c++) {
const hex = document.createElement("div");
hex.className = "hex";
hex.dataset.x = c; hex.dataset.y = r;
const label = document.createElement("span");
label.className = "hex-label";
label.textContent = `${c},${r}`;
hex.appendChild(label);
row.appendChild(hex);
}
grid.appendChild(row);
}
}
function applyTransform() {
gridWrap.style.setProperty("--vp-scale", String(zoom));
gridWrap.style.fontSize = `${fontSize}px`;
}
function renderPathSvg() {
grid.querySelector(".path-svg")?.remove();
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("path-svg");
svg.setAttribute("width", String(grid.offsetWidth));
svg.setAttribute("height", String(grid.offsetHeight));
grid.querySelectorAll(".hex").forEach((hex) => {
let ox = hex.offsetWidth / 2, oy = hex.offsetHeight / 2;
let cur = hex;
while (cur && cur !== grid) {
ox += cur.offsetLeft; oy += cur.offsetTop;
cur = cur.offsetParent;
}
const t = document.createElementNS("http://www.w3.org/2000/svg", "text");
t.setAttribute("x", String(ox));
t.setAttribute("y", String(oy + hex.offsetHeight * 0.28));
t.setAttribute("text-anchor", "middle");
t.setAttribute("class", "svg-coord");
t.textContent = `${hex.dataset.x},${hex.dataset.y}`;
svg.appendChild(t);
});
grid.appendChild(svg);
gridWrap.classList.add("svg-active");
}
function reset() {
zoom = 2; fontSize = 16;
applyTransform();
buildGrid();
renderPathSvg();
log.textContent = "reset — zoomed in 2× via CSS transform, SVG labels active";
}
// BUGGY bake: change font-size first, rebuild SVG at the end.
document.getElementById("buggy-bake").addEventListener("click", () => {
const F = zoom;
// 1. font-size mutated (hexes grow in layout)
fontSize *= F;
gridWrap.style.fontSize = `${fontSize}px`;
// 2. transform reset to scale 1
zoom = 1;
gridWrap.style.setProperty("--vp-scale", "1");
// Force a paint here to simulate the worst case
void grid.offsetWidth;
// 3. SVG rebuilt (now with new sizes)
setTimeout(() => {
renderPathSvg();
log.textContent = "buggy bake done. Between font-size change and SVG rebuild, OLD SVG labels were at OLD positions → slip visible.";
}, 300);
});
// FIXED bake: tear down SVG FIRST so HTML labels take over during the transition.
document.getElementById("fixed-bake").addEventListener("click", () => {
const F = zoom;
// 1. TEAR DOWN old SVG + remove the class so HTML labels become visible.
grid.querySelector(".path-svg")?.remove();
gridWrap.classList.remove("svg-active");
// 2. font-size mutated (hexes grow). HTML labels are inside hexes — they
// track perfectly. User sees correctly-placed HTML labels during the
// in-between frame.
fontSize *= F;
gridWrap.style.fontSize = `${fontSize}px`;
// 3. transform reset to scale 1.
zoom = 1;
gridWrap.style.setProperty("--vp-scale", "1");
void grid.offsetWidth;
// 4. SVG rebuilt with new positions.
setTimeout(() => {
renderPathSvg();
log.textContent = "fixed bake done. SVG was torn down before font-size change → HTML labels took over for the transition → no slip.";
}, 300);
});
document.getElementById("reset").addEventListener("click", reset);
reset();
</script>
</body>
</html>

View file

@ -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 } },
);

View file

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