diff --git a/internal/check-control-inset-measured.mjs b/internal/check-control-inset-measured.mjs
new file mode 100644
index 000000000..4db1ff502
--- /dev/null
+++ b/internal/check-control-inset-measured.mjs
@@ -0,0 +1,172 @@
+#!/usr/bin/env node
+/**
+ * check-control-inset-measured — bottomControlInset actually MEASURES the map's bottom controls.
+ *
+ * ★ WHY THIS EXISTS SEPARATELY FROM check:popup-pan-controls. That gate proves the ARITHMETIC: given
+ * a band height, the pan clears it. This one proves the band height is real. The two halves fail
+ * differently, and the second failure is the silent one: if this function returns 0 — a selector
+ * typo, an engine that renames its control classes, a control that lives outside the container —
+ * then panPlanFor is handed bottomInset 0, behaves exactly as it did before the fix, and
+ * check:popup-pan-controls still passes every case, because its arithmetic is still correct.
+ * The bug would be back and both gates would be green.
+ *
+ * So this asserts the measurement against a REAL DOM: the engine's own class names, the box model it
+ * actually produces, and the cases that make a naive implementation wrong.
+ *
+ * ★ IT RUNS THE SHIPPED FUNCTION, not a copy. bottomControlInset is not exported — it lives inside
+ * the SFC — so the source is read and evaluated in the page, the same reach
+ * check:popup-pan-controls uses for panPlanFor. A re-typed copy would pass forever while the
+ * component drifted, which is the one failure this gate must not have.
+ *
+ * Exit 0 pass · 1 fail · 2 the function could not be found · 75 no browser slot.
+ */
+import { readFileSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from './lib/playwright-guarded.mjs'
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
+const SRC = join(ROOT, 'library/components/maps/RegionCountryMap.vue')
+const src = readFileSync(SRC, 'utf8')
+
+const m = src.match(/function bottomControlInset\(container\)[\s\S]*?\n\}/)
+if (!m) {
+ console.error('CONTROL-INSET: NOT MEASURED — bottomControlInset was not found in RegionCountryMap.vue.')
+ console.error(' Renamed or moved? Re-point this gate rather than dropping it: without it, an inset that')
+ console.error(' silently returns 0 restores the original bug while every other gate stays green.')
+ process.exit(2)
+}
+const FN = m[0]
+
+// A map container shaped like the real one: the engine's four corner containers, its attribution in
+// the bottom-right, and our own pin hint. Sizes are CSS, so the browser computes the boxes.
+const PAGE = (inner) => `
+
+${inner}
`
+
+const ATTRIB = (text = '© OpenStreetMap contributors') =>
+ ``
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true })
+let bad = 0
+const ok = (cond, what) => { if (!cond) bad++; console.log(` ${cond ? 'ok ' : 'FAIL'} ${what}`) }
+
+/** Evaluate the SHIPPED function against a page, and also report what the DOM says, independently. */
+async function measure(inner) {
+ await page.setContent(PAGE(inner))
+ return page.evaluate(([fnSrc]) => {
+ const fn = new Function(`${fnSrc}\nreturn bottomControlInset`)()
+ const container = document.querySelector('.map')
+ const box = container.getBoundingClientRect()
+ // The independent answer: the highest top of anything that takes a pointer event down there.
+ let top = Infinity
+ for (const el of container.querySelectorAll('.maplibregl-ctrl, .pin-hint-pill')) {
+ const r = el.getBoundingClientRect()
+ if (r.width > 0 && r.height > 0 && r.top < top) top = r.top
+ }
+ const expected = Number.isFinite(top) ? Math.max(0, Math.round(box.bottom - top)) : 0
+ return { got: fn(container), expected }
+ }, [FN])
+}
+
+// ── 1. THE ORDINARY CASE: one line of attribution. The inset must be positive and must match what
+// the DOM independently says — not merely "some number", which a stub returning 1 would satisfy.
+{
+ const { got, expected } = await measure(ATTRIB())
+ ok(got > 0, `a visible attribution reserves a band (${got}px), not 0 — the silent-failure case`)
+ ok(got === expected, `and it equals what the DOM says (${got} vs ${expected})`)
+}
+
+// ── 2. NOTHING TO RESERVE. No controls at all must give exactly 0, so a map without chrome is not
+// penalised and the pan behaves as it did before this rule existed.
+{
+ const { got } = await measure('')
+ ok(got === 0, `no bottom controls reserves exactly 0 (${got})`)
+}
+
+// ── 3. A COLLAPSED CONTROL RESERVES NOTHING. An element present but zero-sized takes no click, and
+// counting it would reserve the whole map height — the pan would then refuse every popup.
+{
+ const { got } = await measure('')
+ ok(got === 0, `a zero-sized control reserves nothing (${got})`)
+}
+
+// ── 4. THE TALLEST WINS, ACROSS CORNERS. Our pin hint sits bottom-LEFT while the attribution is
+// bottom-RIGHT; the band has to cover whichever reaches higher, or the taller one still takes the
+// click. Measured with a deliberately tall hint.
+{
+ const inner = ATTRIB() + ''
+ const { got, expected } = await measure(inner)
+ ok(got === expected && got >= 80, `the taller of two corners wins (${got}px covers an 80px hint)`)
+}
+
+// ── 5. A WRAPPED ATTRIBUTION IS TALLER, AND THE INSET FOLLOWS IT. This is the case a hard-coded
+// constant gets wrong: at 390 a long credit wraps to two lines and the band grows.
+{
+ const oneLine = await measure(ATTRIB())
+ const wrapped = await measure(ATTRIB('© OpenStreetMap contributors, © OpenMapTiles, © Natural Earth, and a long credit that wraps at this width'))
+ ok(wrapped.got > oneLine.got, `a wrapped credit reserves more than one line (${wrapped.got} > ${oneLine.got})`)
+ ok(wrapped.got === wrapped.expected, `and still equals the DOM (${wrapped.got} vs ${wrapped.expected})`)
+}
+
+// ── 6. A CONTROL OUTSIDE THE CONTAINER IS NOT OURS TO RESERVE. querySelectorAll is scoped to the
+// container; a stray control elsewhere on the page must not inflate the band.
+{
+ await page.setContent(PAGE('') .replace('