diff --git a/internal/check-raw-map-rtl-shaping.mjs b/internal/check-raw-map-rtl-shaping.mjs
new file mode 100644
index 000000000..dc0a5092d
--- /dev/null
+++ b/internal/check-raw-map-rtl-shaping.mjs
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+/**
+ * check-raw-map-rtl-shaping — the RAW maps (built with `new gl.Map`, not useMapInstance) must
+ * shape Arabic, Hebrew and Persian place names.
+ *
+ * gospel's check-gospel-rtl-shaping.mjs (b9dd5545) established the method on resources-map; this
+ * is the same gate for the other raw maps: cities-internet/connectivity-map (both engines) and
+ * key-cities/cities-map (Mapbox-only; gated, so it has no leaf page and is mounted on a
+ * same-origin host page). Read that file's header for the full reasoning; the load-bearing parts:
+ *
+ * - WHY IT MATTERS TO EVERYONE: these maps do not localise basemap labels, so each place is drawn
+ * in its OWN script. Over the Middle East most labels are Arabic/Persian/Hebrew for EVERY
+ * viewer in every UI language. Unshaped = disconnected glyphs in the wrong order.
+ * - IT PANS FIRST: the shaper registers lazily and reads 'deferred' at a world view. Only after
+ * RTL labels are really on screen does 'loaded' mean they went through the shaper. No RTL label
+ * on screen is NOT MEASURED, never PASS.
+ * - ONE property definition per engine global does both the map hook and the poison: two
+ * addInitScript defineProperty calls on one global silently replace each other.
+ *
+ * WHY THE FIX HAS TO BE IN THE MAP, NOT THE ENTRY: bundle entries used to call setRTLTextPlugin at
+ * load time, which only worked because every leaf
preloaded mapbox-gl.js. The engine is now
+ * injected lazily (ensureEngineScript), after the entry has run, so the call has to follow the
+ * engine global and precede `new gl.Map`: installRtlTextPlugin(gl) from library/maps/rtl-text.js.
+ *
+ * --poison neuters setRTLTextPlugin in the page (the real pre-fix state) and must be caught on
+ * every measured row. Exit: 0 pass · 1 fail · 2 not measured.
+ */
+import { chromium } from 'playwright'
+import { serveReaped } from './_serve-reaped.mjs'
+import { TREE_NAME } from './_tree-name.mjs'
+import { readFileSync } from 'node:fs'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const PORT = Number(process.env.RTL_RAW_PORT || 5394)
+const POISON = process.argv.includes('--poison')
+const TK = process.env.MAPBOX_TOKEN || JSON.parse(readFileSync(resolve(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8')).token || ''
+const BASE = `http://127.0.0.1:${PORT}/${TREE_NAME}`
+/* Arabia: the densest concentration of rendered Arabic on the basemap. */
+const RTL_VIEW = { center: [39.2, 24.5], zoom: 5 }
+
+const HOST_PATH = `${TREE_NAME}/key-cities/__rtl-host.html`
+const TARGETS = [
+ ...['mapbox', 'maplibre'].map((provider) => ({
+ name: 'cities-internet', provider,
+ url: `${BASE}/cities-internet/connectivity-map/index.html?provider=${provider}&lang=ar` + (provider === 'mapbox' ? `&tk=${TK}` : ''),
+ })),
+ { name: 'key-cities', provider: 'mapbox', url: `http://127.0.0.1:${PORT}/${HOST_PATH}`, host: true },
+]
+const HOST_HTML = `
+
+
+`
+
+const srv = serveReaped(resolve(ROOT, 'public'), PORT)
+await new Promise((r) => setTimeout(r, 2500))
+const browser = await chromium.launch({
+ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
+})
+
+const INSTALL = (poison) => {
+ const define = (name) => {
+ let v
+ Object.defineProperty(window, name, {
+ configurable: true,
+ get: () => v,
+ set: (g) => {
+ v = g
+ if (!g) return
+ if (poison && g.setRTLTextPlugin) g.setRTLTextPlugin = () => {}
+ if (g.Map && !g.__hooked) {
+ g.__hooked = true
+ const Orig = g.Map
+ g.Map = function (...a) { const m = new Orig(...a); window.__MAP = m; return m }
+ g.Map.prototype = Orig.prototype
+ Object.setPrototypeOf(g.Map, Orig)
+ }
+ },
+ })
+ }
+ define('mapboxgl'); define('maplibregl')
+}
+
+const results = []
+for (const t of TARGETS) {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
+ await page.addInitScript(INSTALL, POISON)
+ if (t.host) await page.route(/__rtl-host\.html/, (r) => r.fulfill({ contentType: 'text/html', body: HOST_HTML }))
+ let r
+ try {
+ await page.goto(t.url, { waitUntil: 'networkidle', timeout: 90000 })
+ await page.waitForTimeout(8000)
+ r = await page.evaluate(async (view) => {
+ const m = window.__MAP
+ if (!m) return { err: 'the page never constructed a map' }
+ const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
+ // Wait for the style before moving: an unloaded style answers ZERO features.
+ for (let i = 0; i < 60 && !m.isStyleLoaded(); i++) await sleep(500)
+ m.jumpTo(view)
+ await new Promise((res) => { m.once('idle', res); setTimeout(res, 15000) })
+ let names = []
+ for (let i = 0; i < 30; i++) {
+ names = m.queryRenderedFeatures()
+ .filter((f) => f.layer && f.layer.type === 'symbol')
+ .map((f) => String((f.properties && (f.properties.name || f.properties.name_en)) || ''))
+ .filter(Boolean)
+ if (names.some((n) => /[-ۿݐ-ݿ-]/.test(n))) break
+ await sleep(1000)
+ }
+ // the engine that built THIS map, not whichever global happens to exist
+ const g = (window.maplibregl && m instanceof window.maplibregl.Map) ? window.maplibregl : window.mapboxgl
+ return {
+ rtl: names.filter((n) => /[-ۿݐ-ݿ-]/.test(n)),
+ symbols: names.length,
+ status: g && g.getRTLTextPluginStatus ? g.getRTLTextPluginStatus() : 'NO STATUS API',
+ }
+ }, RTL_VIEW)
+ } catch (e) {
+ r = { err: String(e.message).slice(0, 90) }
+ }
+ await page.close()
+ results.push({ ...t, ...r })
+}
+await browser.close()
+srv.stop()
+
+let bad = 0, unmeasured = 0
+for (const r of results) {
+ const tag = `${r.name} ${r.provider}`
+ if (r.err) { console.error(` ${tag}: NOT MEASURED — ${r.err}`); unmeasured++; continue }
+ if (!r.rtl.length) {
+ console.error(` ${tag}: NOT MEASURED — no RTL label rendered at the test view (${r.symbols} symbols); the gate cannot see the defect here.`)
+ unmeasured++; continue
+ }
+ const sample = r.rtl.slice(0, 3).join(', ')
+ if (r.status !== 'loaded') {
+ console.error(` ${tag}: FAIL — ${r.rtl.length} RTL labels on screen (${sample}) but the shaper is '${r.status}'. These labels are drawn unshaped.`)
+ bad++; continue
+ }
+ console.log(` ok ${tag} — ${r.rtl.length}/${r.symbols} rendered labels are RTL (${sample}), shaper loaded`)
+}
+const measured = results.length - unmeasured
+if (POISON) {
+ if (unmeasured) { console.error(`RAW-RTL --poison: NOT MEASURED — ${unmeasured} row(s) had nothing to judge, so the self-test proves nothing for them.`); process.exitCode = 2 }
+ else if (bad === measured) { console.log(`RAW-RTL --poison: ${bad}/${measured} rows caught with the plugin neutered.`); console.log('RAW-RTL: SELF-TEST PASS — the check fails as designed.') }
+ else { console.error('RAW-RTL: SELF-TEST FAIL — a neutered shaper still passed.'); process.exitCode = 1 }
+} else if (bad) {
+ console.error(`RAW-RTL: FAIL — ${bad} of ${results.length} row(s) draw RTL place names unshaped.`)
+ console.error(' Fix: installRtlTextPlugin(gl) from library/maps/rtl-text.js, right after the engine global')
+ console.error(' resolves and BEFORE new gl.Map.')
+ process.exitCode = 1
+} else if (unmeasured) {
+ console.error(`RAW-RTL: NOT MEASURED — ${unmeasured} of ${results.length} row(s) could not be judged.`); process.exitCode = 2
+} else console.log(`RAW-RTL: PASS — cities-internet (both engines) and key-cities shape RTL place names.`)
diff --git a/package.json b/package.json
index 599647e6c..406b1a54e 100644
--- a/package.json
+++ b/package.json
@@ -134,6 +134,8 @@
"check:quoted-scripts": "node internal/check-quoted-scripts.mjs",
"check:rtl-shaping": "node internal/check-gospel-rtl-shaping.mjs",
"check:rtl-shaping:poison": "node internal/check-gospel-rtl-shaping.mjs --poison",
+ "check:rtl-raw": "node internal/check-raw-map-rtl-shaping.mjs",
+ "check:rtl-raw:poison": "node internal/check-raw-map-rtl-shaping.mjs --poison",
"check:scrim-viewport": "node internal/check-scrim-covers-viewport.mjs",
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test",
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue
index 5fad52892..c2b6205f0 100644
--- a/plugin-designs/cities-internet/connectivity-map.vue
+++ b/plugin-designs/cities-internet/connectivity-map.vue
@@ -51,6 +51,7 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
+import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
// People-group dots open the SAME detail panel as Prayer Progress (doxa-simple-map): the
// shared store selection, the shared GO-pin highlight, and the shared legend wrappers
// that host PeopleGroupDetail on desktop and in the phone sheet.
@@ -278,6 +279,11 @@ onMounted(async () => {
const gl = engineGlobal(ciProvider) || window.mapboxgl
if (unmounted) return
if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return }
+ // RTL SHAPING, before the first map exists. This raw map does not go through useMapInstance
+ // (which installs it), and the basemap draws each place in its OWN script: over the Middle East
+ // most labels are Arabic, Persian or Hebrew for EVERY viewer. An entry-time call cannot do this,
+ // because the engine is injected lazily, after the bundle has run.
+ installRtlTextPlugin(gl)
try {
// The dots are a second layer, not a precondition: if the list fails, the map still shades.
const groupsLoaded = loadGroups().catch((e) => { console.warn('[connectivity-map] people groups:', e?.message || e) })
diff --git a/plugin-designs/key-cities/cities-map.vue b/plugin-designs/key-cities/cities-map.vue
index a6e2b88cc..8fc81dcfb 100644
--- a/plugin-designs/key-cities/cities-map.vue
+++ b/plugin-designs/key-cities/cities-map.vue
@@ -37,6 +37,7 @@ import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
+import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
const profileConfig = inject('profileConfig', ref(null))
@@ -137,6 +138,9 @@ onMounted(async () => {
try { await ensureEngineScript('mapbox') } catch (_) {}
const gl = engineGlobal('mapbox')
if (!gl || !mapEl.value) { err.value = 'mapbox-gl not loaded'; return }
+ // RTL SHAPING before the map exists: a raw map is not covered by useMapInstance's install, and
+ // the Mapbox basemap draws Middle-East places in Arabic/Persian/Hebrew script for every viewer.
+ installRtlTextPlugin(gl)
try {
await load()
const map = new gl.Map({