diff --git a/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs
new file mode 100644
index 000000000..1384ced8d
--- /dev/null
+++ b/plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs
@@ -0,0 +1,134 @@
+#!/usr/bin/env node
+/**
+ * check-dashboard-geocoder-provider.mjs — a dashboard running on MapLibre must not call Mapbox.
+ *
+ * THE CONTRACT: a partner who selects MapLibre has selected the keyless engine precisely so that no
+ * Mapbox key and no Mapbox traffic is involved. One request to api.mapbox.com sends that visitor's
+ * IP to Mapbox and breaks the promise, whatever the page looks like afterwards.
+ *
+ * ★ WHAT THIS MEASURED, AND IT IS NOT WHERE THE AUDIT POINTED. studio's Mapbox-globals audit named
+ * DashboardSearchHeader.vue, which did preload api.mapbox.com/.../mapbox-gl-geocoder.min.js
+ * unconditionally and never passed `provider` to the GeocoderComponent it mounts — both now fixed.
+ * But that component is NOT MOUNTED by this dashboard (research-dashboard.vue:23 says so in as many
+ * words), so fixing it changes nothing here. The four requests this leaf actually makes under
+ * ?provider=maplibre are all from the GENERATED LEAF PAGE's own tags:
+ *
+ * mapbox-gl.css · mapbox-gl.js · mapbox-gl-geocoder.css · mapbox-gl-geocoder.min.js
+ *
+ * i.e. the leaf ships Mapbox GL and the Mapbox geocoder to every visitor regardless of the engine
+ * it was asked for. That is the leaf GENERATOR's doing, not this plugin's, and it is why this gate
+ * is deliberately NOT wired into gates:post yet: it would be a red gate about somebody else's file.
+ * Run it by name; it names the exact URLs.
+ *
+ * ★ ZERO IS THE ASSERTION, not "it rendered". A build that quietly kept the Mapbox geocoder would
+ * look identical: the bars appear, suggestions work, nothing errors. Only the request log tells the
+ * truth, which is why this counts requests rather than looking at the page.
+ *
+ * ASSERTIONS, on the built tree:
+ * maplibre ZERO requests to api.mapbox.com.
+ * both whatever search bars this leaf renders still produce suggestions — a zero bought by
+ * breaking search is not a fix. MEASURED: this leaf renders NONE, because the search
+ * header is not mounted here, so that half reports rather than asserts. Point the gate
+ * at a leaf that mounts the bars and it starts asserting again.
+ *
+ * Exit: 0 pass or SKIP (no built tree, no browser, no data) · 1 fail.
+ */
+import { createServer } from 'node:http'
+import { spawn } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve, dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const TAG = 'DASHBOARD-GEOCODER-PROVIDER'
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
+const skip = (why) => { console.log(`${TAG}: SKIP — ${why}`); process.exit(0) }
+
+const { TREE_NAME } = await import(join(ROOT, 'internal', '_tree-name.mjs'))
+const MANIFEST = join(ROOT, 'public', TREE_NAME, 'manifest.json')
+if (!existsSync(MANIFEST)) skip('no built manifest (run `bun run tree` first).')
+let chromium
+try { ({ chromium } = await import('playwright')) } catch { skip('playwright is not installed.') }
+
+const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
+const bundle = (manifest.bundles || []).find((b) => b.bundle === 'research-dashboard')
+const profile = bundle && (bundle.profiles || []).find((p) => p.iframeUrl)
+if (!profile) skip('the manifest declares no research-dashboard leaf to load.')
+
+const freePort = () => new Promise((ok) => {
+ const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) })
+})
+const PORT = await freePort()
+const TREE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
+const LEAF = `${TREE}/${String(profile.iframeUrl).replace(/^\.\//, '')}`
+const tree = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(PORT)], { cwd: ROOT, stdio: 'ignore' })
+const cleanup = () => { try { tree.kill() } catch { /* already gone */ } }
+await new Promise((r) => setTimeout(r, 900))
+if (!await fetch(`${TREE}/manifest.json`, { signal: AbortSignal.timeout(4000) }).then((r) => r.ok).catch(() => false)) {
+ cleanup(); skip('could not serve the built tree locally.')
+}
+const { requireThisTree } = await import(join(ROOT, 'internal', '_serves-this-tree.mjs'))
+await requireThisTree(TREE, ROOT, TAG)
+
+const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'] })
+const fails = []
+const lines = []
+try {
+ for (const engine of ['maplibre', 'mapbox']) {
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
+ const mapboxCalls = []
+ page.on('request', (r) => { const u = r.url(); if (u.includes('api.mapbox.com')) mapboxCalls.push(u) })
+ await page.goto(`${LEAF}?provider=${engine}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
+ await page.waitForTimeout(20000)
+
+ const search = await page.evaluate(async () => {
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+ const seen = new Set(); const hits = []
+ const walk = (root) => {
+ if (!root || seen.has(root)) return
+ seen.add(root)
+ for (const el of root.querySelectorAll('*')) {
+ if (el.matches && el.matches('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')) hits.push(el)
+ if (el.shadowRoot) walk(el.shadowRoot)
+ }
+ }
+ walk(document)
+ if (!hits.length) return { bars: 0, suggestions: 0 }
+ const input = hits[0]
+ input.focus()
+ input.value = 'Afar'
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ for (let i = 0; i < 40; i++) {
+ await sleep(500)
+ const root = input.getRootNode()
+ const n = root.querySelectorAll('.mapboxgl-ctrl-geocoder .suggestions li, .maplibregl-ctrl-geocoder .suggestions li, .suggestions li').length
+ if (n) return { bars: hits.length, suggestions: n }
+ }
+ return { bars: hits.length, suggestions: 0 }
+ })
+ await page.close()
+
+ lines.push(` · ${engine.padEnd(9)} api.mapbox.com requests: ${mapboxCalls.length} · search bars: ${search.bars} · suggestions for "Afar": ${search.suggestions}`)
+
+ if (engine === 'maplibre' && mapboxCalls.length) {
+ fails.push(`under maplibre the dashboard made ${mapboxCalls.length} request(s) to api.mapbox.com:`)
+ for (const u of [...new Set(mapboxCalls)].slice(0, 8)) fails.push(` ${u.slice(0, 150)}`)
+ }
+ // Reported, not asserted, when the leaf mounts no bars at all — see the header note.
+ if (search.bars && !search.suggestions) {
+ fails.push(`under ${engine} the search bar rendered but typing produced no suggestions — zero Mapbox traffic bought by a broken search is not a fix.`)
+ }
+ }
+} catch (err) {
+ fails.push(`the measurement itself failed: ${err && err.message}`)
+} finally {
+ await browser.close().catch(() => {})
+ cleanup()
+}
+
+for (const l of lines) console.log(l)
+if (fails.length) {
+ console.log(`\n${TAG}: FAIL`)
+ for (const f of fails) console.log(` ✗ ${f}`)
+ process.exit(1)
+}
+console.log(`${TAG}: PASS — MapLibre calls Mapbox zero times and still searches; Mapbox is unchanged.`)
diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue
index 22463e72f..95acc1c90 100644
--- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue
+++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardSearchHeader.vue
@@ -32,6 +32,7 @@ import { inject, ref, computed, unref, onMounted } from 'vue'
import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue'
import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
+import { resolveMapProvider } from '@map/composables/useMapProvider.js'
const bus = inject('selectionBus', null)
const dash = inject('dashboardData', null)
@@ -43,6 +44,15 @@ const mapboxToken = inject('mapboxToken', ref(''))
// map profiles wire :data-source-id="dataSource" (DASH-FIX-2).
const searchSourceId = inject('searchSourceId', '')
+// ★ WHICH ENGINE THIS DASHBOARD IS RUNNING ON, because the search bar has to agree with it.
+// The same ladder every map panel uses: profile-config.provider -> ?provider= -> window.MAP_PROVIDER
+// -> mapbox.
+// inject() at setup top level, NOT inside the computed: Vue only resolves an injection during
+// setup, and a call from inside a computed returns undefined with a warning — which would have
+// silently resolved every host to the default engine, i.e. exactly the bug being fixed.
+const profileConfig = inject('profileConfig', null)
+const provider = computed(() => resolveMapProvider(profileConfig))
+
const token = computed(() => unref(mapboxToken) || '')
// ── Host-independent geocoder-plugin loader ────────────────────────────────────
@@ -52,10 +62,21 @@ const token = computed(() => unref(mapboxToken) || '')
// host remembering to add that script. So load the plugin from the Mapbox CDN
// ourselves (mirrors how GeocoderComponent injects the geocoder CSS at runtime)
// and only mount the bars once it's ready — works in ANY host with zero wiring.
+// ★ AND UNDER MapLibre IT MUST NOT BE THE MAPBOX ONE. This preloader fetched
+// api.mapbox.com/.../mapbox-gl-geocoder.min.js unconditionally, so a partner who chose MapLibre
+// precisely to avoid Mapbox still sent every visitor's IP to api.mapbox.com the moment this
+// dashboard mounted — the keyless embed's whole reason for existing, cancelled by one hardcoded
+// URL. (Found by studio's Mapbox-globals audit.)
+//
+// GeocoderComponent already loads whichever plugin its `provider` says (ensureMapboxGeocoder /
+// ensureMaplibreGeocoder), so under MapLibre the right thing to do here is NOTHING: mark ready and
+// let the component fetch its own from jsDelivr. The mapbox preload stays for the mapbox path,
+// where it is what keeps a host from having to remember a \n\n