diff --git a/app-profiles/doxa-research-map/index.html b/app-profiles/doxa-research-map/index.html
index 678da270b..13812a7c1 100644
--- a/app-profiles/doxa-research-map/index.html
+++ b/app-profiles/doxa-research-map/index.html
@@ -191,15 +191,23 @@
// with the value at request time; the .env file is gitignored.
window.DOXA_TOKEN = '%VITE_MAPBOX_TOKEN%';
+ // Map engine override for slots WITHOUT an explicit config.provider:
+ // ?provider=maplibre flips the default engine for those slots. A slot that
+ // sets config.provider always wins (per-map). Default stays 'mapbox'.
+ window.MAP_PROVIDER = new URLSearchParams(location.search).get('provider') || undefined;
+
window.DOXA_SLOTS = [
// ── research-map: full 4-tab build (Prayer / Engagement / Adoption / Language Families) ──
- { profile: 'research-map', label: 'Research — pray-tools', gem: 'amethyst',
- config: { dataSource: 'pray-tools', instanceId: 'dev-research-1' } },
+ // Slot 1 renders with the default engine (Mapbox, unless ?provider= flips it).
+ { profile: 'research-map', label: 'Research — Mapbox engine', gem: 'amethyst',
+ config: { dataSource: 'pray-tools', instanceId: 'dev-research-1', provider: 'mapbox' } },
- { profile: 'research-map', label: 'Research — second instance', gem: 'sapphire',
- config: { dataSource: 'pray-tools', instanceId: 'dev-research-2' } },
+ // Slot 2 is pinned to MapLibre so both engines render side-by-side on one page.
+ { profile: 'research-map', label: 'Research — MapLibre engine', gem: 'emerald',
+ config: { dataSource: 'pray-tools', instanceId: 'dev-research-2', provider: 'maplibre' } },
- { profile: 'research-map', label: 'Research — third instance', gem: 'emerald',
+ // Slot 3 follows the page default (?provider= or Mapbox).
+ { profile: 'research-map', label: 'Research — default engine', gem: 'sapphire',
config: { dataSource: 'pray-tools', instanceId: 'dev-research-3' } }
];
diff --git a/app-profiles/doxa-research-map/research-map.vue b/app-profiles/doxa-research-map/research-map.vue
index 8312ee4fb..2878e03b2 100644
--- a/app-profiles/doxa-research-map/research-map.vue
+++ b/app-profiles/doxa-research-map/research-map.vue
@@ -32,6 +32,7 @@ import { inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, mark
// ─── Composables (framework) ─────────────────────────────────────────────────
import { useMapInstance } from '@map/composables/useMapInstance.js'
+import { resolveMapProvider, providerLabel } from '@map/composables/useMapProvider.js'
import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js'
import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js'
import { useMapEvents } from '@map/composables/useMapEvents.js'
@@ -322,6 +323,11 @@ useShadowStyles(`
failure reads as "slow" and the user just waits; this says the map is gone. */
.rm-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);text-align:center;font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#fecaca;background:rgba(15,18,22,.95);border:1px solid rgba(254,202,202,.28);border-radius:10px;padding:12px 14px;box-shadow:0 2px 10px rgba(0,0,0,.4); }
.rm-badge { position:absolute;top:10px;right:10px;background:rgba(124,140,248,0.12);color:#7c8cf8;border:1px solid rgba(124,140,248,0.3);padding:3px 9px;border-radius:4px;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;pointer-events:none;z-index:50; }
+ /* Engine badge — bottom-left, unobtrusive. Colour differs per engine so a
+ side-by-side comparison is readable at a glance. */
+ .rm-provider-badge { position:absolute;bottom:8px;left:8px;z-index:50;pointer-events:none;padding:2px 8px;border-radius:999px;font:600 10px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;letter-spacing:.04em;color:#fff;background:rgba(30,41,59,0.72);border:1px solid rgba(255,255,255,0.18);backdrop-filter:blur(2px); }
+ .rm-provider-badge[data-provider="mapbox"] { background:rgba(37,99,235,0.78);border-color:rgba(147,197,253,0.5); }
+ .rm-provider-badge[data-provider="maplibre"] { background:rgba(5,150,105,0.80);border-color:rgba(110,231,183,0.5); }
/* Mobile-only legend slot — desktop hides via @media */
.rm-legend-mobile-slot { display:none; }
@media(max-width:767px){
@@ -336,6 +342,9 @@ useShadowStyles(`
// ─── Config from ProfileLoader (provide/inject — never direct imports) ──────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
+// Map engine: profile-config.provider → window.MAP_PROVIDER → 'mapbox' (default).
+const mapProvider = resolveMapProvider(profileConfig)
+const mapProviderName = providerLabel(mapProvider)
const dataSource = inject('dataSource')
const colorSet = inject('colorSet')
const injectedInstanceId = inject('instanceId', null)
@@ -516,6 +525,7 @@ const RESEARCH_MAX_ZOOM = 22
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
+ provider: mapProvider, // 'mapbox' (default) | 'maplibre'
style: profileConfig?.value?.style ?? mapTheme.bootStyle(), // persisted-theme boot (shared)
center: profileConfig?.value?.center ?? mapDefaults.center ?? [20, 10],
zoom: profileConfig?.value?.zoom ?? mapDefaults.zoom ?? 1.8,
@@ -2016,6 +2026,9 @@ onBeforeUnmount(() => {
+
+ {{ mapProviderName }}
+
@@ -2026,6 +2039,7 @@ onBeforeUnmount(() => {
v-if="appReady && map"
:map-instance="map"
:access-token="mapboxToken"
+ :provider="mapProvider"
:data-source-id="dataSource"
:is-dark="isDark"
:placeholder="geocoderPlaceholder"
diff --git a/bun.lock b/bun.lock
index dffa927b4..a2a0575e5 100644
Binary files a/bun.lock and b/bun.lock differ
diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs
index a7ad5b1cc..7fa2dd9fb 100644
--- a/internal/generate-tree.mjs
+++ b/internal/generate-tree.mjs
@@ -91,7 +91,7 @@ function assetsBaseFor(depth) {
// title is the PRODUCT title (config displayTitle, default org-free), never the deployment TREE_NAME
// (TREE_NAME stays in URLs/paths where it is a folder name, not branding).
const DISPLAY_TITLE = (typeof DEST_CONFIG.displayTitle === 'string' && DEST_CONFIG.displayTitle.trim())
- ? DEST_CONFIG.displayTitle.trim() : 'Embeddable JAMstack Maps'
+ ? DEST_CONFIG.displayTitle.trim() : 'doxa-maps'
// Base URL every intra-tree ref (manifest iframeUrl/scriptUrl) is built against.
// With embedBaseUrl set (e.g. "https://doxa.life/js"), refs are ABSOLUTE — the
// build-time override for known hosts / partner embeds. WITHOUT it, refs are
@@ -233,7 +233,7 @@ header.forge .crumb a { color: var(--cy); }
const GEMS = ['#46d4ff', '#b9a6f5', '#5ee0a0', '#ffc24b', '#f472b6', '#f59e0b', '#34d399']
const hexRgb = (h) => { const n = parseInt(h.slice(1), 16); return (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) }
-// ── The "Embeddable JAMstack Maps" staging shell ────────────────────────────────────────────
+// ── The "doxa-maps" staging shell ────────────────────────────────────────────
// ONE staging design, emitted at every tree level (root + each bundle). It is a
// thin HTML shell that loads the shared, org-agnostic renderer (staging-forge.js,
// inlined at build time below) with a `window.FORGE` config telling it which tree
@@ -253,7 +253,7 @@ function readForgeAsset(rel) {
}
/**
- * The shared Embeddable JAMstack Maps staging page. Self-contained: CSS + renderer are
+ * The shared doxa-maps staging page. Self-contained: CSS + renderer are
* INLINED (no external fetch that could 404 on a static host), except the
* manifest which the renderer fetches relative to the page.
*
@@ -312,7 +312,7 @@ function forgeHtml(o) {
// ── Page templates ───────────────────────────────────────────────────────────
-/** Root staging page — the Embeddable JAMstack Maps showing ALL bundles (scope='root'). */
+/** Root staging page — the doxa-maps showing ALL bundles (scope='root'). */
function rootHtml(bundles, manifest) {
return forgeHtml({
inlineManifest: manifest,
@@ -322,13 +322,13 @@ function rootHtml(bundles, manifest) {
})
}
-/** Bundle staging page — the Embeddable JAMstack Maps showing THIS bundle's profiles (scope='bundle'). */
+/** Bundle staging page — the doxa-maps showing THIS bundle's profiles (scope='bundle'). */
function bundleHtml(b, index, manifest) {
return forgeHtml({
inlineManifest: manifest,
scope: 'bundle',
bundle: b.bundle,
- title: `${DISPLAY_TITLE} · ${b.bundle}`,
+ title: `${DISPLAY_TITLE}`,
subtitle: `${b.bundle} — one bundle, its profiles as paste-anywhere maps`,
crumbHref: '../index.html',
crumbLabel: 'all bundles',
diff --git a/internal/staging-forge.css b/internal/staging-forge.css
index a2a1d1837..b86e6e1d1 100644
--- a/internal/staging-forge.css
+++ b/internal/staging-forge.css
@@ -1,7 +1,7 @@
/* staging-forge.css — the ONE-PAGE semantic staging theme (root + bundle pages).
design brief 2026-07-11: tree sidebar left on desktop, tree-top + scroll-to-map
on mobile, GEM FRAMES as the hero (each map in its colored jewel frame), quiet
- architectural dark, zero folder-hopping. Embeddable JAMstack Maps design language. */
+ architectural dark, zero folder-hopping. doxa-maps design language. */
:root {
--bg:#0a0e15; --surf:#121a27; --surf2:#0e1623; --line:#223047;
--ink:#eef2f7; --mut:#9aa7b8; --dim:#6b7787;
diff --git a/internal/staging.js b/internal/staging.js
index 07a9b66ff..56340e939 100644
--- a/internal/staging.js
+++ b/internal/staging.js
@@ -1,4 +1,4 @@
-// staging.js — doxa-maps "Embeddable JAMstack Maps" staging showcase (dev only, no build step).
+// staging.js — doxa-maps "doxa-maps" staging showcase (dev only, no build step).
//
// UX: the header showcases the single-build-process architecture (one
// shared core → one build → JS bundles → element tags → maps). A horizontal-scroll strip lists
diff --git a/library/components/map-controls/GeocoderComponent.vue b/library/components/map-controls/GeocoderComponent.vue
index 09c3d330f..c0e10a372 100644
--- a/library/components/map-controls/GeocoderComponent.vue
+++ b/library/components/map-controls/GeocoderComponent.vue
@@ -67,17 +67,24 @@ import { useI18n } from 'vue-i18n'
import { useDoxaSearch } from '../../composables/useDoxaSearch.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
+// The Mapbox and MapLibre geocoders emit identical DOM under different class
+// prefixes (.mapboxgl-ctrl-geocoder* vs .maplibregl-ctrl-geocoder*). dualEngine
+// duplicates every rule for both so one skin covers whichever engine is active.
+function dualEngine(css) {
+ return css + css.replace(/mapboxgl/g, 'maplibregl')
+}
+
// iOS Safari auto-zooms the page whenever a text input with computed
// font-size < 16px receives focus, and the "Done" keyboard button re-fires
// the same zoom on blur. We override the Mapbox geocoder input to 16px in
// the shadow root so the auto-zoom never triggers. 16px is the documented
// threshold; setting it explicitly here also stops the page from getting
// stuck zoomed-in on devices that disallow pinch-zoom recovery.
-useShadowStyles(
+useShadowStyles(dualEngine(
'.mapboxgl-ctrl-geocoder--input,'
+ '.mapboxgl-ctrl-geocoder input[type="text"],'
+ '.mapboxgl-ctrl-geocoder input[type="search"]'
- + '{font-size:16px !important;line-height:1.3;}',
+ + '{font-size:16px !important;line-height:1.3;}'),
'geocoder-ios-zoom-fix'
)
@@ -85,8 +92,8 @@ useShadowStyles(
// slot but is NOT vertically centered with the clear (×) button, so it reads as a
// visual glitch while a query is in flight. The local search is fast enough that
// the spinner adds no value (coder: "the loading indicator needs to not show up").
-useShadowStyles(
- '.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}',
+useShadowStyles(dualEngine(
+ '.mapboxgl-ctrl-geocoder--icon-loading{display:none !important;}'),
'geocoder-hide-loading'
)
@@ -98,11 +105,11 @@ useShadowStyles(
// search icon is re-centered for the shorter box (the clear/× button is already centered
// at top:50% by each profile's base rule). Shared here so every profile inherits it —
// this REPLACES the per-profile @media mobile-height hack (bug-searchbar-mobile-height).
-useShadowStyles(
+useShadowStyles(dualEngine(
'.mapboxgl-ctrl-geocoder{height:35px!important;min-height:35px!important;box-sizing:border-box!important;}'
+ '.mapboxgl-ctrl-geocoder--input{height:35px!important;min-height:35px!important;box-sizing:border-box!important;'
+ 'padding-top:0!important;padding-bottom:0!important;line-height:35px!important;}'
- + '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}',
+ + '.mapboxgl-ctrl-geocoder--icon-search{top:50%!important;transform:translateY(-50%)!important;margin-top:0!important;left:7px!important;}'),
'geocoder-height-35'
)
@@ -119,7 +126,7 @@ useShadowStyles(
// layout); they must not re-declare colour, radius, shadow or focus ring.
//
// Palette is research-map's — the reference implementation.
-useShadowStyles(
+useShadowStyles(dualEngine(
// FILL — the pill always fills whatever container it is placed in, and the
// container is what a profile sizes. This is the shared search-bar CONTRACT, not
// a per-map preference: every map wants the pill to fill its slot. Sizing the
@@ -161,7 +168,7 @@ useShadowStyles(
+ 'color:rgba(243,243,241,0.65)!important;}'
+ '.geocoder-dark .mapboxgl-ctrl-geocoder--icon,.dsm-dark .mapboxgl-ctrl-geocoder .mapboxgl-ctrl-geocoder--icon{'
+ 'fill:rgba(243,243,241,0.75)!important;}'
- + '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}',
+ + '.geocoder-dark .mapboxgl-ctrl-geocoder--button,.dsm-dark .mapboxgl-ctrl-geocoder--button{background:transparent!important;}'),
'geocoder-skin'
)
@@ -174,6 +181,12 @@ const props = defineProps({
// pass a live instance, so their behavior is unchanged.
mapInstance: { type: Object, required: false, default: null },
accessToken: { type: String, required: true },
+ /**
+ * Map engine: 'mapbox' (default — MapboxGeocoder) | 'maplibre'
+ * (MaplibreGeocoder + a Mapbox REST forwardGeocode, so place search still
+ * works on a MapLibre map). The local people-group geocoder is shared.
+ */
+ provider: { type: String, default: 'mapbox' },
isDark: { type: Boolean, default: false },
/**
* When true, render the geocoder input into THIS component's own container
@@ -351,8 +364,17 @@ const standaloneContainer = ref(null)
// The geocoder stylesheet must live inside the shadow root; injecting it into
// the document head has no effect because the widget renders inside a
// ShadowRoot. We target the closest ShadowRoot of the map container.
-function injectGeocoderCSS(shadowRoot) {
- const HREF = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'
+const GEOCODER_CSS = {
+ mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
+ maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
+}
+// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
+// index.html; MapLibre's is injected on demand so a profile can flip provider
+// with no host change.
+const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js'
+
+function injectGeocoderCSS(shadowRoot, prov) {
+ const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
if (shadowRoot.querySelector(`link[href="${HREF}"]`)) return // already injected
const link = document.createElement('link')
link.rel = 'stylesheet'
@@ -360,9 +382,72 @@ function injectGeocoderCSS(shadowRoot) {
shadowRoot.prepend(link)
}
+/** Load the MaplibreGeocoder UMD global on demand (idempotent). */
+async function ensureMaplibreGeocoder() {
+ if (typeof window !== 'undefined' && window.MaplibreGeocoder) return
+ if (typeof document === 'undefined') return
+ const existing = document.querySelector(`script[src="${MAPLIBRE_GEOCODER_JS}"]`)
+ if (existing) {
+ if (window.MaplibreGeocoder) return
+ await new Promise(resolve => {
+ existing.addEventListener('load', resolve, { once: true })
+ existing.addEventListener('error', resolve, { once: true })
+ })
+ return
+ }
+ await new Promise((resolve, reject) => {
+ const s = document.createElement('script')
+ s.src = MAPLIBRE_GEOCODER_JS
+ s.async = false
+ s.addEventListener('load', resolve, { once: true })
+ s.addEventListener('error', () => reject(new Error('[GeocoderComponent] Failed to load MapLibre geocoder')), { once: true })
+ document.head.appendChild(s)
+ })
+}
+
+/**
+ * Builds a MaplibreGeocoder `geocoderApi.forwardGeocode` backed by the Mapbox
+ * Geocoding REST API — so place search still works on a MapLibre map (hybrid:
+ * MapLibre basemap + Mapbox geocoding). Returns Carmen GeoJSON, which the
+ * geocoder consumes directly. Empty result (no token / network error) leaves the
+ * local people-group search fully functional.
+ */
+function makeMapboxForwardGeocode(token, getLang) {
+ return async (config) => {
+ const empty = { features: [] }
+ if (!token || !config?.query) return empty
+ try {
+ const lang = Array.isArray(config.language) ? config.language[0] : (config.language || getLang() || 'en')
+ const params = new URLSearchParams({
+ access_token: token,
+ autocomplete: 'true',
+ limit: String(config.limit || 5),
+ })
+ if (lang) params.set('language', lang)
+ const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(config.query)}.json?${params.toString()}`
+ const res = await fetch(url)
+ if (!res.ok) return empty
+ const geojson = await res.json()
+ return (geojson && Array.isArray(geojson.features)) ? geojson : empty
+ } catch (e) {
+ return empty
+ }
+ }
+}
+
// ── Geocoder lifecycle ────────────────────────────────────────────────────────
-onMounted(() => {
- if (typeof MapboxGeocoder === 'undefined') {
+onMounted(async () => {
+ const isMaplibre = props.provider === 'maplibre'
+
+ // Ensure the right geocoder global is present. Mapbox's is preloaded via
+ // index.html; MapLibre's is injected on demand.
+ if (isMaplibre) {
+ await ensureMaplibreGeocoder()
+ if (typeof MaplibreGeocoder === 'undefined') {
+ console.error('[GeocoderComponent] MaplibreGeocoder global unavailable.')
+ return
+ }
+ } else if (typeof MapboxGeocoder === 'undefined') {
return
}
@@ -378,7 +463,7 @@ onMounted(() => {
? standaloneContainer.value?.getRootNode?.()
: map.getContainer?.()?.getRootNode?.()
if (shadowRoot instanceof ShadowRoot) {
- injectGeocoderCSS(shadowRoot)
+ injectGeocoderCSS(shadowRoot, props.provider)
}
// Create geocoder instance.
@@ -393,11 +478,11 @@ onMounted(() => {
// category tag the `render` callback (renderSuggestion) prepends.
//
// `localGeocoderOnly` is intentionally LEFT OUT — we want BOTH sources
- // (Mapbox places AND DOXA records) visible in the suggestions list.
- // eslint-disable-next-line no-undef
- geocoder.value = new MapboxGeocoder({
- accessToken: props.accessToken,
- mapboxgl: mapboxgl, // CDN global — same reference used by the map instance
+ // (place results AND DOXA records) visible in the suggestions list.
+ //
+ // Shared options — the two geocoders share almost the entire option surface;
+ // only the constructor shape + the geocoding backend differ (see below).
+ const sharedOpts = {
marker: false,
// Standalone (header) bars are search-only: results drive the selection bus,
// not a map flyTo — so disable flyTo (no map). Map-control mode keeps the
@@ -409,24 +494,42 @@ onMounted(() => {
// mode keeps the default (byte-identical: !standalone === true).
trackProximity: !props.standalone,
placeholder: effectivePlaceholder.value,
- // Localize Mapbox's remote place results AND the geocoder's own UI strings.
- // Mapbox accepts ISO-639-1 codes (comma-separated); the active i18n locale
- // is set once at mount from profile-config.lang (default 'en'). See loc-002.
+ // Localize remote place results AND the geocoder's own UI strings. Mapbox
+ // accepts ISO-639-1 codes; the active i18n locale is set once at mount from
+ // profile-config.lang (default 'en'). See loc-002.
language: locale.value || 'en',
localGeocoder: doxaLocalGeocoder,
- // Raise suggestion limit well above Mapbox's default (5) so a query like
- // "India" can surface many people-groups. CSS caps the dropdown height
- // and adds overflow-y:auto so the user can scroll through them.
+ // Raise suggestion limit well above the default (5) so a query like "India"
+ // can surface many people-groups. CSS caps the dropdown height + overflow.
limit: 20,
- // Ensure our local results show even when Mapbox has remote matches.
- // (Some versions default to localGeocoder results appearing AFTER the
- // remote ones; keeping default order is fine — the category tag makes
- // our rows unambiguous.)
zoom: 6,
- // Custom row renderer — injects structured, bold-labeled metadata
- // for DOXA results. See renderSuggestion() above.
- render: renderSuggestion
- })
+ // Custom row renderer — injects structured, bold-labeled metadata for DOXA
+ // results. See renderSuggestion() above.
+ render: renderSuggestion,
+ }
+
+ if (isMaplibre) {
+ // MapLibre map + Mapbox geocoding (hybrid). forwardGeocode hits the Mapbox
+ // REST API with the same public token; the local people-group search is
+ // unchanged. Requires the geocoderApi as the FIRST constructor arg.
+ // eslint-disable-next-line no-undef
+ const gl = typeof maplibregl !== 'undefined' ? maplibregl : undefined
+ const geocoderApi = { forwardGeocode: makeMapboxForwardGeocode(props.accessToken, () => locale.value) }
+ // eslint-disable-next-line no-undef
+ geocoder.value = new MaplibreGeocoder(geocoderApi, {
+ ...sharedOpts,
+ maplibregl: gl,
+ showResultsWhileTyping: true,
+ reverseGeocode: false,
+ })
+ } else {
+ // eslint-disable-next-line no-undef
+ geocoder.value = new MapboxGeocoder({
+ accessToken: props.accessToken,
+ mapboxgl: mapboxgl, // CDN global — same reference used by the map instance
+ ...sharedOpts,
+ })
+ }
// Forward geocoder events as Vue emits. When a DOXA people-group result
// is chosen we additionally emit `people-group-result` carrying the
diff --git a/library/composables/useMapInstance.js b/library/composables/useMapInstance.js
index 19a4a963d..90f89de1e 100644
--- a/library/composables/useMapInstance.js
+++ b/library/composables/useMapInstance.js
@@ -28,24 +28,87 @@
import { ref } from 'vue'
import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
-const MAPBOX_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css'
+// ─── Engine registry (provider → CDN script + css + global) ──────────────────
+// The map engine is loaded as a page global (CDN