Integrate main into bundler-2/work

# Conflicts:
#	public/dx-maps/big-picture/big-picture.js
#	public/dx-maps/big-picture/big-picture/big-picture/index.html
#	public/dx-maps/big-picture/big-picture/frontier/index.html
#	public/dx-maps/big-picture/big-picture/index.html
#	public/dx-maps/dashboard-plus-grid/dashboard-plus-grid.js
#	public/dx-maps/dashboard-plus-grid/dashboard-plus-grid/default/index.html
#	public/dx-maps/dashboard-plus-grid/dashboard-plus-grid/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/doxa-research-map/doxa-research-map.js
#	public/dx-maps/doxa-research-map/research-map/index.html
#	public/dx-maps/doxa-research-map/research-map/maplibre/index.html
#	public/dx-maps/manifest.json
#	public/dx-maps/research-map-plus-grid/research-map-plus-grid.js
#	public/dx-maps/research-map-plus-grid/research-map-plus-grid/default/index.html
#	public/dx-maps/research-map-plus-grid/research-map-plus-grid/index.html
This commit is contained in:
Doxa Maps 2026-09-26 00:25:07 +00:00
commit 7f7ff76616
122 changed files with 21153 additions and 1274 deletions

View file

@ -10,6 +10,19 @@ Made by Vibe Coders for Vibe Coders to prepare the way of the Lord and make His
iframe, script embed, mobile app — or open every map at once as one app. Five plugin
capabilities, three outputs, one shared library, one build.
### What the library gives every map
| | |
|---|---|
| 🗺️ **Two map engines** | Every map draws with **Mapbox** or **MapLibre** (free, no key) — switch with `"provider"` in its settings or `?provider=maplibre` in the address. Mapbox is the default. |
| 🔑 **A key or a key service** | Give Mapbox a public token, or the address of a token service that hands one out — set once in the Plugin Studio header. |
| 🌍 **11 languages** | `?lang=` or `"locale"`, with Arabic right to left — [Languages](#languages). |
| 🧩 **Web components** | Each map is its own HTML tag, like `<all-people-groups>`, that works on any page — no framework needed. |
| 🔗 **Maps that talk to each other** | Maps share props and events, so one app can nest them and a click in one can move another. |
| 🪶 **Small bundles** | Each map's file carries only the parts of the shared library it uses (tree-shaken). |
| ⚙️ **One build step** | `bun run build` makes every map at once. |
| 📦 **Three outputs** | A web folder (iframe, script embed), a mobile app, a desktop app — [the 3 outputs](#what-the-build-makes--3-outputs). |
| What dx-maps set out to do | Proof |
|---|---|
| **One build → one folder** you can drop on any host | `bun run check:drop` |

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 MiB

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 954 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 205 KiB

View file

@ -1,23 +1,23 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "1a2ddbee5f7ade4c59be1e729334fbaabb9a4bb52fe91aaae70df0b8a3e817a2"
"sha256": "483a04b2ae46e00b0e0530c9291070b30c9baeaddf2940cd61e04cdffb382095"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
"sha256": "646efc14670963e1d3e1b1982ff2130562f28183a5908ef3c0c5dbd8d7f9597a"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
"sha256": "646efc14670963e1d3e1b1982ff2130562f28183a5908ef3c0c5dbd8d7f9597a"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
"sha256": "646efc14670963e1d3e1b1982ff2130562f28183a5908ef3c0c5dbd8d7f9597a"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "5f73cb71c0a374a5506df426ac4b43648f28d7128b9583da1b358c1ca7519bda"
"sha256": "9a7161f8337ff9efffcf4c8092157b5a8932de91c69fb541672f92c040033716"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
@ -49,7 +49,7 @@
},
"exp-wall-of-people-groups": {
"bundle": "wall-of-people-groups",
"sha256": "2dfc3a3c4918b574862c07271d8594e56b6d1d7ef96553fe67359bbe58c2cad2"
"sha256": "1ce6d49a495aa794200b97005e620db37fca3ade59325fa359d3a59d200cbcba"
},
"lang-arabic-upg100": {
"bundle": "my-upg-100-list",
@ -61,11 +61,11 @@
},
"prod-cities-internet": {
"bundle": "cities-internet",
"sha256": "65e98efcd79016282460e3141ccb2995ad5a11d7039c3303d7c4ae2a7a5ee32d"
"sha256": "236a1a98ec06562224f77af8ca8290e4381b4df8e81aa143ecb886f5f14f7113"
},
"prod-cities-internet-phone": {
"bundle": "cities-internet",
"sha256": "65e98efcd79016282460e3141ccb2995ad5a11d7039c3303d7c4ae2a7a5ee32d"
"sha256": "236a1a98ec06562224f77af8ca8290e4381b4df8e81aa143ecb886f5f14f7113"
},
"prod-gospel-resources": {
"bundle": "gospel-resources",
@ -77,15 +77,15 @@
},
"prod-prayer-phone": {
"bundle": "doxa-simple-map",
"sha256": "01973724b4f6b656d4f4743cf2167b8d5a984f48401e4738cec83fb3719d289b"
"sha256": "646efc14670963e1d3e1b1982ff2130562f28183a5908ef3c0c5dbd8d7f9597a"
},
"prod-regions": {
"bundle": "regions-dashboard",
"sha256": "8766e709cbc4e49a30590ce57c4322d54912e3749bfa44b1c58afeb41bae4d67"
"sha256": "85a9367643be850d554fbcc704ab57f850e30388ef6cfe5e001032bc3f323f0c"
},
"prod-regions-phone": {
"bundle": "regions-dashboard",
"sha256": "8766e709cbc4e49a30590ce57c4322d54912e3749bfa44b1c58afeb41bae4d67"
"sha256": "85a9367643be850d554fbcc704ab57f850e30388ef6cfe5e001032bc3f323f0c"
},
"prod-research-gallery-phone": {
"bundle": "doxa-maps-spa",

View file

@ -11,7 +11,7 @@
*
* "Answers 200" and "is serving this checkout" are different questions. This asks the second.
*
* THE FINGERPRINT is public/doxa-maps/manifest.json, which generate-tree rewrites on every
* THE FINGERPRINT is public/<TREE_NAME>/manifest.json, which generate-tree rewrites on every
* build and which names every bundle and profile in the tree. If the bytes served differ
* from the bytes on disk, it is a different build — a different worktree, or a stale server
* someone forgot to restart, both of which produce the same lie.
@ -27,7 +27,7 @@ import { TREE_NAME, TREE_NAME_RE } from './_tree-name.mjs'
const MANIFEST = `${TREE_NAME}/manifest.json`
/**
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /doxa-maps/ is tolerated)
* @param {string} base e.g. http://127.0.0.1:4173 (a trailing /<TREE_NAME>/ is tolerated)
* @param {string} repo absolute path to the repo root
* @param {{assets?: string[]}} [opts] data files this gate JUDGES (tree-relative, e.g.
* 'assets/tables/doxa-gospel-access.json'); their served bytes must equal disk too

View file

@ -8,7 +8,7 @@
* { key:'map', element:'doxa-research-map', src:'../../doxa-research-map/doxa-research-map.js' }
*
* That `src` is resolved BY THE BROWSER, relative to the composite's generated leaf page, which is
* served two levels deep (doxa-maps/<bundle>/<profile>/index.html). So a sibling bundle is reached
* served two levels deep (<TREE_NAME>/<bundle>/<profile>/index.html). So a sibling bundle is reached
* with `../../<bundle>/<bundle>.js`. Get the depth wrong by one and you get a 404.
*
* ★ THE FAILURE IS SILENT, WHICH IS WHY IT NEEDS A GATE. StackLayout mounts each section inside a

View file

@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* check-dev-assets — under the DEV SERVER, every plugin reaches the shared asset pool.
*
* WHY (2026-09-25, reported from a laptop running `bun run dev`): under the Plugin Studio, Big Picture,
* Regions, UPG-100 (and its posters), Cities with Internet and Gospel drew with NO boundary polygons.
* The built tree was fine, and every gate measured the built tree. In dev, a plugin that falls back to
* the page-relative './assets' on a /__leaf/<bundle>/<profile> page asked for /__leaf/<bundle>/assets/…,
* which the dev server answered with the leaf's own HTML at HTTP 200: JSON.parse failed, and the map
* drew with no polygons, previews, fonts or tables. One cause, four faces.
*
* WHAT IS ASSERTED, on a dev server whose identity is printed first:
* HTTP boundaries/, previews/, fonts/, images/ and tables/ each resolve (200, not HTML) at every
* prefix a dev page can produce: /assets/, /__plugin-gallery/assets/, /<tree>/assets/,
* /__leaf/<bundle>/assets/ and /__leaf/<bundle>/<profile>/assets/.
* LEAVES every leaf of the seven studio maps (Big Picture, Simple Research, Regions, UPG-100, Prayer
* Progress, Cities, Gospel): its dev page hands the element assetsBase "/assets" (as a built
* leaf hands "../../assets"); a boundary file and a table under THAT base answer 200 with bytes
* that JSON.parse, and a bogus file under it is a real 404, not HTML.
* HEADLESS by project rule (browsers are one agent's, on this machine): whether each map then DRAWS
* its polygons is a visual check, handed over as "needs eyes".
*
* USAGE node internal/check-dev-assets.mjs [--url http://127.0.0.1:5190]
* Without --url it starts a SHORT-LIVED dev server for THIS checkout on a free port and stops it.
* EXIT 0 pass · 1 an asset does not resolve · 75 not measured.
*/
import { spawn, spawnSync } from 'node:child_process'
import { createServer } from 'node:http'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const MAP_BUNDLES = ['dashboard-plus-grid', 'doxa-research-map', 'regions-dashboard', 'my-upg-100-list', 'doxa-simple-map', 'cities-internet', 'gospel-resources']
const JSON_SAMPLES = ['boundaries/world-adm0.geojson', 'tables/country-connectivity.json']
const SAMPLES = ['boundaries/world-adm0.geojson', 'previews/andean-caribbean.webp', 'fonts/bebas-neue/bebas-neue-latin.woff2', 'images/worldmap.svg', 'tables/arclight-index.json']
let BASE = arg('--url', ''), dev = null
const stop = () => { if (dev) { try { process.kill(-dev.pid) } catch { try { dev.kill() } catch { /* gone */ } } } }
if (!BASE) {
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) })
dev = spawn('bun', ['x', 'vite', '--port', String(port), '--strictPort', '--host', '127.0.0.1'], { cwd: ROOT, stdio: 'ignore', detached: true })
BASE = `http://127.0.0.1:${port}`
let up = false
for (let i = 0; i < 120 && !up; i++) { await new Promise((r) => setTimeout(r, 500)); up = await fetch(BASE + '/').then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`DEV-ASSETS: NOT MEASURED — the dev server did not come up on ${BASE}`); process.exit(75) }
const sha = spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim()
const dirty = spawnSync('git', ['status', '--porcelain', '--', 'library', 'plugin-designs', 'internal', 'vite.config.js'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim()
console.log(`DEV-ASSETS: measuring a dev server started for THIS checkout — ${ROOT} @ ${sha}${dirty ? ' + uncommitted changes' : ''} on ${BASE}`)
} else {
const id = await fetch(BASE + '/__dev-sha').then((r) => (r.ok ? r.text() : '')).catch(() => '')
console.log(`DEV-ASSETS: measuring ${BASE}${id ? ` — serving ${id.trim()}` : ' — (it does not report which commit it serves)'}`)
}
let bad = 0
try {
// ── HTTP ──
const prefixes = ['/assets/', '/__plugin-gallery/assets/', `/${TREE_NAME}/assets/`, '/__leaf/regions-dashboard/assets/', '/__leaf/my-upg-100-list/upg-100-list/assets/']
for (const p of prefixes) {
const res = []
for (const s of SAMPLES) {
const r = await fetch(BASE + p + s).catch(() => null)
const ct = r ? r.headers.get('content-type') || '' : ''
const ok = r && r.status === 200 && !/text\/html/.test(ct)
if (!ok) { bad++; res.push(`${s.split('/')[0]} ${r ? r.status : 'ERR'}${/html/.test(ct) ? ' (HTML)' : ''}`) }
}
console.log(` ${res.length ? 'FAIL' : 'ok '} ${p.padEnd(44)} ${res.length ? res.join(', ') : SAMPLES.map((s) => s.split('/')[0]).join(' ') + ' all 200'}`)
}
// ── EVERY LEAF OF THE SEVEN MAPS: the base its page hands the element serves REAL, parseable data ──
// (bytes + JSON.parse, never an HTML fallback), and a bogus path under the same base is a real 404.
const reg = await fetch(BASE + '/__plugin-gallery/registry.json').then((r) => r.json()).catch(() => [])
const leaves = []
for (const b of reg) if (MAP_BUNDLES.includes(b.bundle)) for (const it of b.items || []) leaves.push(`/__leaf/${b.bundle}/${it.profile}`)
if (leaves.length < MAP_BUNDLES.length) { bad++; console.log(` FAIL registry lists ${leaves.length} leaves for the ${MAP_BUNDLES.length} map bundles`) }
for (const leaf of leaves) {
const html = await fetch(BASE + leaf).then((r) => r.text()).catch(() => '')
const m = /profile-config='([^']*)'/.exec(html)
let base = ''
try { base = JSON.parse(m[1]).assetsBase || '' } catch { /* no config */ }
const out = []
let ok = base === '/assets'
if (!ok) out.push(`assetsBase ${JSON.stringify(base)}`)
const at = (f) => base ? BASE + base + '/' + f : new URL('assets/' + f, BASE + leaf).href
for (const f of JSON_SAMPLES) {
const r = await fetch(at(f)).catch(() => null)
const body = r ? await r.text() : ''
let parsed = false
try { JSON.parse(body); parsed = true } catch { /* not JSON */ }
if (!r || r.status !== 200 || !parsed) { ok = false; out.push(`${f} ${r ? r.status : 'ERR'}${parsed ? '' : ' NOT JSON'}`) } else out.push(`${f.split('/').pop()} ${body.length} B`)
}
const ctl = await fetch(at('boundaries/no-such-file.geojson')).catch(() => null)
if (!ctl || ctl.status !== 404) { ok = false; out.push(`bogus path ${ctl ? ctl.status : 'ERR'} (want 404)`) }
if (!ok) bad++
console.log(` ${ok ? 'ok ' : 'FAIL'} ${leaf.padEnd(52)} assetsBase ${base || '(none)'}: ${out.join(', ')}${ok ? ', bogus 404' : ''}`)
}
} finally { stop() }
if (bad) { console.log(`DEV-ASSETS: FAIL — ${bad} check(s): the dev server does not hand every plugin the asset pool.`); process.exit(1) }
console.log('DEV-ASSETS: PASS — the pool resolves at every prefix a dev page can produce, and dev leaves hand it to their element.')

View file

@ -44,7 +44,7 @@
* USAGE
* node internal/check-facet-changes-map.mjs # against BASE_URL (default dev 5173)
* node internal/check-facet-changes-map.mjs --self-test # NEGATIVE test: the gate must FAIL
* BASE_URL=http://127.0.0.1:4173 PROBE_PATH=/doxa-maps/... node internal/check-facet-changes-map.mjs
* BASE_URL=http://127.0.0.1:4173 PROBE_PATH=/<TREE_NAME>/... node internal/check-facet-changes-map.mjs
*/
import { chromium } from 'playwright'
@ -63,7 +63,7 @@ import { chromium } from 'playwright'
//
// SO: when you need a trustworthy answer rather than a quick one, run it against the BUILT tree:
// BASE_URL=http://127.0.0.1:4173 \
// PROBE_PATH=/doxa-maps/research-dashboard/research-dashboard/index.html \
// PROBE_PATH=/<TREE_NAME>/research-dashboard/research-dashboard/index.html \
// node internal/check-facet-changes-map.mjs
// The built tree can be stale — that is the trade — but it is DETERMINISTIC, which is what a gate
// needs. The dev default is kept because it is right for hand-running against uncommitted source.
@ -76,7 +76,7 @@ const PATH_ = process.env.PROBE_PATH || '/'
// run on one machine and a default port belongs to whichever started first — measured
// 2026-09-24 on :4173. This gate reports on how a map's source data changes when a facet is
// picked, so a run against another tree describes somebody else's data flow. The fingerprint
// is public/doxa-maps/manifest.json; a dev server serves it straight off disk, so this stays
// is public/<TREE_NAME>/manifest.json; a dev server serves it straight off disk, so this stays
// quiet in the normal `bun run dev` case and only speaks when the bytes really differ.
{
const { servesThisTree } = await import('./_serves-this-tree.mjs')

View file

@ -106,7 +106,18 @@ try {
const sr = document.querySelector('gospel-resources-map')?.shadowRoot
if (!sr) return []
const out = new Set()
/* ═══ A TABLE CELL IS DATA, NOT CHROME — fifth correction ════════════════════════
/* ═══ SETTLED: ORGANISATION AND PRODUCT NAMES STAY ENGLISH ═══════════════════════
* Ruled 2026-09-25, not inferred here: proper nouns are published as proper nouns in a
* localised product, exactly as res.bibleStories (Open Bible Stories, unfoldingWord)
* and res.scriptureEarth are already exempted in check-i18n-never-translated. Scope of
* the ruling is the Source column and product names ONLY.
* It is a VERDICT, not a bookmark — nothing here is waiting on anyone.
* Note what it does NOT cover: res.jesusFilm still has to render translated where it is
* used as a resource-type LABEL, and does. The structural rule below is what lets both
* be true at once — the same words are a name in a Source cell and a label in the
* chrome, and only the position tells them apart.
*
* ═══ A TABLE CELL IS DATA, NOT CHROME — fifth correction ════════════════════════
* This gate's own heading says it reads the CHROME. Table cells are the data, and
* judging them produced two false findings that were each a real English string
* sitting exactly where it belongs:
@ -172,10 +183,33 @@ try {
looking for a rendering bug that did not exist. */
if (!labels.length) { unmeasured.push(leaf + ' ' + lc + ': the walk collected no labels'); continue }
const labelSet = new Set(labels)
/* ═══ AN INTERPOLATED STRING NEVER EQUALS ITS CATALOGUE ENTRY ════════════════════
* Whole-label equality is the right unit for a fixed label and blind to a sentence
* carrying a number: the catalogue holds "Across all {n} languages in the corpus." and
* the screen shows "Across all 5,067 languages in the corpus." Those are never equal, so
* three chart subtitles could have shipped untranslated in eleven locales with this gate
* reporting PASS — coverage I would have claimed and not had.
* So a placeholder becomes a wildcard and the comparison is by pattern. Everything else
* is still compared by equality, which is what keeps a word inside a sentence from
* impersonating a label. */
const PLACEHOLDER = /\{\w+\}/
const asPattern = (str) => new RegExp('^' + str.split(/\{\w+\}/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.{1,40}') + '$')
const someLabelMatches = (str) => {
const re = asPattern(str)
for (const l of labelSet) if (re.test(l)) return true
return false
}
for (const [key, e] of Object.entries(en)) {
const t = loc[key]
if (!t || t === e) continue
compared++
if (PLACEHOLDER.test(e)) {
if (someLabelMatches(e) && !someLabelMatches(t)) {
leaks.push(leaf + ' ' + lc + ' ' + key + ': an interpolated label reads English "' + e.slice(0, 40) + '"')
}
continue
}
if (labelSet.has(e) && !labelSet.has(t)) leaks.push(leaf + ' ' + lc + ' ' + key + ': a label reads English "' + e.slice(0, 40) + '"')
}
}

View file

@ -43,7 +43,11 @@ const POISON = process.argv.includes('--poison')
// (cities-internet, key-cities, upg-100-list, RegionCountryMap) — copying it would give each of
// them their own drifting copy of the pan-first logic and the poison arm, which are the two parts
// that took corrections to get right. The leaf is the only thing that differs.
// node internal/check-gospel-rtl-shaping.mjs [--poison] [--leaf doxa-maps/<bundle>/<profile>/index.html]
// node internal/check-gospel-rtl-shaping.mjs [--poison] [--leaf <TREE_NAME>/<bundle>/<profile>/index.html]
//
// ⚠ AND THE DEFAULT READS THE TREE NAME RATHER THAN SPELLING IT. The tree was renamed
// doxa-maps -> dx-maps; a default of "doxa-maps/..." 404s, and a probe reporting "0 problems"
// against a 404 reads exactly like success. The build config is the one place that knows.
const leafArg = process.argv.indexOf('--leaf')
const LEAF = (leafArg > -1 && process.argv[leafArg + 1])
|| process.env.RTL_SHAPE_LEAF

View file

@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* check-leaf-engine-per-provider — a leaf loads the engine its PROVIDER asks for, and no other.
*
* THE BUG (card BUG-LEAF-PAGES-PRELOAD-MAPBOX-EVEN-UNDER-MAPLIBRE; measured by exp-shepherd at
* 577149af). generate-tree chose a leaf's engine peer at BUILD time, from the instance config, and
* baked it into <head> as static tags: mapbox-gl.css, the geocoder css, mapbox-gl.js and the geocoder
* js, all from api.mapbox.com. The provider is chosen at RUNTIME (resolveMapProvider:
* profile-config.provider → ?provider= → window.MAP_PROVIDER → mapbox). So a partner that embeds a
* leaf with ?provider=maplibre still made its visitors' browsers fetch 4 files from Mapbox, sending
* every visitor's IP to a company the partner chose not to use. No project rule permits that.
*
* WHAT IS ASSERTED, on THIS checkout's served tree (every leaf in the manifest, instances included):
* MAPLIBRE every leaf at ?provider=maplibre makes ZERO requests to any *.mapbox.com host; a MAP
* leaf must also still draw (a canvas with a live GL context, window.maplibregl defined),
* so a fix cannot pass by breaking the map.
* DEFAULT every MAP leaf at its default still DRAWS, with the SAME engine it drew with before.
* The engine is read from the canvas that drew (mapboxgl-canvas / maplibregl-canvas), not
* from which globals exist: some leaves are MapLibre by design (prayer-walk-planner, the
* ".../maplibre" instances), and a peer that merely loaded Mapbox proves nothing.
* --baseline <tree root> measures that tree too (e.g. main, with the static peer) and requires, per
* leaf: the default pass draws with the baseline's engine, and the maplibre pass draws with
* MapLibre wherever the baseline drew a map at all. Without it, a map leaf must simply draw.
* Paint here is a floor, not the verdict on looks: verify-map-paint and check:gospel-map-paints judge
* the map itself.
*
* USAGE node internal/check-leaf-engine-per-provider.mjs [--dir <tree root>] [--only a/b,c/d]
* Run through internal/browser-slot.sh.
* @browser-slot-max-hold 2700
* EXIT 0 pass · 1 a leaf calls Mapbox under maplibre, or a map stopped drawing · 75 not measured.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const ONLY = arg('--only', '') ? new Set(arg('--only', '').split(',')) : null
const BASELINE = arg('--baseline', '') ? resolve(arg('--baseline', '')) : null
const CONCURRENCY = 3
const MAPBOX_HOST = /(^|\.)mapbox\.com$/i
const manifestPath = join(DIR, TREE_NAME, 'manifest.json')
if (!existsSync(manifestPath)) { console.log(`LEAF-ENGINE: NOT MEASURED — no ${manifestPath}`); process.exit(75) }
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const leaves = []
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
const base = `${b.bundle}/${p.profile}`
const add = (id) => { if ((!ONLY || ONLY.has(id)) && existsSync(join(DIR, TREE_NAME, id, 'index.html'))) leaves.push({ id, isMap: !!p.isMap }) }
add(base)
for (const inst of p.instances || []) add(`${base}/${inst.id}`)
}
if (!leaves.length) { console.log('LEAF-ENGINE: NOT MEASURED — no leaves matched'); process.exit(75) }
async function serve(dir) {
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', dir], { stdio: 'ignore' })
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) }
if (!up) { try { srv.kill() } catch { /* gone */ } return null }
return { port, stop: () => { try { srv.kill() } catch { /* already gone */ } } }
}
const cur = await serve(DIR)
const base = BASELINE ? await serve(BASELINE) : null
const stop = () => { cur && cur.stop(); base && base.stop() }
if (!cur || (BASELINE && !base)) { stop(); console.log(`LEAF-ENGINE: NOT MEASURED — could not serve ${!cur ? DIR : BASELINE}`); process.exit(75) }
// A drawn map: a canvas, somewhere down the shadow roots, with a live WebGL context.
const DRAWN = `(() => {
const canvases = []
const walk = (r) => { for (const e of r.querySelectorAll('*')) { if (e.tagName === 'CANVAS') canvases.push(e); if (e.shadowRoot) walk(e.shadowRoot) } }
walk(document)
const drawing = canvases.filter((c) => { if (c.width < 50 || c.height < 50) return false
const gl = c.getContext('webgl2') || c.getContext('webgl'); return !!gl && !gl.isContextLost() })
const engines = [...new Set(drawing.map((c) => c.classList.contains('maplibregl-canvas') ? 'maplibre' : c.classList.contains('mapboxgl-canvas') ? 'mapbox' : 'other'))].sort()
return { canvases: canvases.length, live: drawing.length > 0, engine: engines.join('+') || 'none', mapboxgl: !!window.mapboxgl, maplibregl: !!window.maplibregl }
})()`
const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] })
async function load(srvPort, id, provider) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 } })
const page = await ctx.newPage()
const mapboxReqs = []
page.on('request', (r) => { try { if (MAPBOX_HOST.test(new URL(r.url()).hostname)) mapboxReqs.push(r.url()) } catch { /* data: etc */ } })
const url = `http://127.0.0.1:${srvPort}/${TREE_NAME}/${id}/index.html${provider === 'maplibre' ? '?provider=maplibre' : ''}`
try {
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {})
let d = null
for (let i = 0; i < 30; i++) { d = await page.evaluate(DRAWN).catch(() => null); if (d && d.live) break; await page.waitForTimeout(500) }
await page.waitForTimeout(1500) // late requests (a lazily injected geocoder) still count
return { ...d, mapboxReqs: [...mapboxReqs] }
} catch (e) { return { error: String(e.message || e).split('\n')[0], mapboxReqs: [...mapboxReqs] } }
finally { await ctx.close() }
}
const rows = []
const queue = leaves.slice()
await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const L = queue.shift()
const ml = await load(cur.port, L.id, 'maplibre')
const mb = L.isMap ? await load(cur.port, L.id, 'default') : null
const bl = base && L.isMap ? await load(base.port, L.id, 'default') : null
const problems = []
if (ml.error) problems.push(`maplibre load: ${ml.error}`)
if (ml.mapboxReqs.length) problems.push(`${ml.mapboxReqs.length} Mapbox request(s) under maplibre: ${[...new Set(ml.mapboxReqs.map((u) => new URL(u).pathname.split('/').slice(0, 3).join('/') || u))].slice(0, 3).join(', ')}`)
if (mb && mb.error) problems.push(`default load: ${mb.error}`)
if (bl && bl.error) problems.push(`baseline load: ${bl.error}`)
// What SHOULD draw: with a baseline, whatever drew there; without one, a map leaf draws something.
const baselineDrew = bl ? bl.live : (L.isMap ? true : false)
if (L.isMap && !ml.error && baselineDrew && !(ml.live && ml.engine.includes('maplibre'))) problems.push(`under maplibre the map does not draw with MapLibre (drew: ${ml.engine})`)
if (mb && !mb.error) {
if (bl && !bl.error) {
if (mb.engine !== bl.engine) problems.push(`the DEFAULT path changed engine: before ${bl.engine}, now ${mb.engine}`)
} else if (!mb.live) problems.push('the DEFAULT path draws no map')
}
rows.push({ id: L.id, isMap: L.isMap, mlReqs: ml.mapboxReqs.length, def: mb ? mb.engine : '', was: bl ? bl.engine : '', problems, unmeasured: !!(ml.error || (mb && mb.error) || (bl && bl.error)) })
}
}))
await browser.close(); stop()
rows.sort((a, b) => a.id.localeCompare(b.id))
console.log(`LEAF-ENGINE: ${rows.length} leaves (${rows.filter((r) => r.isMap).length} maps) on ${DIR}${BASELINE ? ` — baseline ${BASELINE}` : ''}`)
for (const r of rows) console.log(` ${r.problems.length ? (r.unmeasured ? '?? ' : 'FAIL') : 'ok '} ${r.id.padEnd(46)} ${r.isMap ? 'map ' : ' '} mapbox reqs under maplibre: ${String(r.mlReqs).padStart(2)}${r.def ? ` default draws: ${r.def}${r.was ? ` (was ${r.was})` : ''}` : ''}${r.problems.length ? ' — ' + r.problems.join('; ') : ''}`)
const fails = rows.filter((r) => r.problems.length && !r.unmeasured)
if (fails.length) {
console.log(`LEAF-ENGINE: FAIL — ${fails.length} leaf/leaves: under ?provider=maplibre they still call Mapbox, or a map stopped drawing.`)
process.exit(1)
}
if (rows.some((r) => r.unmeasured)) { console.log('LEAF-ENGINE: NOT MEASURED — a leaf did not load.'); process.exit(75) }
console.log(`LEAF-ENGINE: PASS — no leaf calls Mapbox under maplibre; every map draws with MapLibre under maplibre and with ${BASELINE ? 'the same engine as the baseline' : 'an engine'} by default.`)

View file

@ -615,7 +615,6 @@ function forgeHtml(o) {
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(o.title)}</title>
<link rel="icon" href="data:,">
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<style>${FORGE_CSS}</style>
</head>
<body>
@ -798,13 +797,14 @@ function mapPageHtml(opts) {
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js"></script>`
: `<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>`
: `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->`
return `<!DOCTYPE html>
<html lang="en">
<head>
@ -868,7 +868,11 @@ ${apiScript}
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = ${JSON.stringify(tokenFree)};
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = ${JSON.stringify(tokenFree)} || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -255,8 +255,19 @@ export function autoSpaDevPlugin(token = '') {
// <tree>/assets/* too: a plugin nested in a cap-5 shell resolves its assets against the tree base
// the shell hands it (plugin-studio passes /__plugin-gallery/), exactly as it would against
// <tree>/assets in the built tree. Only /assets/ was served, so every nested data table 404'd.
//
// ★ AND ANY /__leaf/…/assets/* (2026-09-25, reported from a laptop dev server: no boundary polygons
// on Big Picture, Regions, UPG-100, Cities, Gospel). A dev leaf page is /__leaf/<bundle>/<profile>,
// and a plugin that falls back to the PAGE-RELATIVE './assets' asks for /__leaf/<bundle>/assets/…
// — which the leaf route below answered with its own HTML at HTTP 200. JSON.parse then failed and
// the map drew with no polygons, no previews, no tables. The built leaf never hits this: its page
// hands the element assetsBase "../../assets". Serving the pool at every such prefix makes the
// page-relative default resolve in dev exactly as it does in the tree (/<tree>/assets included).
const leafAsset = /^\/__leaf\/(?:[^/]+\/){0,3}assets\//.exec(path)
const assetPrefix = path.startsWith('/assets/') ? '/assets/'
: path.startsWith('/__plugin-gallery/assets/') ? '/__plugin-gallery/assets/' : ''
: path.startsWith('/__plugin-gallery/assets/') ? '/__plugin-gallery/assets/'
: path.startsWith(`/${treeName}/assets/`) ? `/${treeName}/assets/`
: leafAsset ? leafAsset[0] : ''
if (assetPrefix) {
let rel = ''
try { rel = decodeURIComponent(path.slice(assetPrefix.length)) } catch { rel = '' }
@ -303,7 +314,9 @@ export function autoSpaDevPlugin(token = '') {
const apiBase = leafCfg.apiBaseUrl || ''
// profile-config carries the same shape the built leaves pass, so a profile/instance
// renders the SAME view here as in production rather than the bundle's default.
const pc = JSON.stringify(instance ? { profile, instanceId: instance } : { profile })
// assetsBase too, as the built leaf's BASE_CONFIG carries it ("../../assets" there; root-absolute
// here, because a /__leaf/ URL's depth differs from the tree's and the pool is served at /assets).
const pc = JSON.stringify(instance ? { profile, instanceId: instance, assetsBase: '/assets' } : { profile, assetsBase: '/assets' })
// The composition tier of THIS profile, read from the same discovery the gallery uses.
// `nested-elements-mfe` (cap-4) is the StackLayout scroll-stack tier — see the style note
// below. An unknown profile falls through to the fixed-viewport leaf, the safe default.

View file

@ -29,7 +29,7 @@
*
* USAGE
* bun run verify:5-caps # against the live catalog
* BASE=http://127.0.0.1:8080/doxa-maps/ node internal/verify-5-capabilities.mjs # a local serve
* BASE=http://127.0.0.1:8080/<TREE_NAME>/ node internal/verify-5-capabilities.mjs # a local serve
* Exits non-zero if any level fails, so it can gate a release.
*/
import { chromium } from 'playwright'

View file

@ -214,6 +214,15 @@ try {
if (!ALLOWED.test(u)) bad.push(`${c} -> ${u}`)
}
}
/* ★ THE SUN BLOCK IS A THIRD PLACE URLS LIVE (2026-09-25). The Deaf bridge is carried as a
single top-level object rather than per-language under codes[], because SUN is one signed
language for all of them — so the loop above cannot see it. Scanned here the moment it was
added, rather than discovered later the way the access table was. */
for (const u of [idx.sun?.scripture?.u, idx.sun?.stories?.u, idx.sun?.about]) {
if (!u) continue
scanned++
if (!ALLOWED.test(u)) bad.push(`sun -> ${u}`)
}
if (!scanned) fails.push('pin index carries no links at all — this check would pass vacuously')
if (bad.length) fails.push(`${bad.length} link(s) on an unscreened publisher (e.g. ${bad[0]})`)
linkCount = scanned

View file

@ -166,10 +166,12 @@ const splitErrors = (lines) => {
return { env, real }
}
// Opt-in viewport for a phone pass (GATE_VIEWPORT=390x844); the default is unchanged.
const GATE_VIEWPORT = (() => { const m = /^(\d+)x(\d+)$/.exec(process.env.GATE_VIEWPORT || ''); return m ? { width: +m[1], height: +m[2] } : null })()
let bad = 0
for (const url of urls) {
const name = url.split(`/${TREE_NAME}/`)[1] || url
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
const page = await browser.newPage({ viewport: GATE_VIEWPORT || { width: 1280, height: 800 } })
const errors = []
// ⚠ THE STYLESHEET COMPLAINT IS A WARNING, NOT AN ERROR, so filtering to type()==='error' above
// meant this gate could never see it. Mapbox emits "This page appears to be missing CSS

View file

@ -168,6 +168,15 @@
"keyScripture": "الكتاب المقدس",
"keyFilmStories": "الفيلم / القصص",
"keyAudioEtc": "صوتيات / عرض / تدريب",
"unresolvedCode": "رمز خارج ISO 639-3 — غير محسوم، لم يُقَس"
"unresolvedCode": "رمز خارج ISO 639-3 — غير محسوم، لم يُقَس",
"c1Title": "ما الذي وُجد، بحسب نوع المورد",
"c1Sub": "عبر جميع اللغات البالغة {n} في مجموعة البيانات. كل عمود يَعُدّ اللغات، لا الشعوب.",
"c1Unit": "لغات وُجد فيها المورد ضمن المصادر التي جرى البحث فيها",
"c2Title": "أكبر التجمعات السكانية التي لم يُعثر لها على شيء",
"c2Sub": "اللغات الـ{n} التي لا يوجد لها كتاب مقدس ولا فيلم في المصادر التي جرى البحث فيها، مرتَّبة بحسب عدد سكان الشعوب التي تتحدثها.",
"c2Unit": "أشخاص في الشعوب التي هذه لغتها الأساسية — لا المتحدثين بها عمومًا",
"c3Title": "البلدان الأكثر لغاتٍ غير مخدومة",
"c3Sub": "البلدان الـ{n} الأكثر لغاتٍ لم يُعثر لها على شيء. طول العمود هو كل لغة موجودة في ذلك البلد.",
"c3Unit": "لغات موجودة في البلد (اللغة العابرة للحدود تُحتسب في كل بلد)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "Bibeltext",
"keyFilmStories": "Film / Geschichten",
"keyAudioEtc": "Audio / Präsentation / Schulung",
"unresolvedCode": "Code außerhalb ISO 639-3 — ungeklärt, nicht gemessen"
"unresolvedCode": "Code außerhalb ISO 639-3 — ungeklärt, nicht gemessen",
"c1Title": "Was GEFUNDEN wurde, nach Ressourcentyp",
"c1Sub": "Über alle {n} Sprachen im Datenbestand. Jeder Balken zählt SPRACHEN, nicht Volksgruppen.",
"c1Unit": "Sprachen, in denen die Ressource in den geprüften Quellen gefunden wurde",
"c2Title": "Größte Bevölkerungen ohne jeden Fund",
"c2Sub": "Die {n} Sprachen ohne Bibeltext und ohne Film in den geprüften Quellen, sortiert nach der Bevölkerung der Volksgruppen, die sie sprechen.",
"c2Unit": "Menschen in den Volksgruppen, deren PRIMÄRE Sprache dies ist — nicht Sprecher der Sprache",
"c3Title": "Länder mit den meisten unversorgten Sprachen",
"c3Sub": "Die {n} Länder mit den meisten Sprachen, für die nichts gefunden wurde. Die Balkenlänge ist jede im Land vorhandene Sprache.",
"c3Unit": "im Land vorhandene Sprachen (eine grenzüberschreitende Sprache zählt in jedem Land)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "scripture",
"keyFilmStories": "film / stories",
"keyAudioEtc": "audio / presentation / training",
"unresolvedCode": "code outside ISO 639-3 — unresolved, not measured"
"unresolvedCode": "code outside ISO 639-3 — unresolved, not measured",
"c1Title": "What was FOUND, by resource type",
"c1Sub": "Across all {n} languages in the corpus. Each bar counts LANGUAGES, not people groups.",
"c1Unit": "languages where the resource was found in the pools checked",
"c2Title": "Largest populations with nothing found",
"c2Sub": "The {n} languages with no scripture and no film in the pools checked, ranked by the population of the people groups who speak them.",
"c2Unit": "people in the people groups whose PRIMARY language this is — not speakers of the language",
"c3Title": "Countries with the most unserved languages",
"c3Sub": "The {n} countries with the most languages that have nothing found. Bar length is every language present in that country.",
"c3Unit": "languages present in the country (a cross-border language counts in each)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "escrituras",
"keyFilmStories": "película / historias",
"keyAudioEtc": "audio / presentación / formación",
"unresolvedCode": "código fuera de ISO 639-3 — sin resolver, no medido"
"unresolvedCode": "código fuera de ISO 639-3 — sin resolver, no medido",
"c1Title": "Qué se ENCONTRÓ, por tipo de recurso",
"c1Sub": "En las {n} lenguas del conjunto de datos. Cada barra cuenta LENGUAS, no etnias.",
"c1Unit": "lenguas en las que se encontró el recurso en las fuentes consultadas",
"c2Title": "Mayores poblaciones sin nada encontrado",
"c2Sub": "Las {n} lenguas sin escrituras ni película en las fuentes consultadas, ordenadas por la población de las etnias que las hablan.",
"c2Unit": "personas en las etnias cuya lengua PRINCIPAL es esta — no hablantes de la lengua",
"c3Title": "Países con más lenguas desatendidas",
"c3Sub": "Los {n} países con más lenguas sin nada encontrado. La longitud de la barra es cada lengua presente en ese país.",
"c3Unit": "lenguas presentes en el país (una lengua transfronteriza cuenta en cada uno)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "écritures",
"keyFilmStories": "film / histoires",
"keyAudioEtc": "audio / présentation / formation",
"unresolvedCode": "code hors ISO 639-3 — non résolu, non mesuré"
"unresolvedCode": "code hors ISO 639-3 — non résolu, non mesuré",
"c1Title": "Ce qui a été TROUVÉ, par type de ressource",
"c1Sub": "Sur l'ensemble des {n} langues du corpus. Chaque barre compte des LANGUES, pas des peuples.",
"c1Unit": "langues où la ressource a été trouvée dans les sources consultées",
"c2Title": "Populations les plus nombreuses sans rien de trouvé",
"c2Sub": "Les {n} langues sans Écritures ni film dans les sources consultées, classées selon la population des peuples qui les parlent.",
"c2Unit": "personnes dans les peuples dont c'est la langue PRINCIPALE — non les locuteurs de la langue",
"c3Title": "Pays comptant le plus de langues non desservies",
"c3Sub": "Les {n} pays comptant le plus de langues sans rien de trouvé. La longueur de la barre correspond à chaque langue présente dans ce pays.",
"c3Unit": "langues présentes dans le pays (une langue transfrontalière compte dans chacun)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "पवित्र शास्त्र",
"keyFilmStories": "फ़िल्म / कहानियाँ",
"keyAudioEtc": "ऑडियो / प्रस्तुति / प्रशिक्षण",
"unresolvedCode": "ISO 639-3 से बाहर कोड — अनसुलझा, मापा नहीं गया"
"unresolvedCode": "ISO 639-3 से बाहर कोड — अनसुलझा, मापा नहीं गया",
"c1Title": "क्या मिला, संसाधन के प्रकार के अनुसार",
"c1Sub": "डेटा की सभी {n} भाषाओं में। हर बार भाषाओं की गिनती करता है, जनसमूहों की नहीं।",
"c1Unit": "भाषाएँ जिनमें जाँचे गए स्रोतों में संसाधन मिला",
"c2Title": "सबसे बड़ी आबादी जिनके लिए कुछ नहीं मिला",
"c2Sub": "{n} भाषाएँ जिनके लिए जाँचे गए स्रोतों में न पवित्र शास्त्र है न फ़िल्म, उन्हें बोलने वाले जनसमूहों की आबादी के क्रम में।",
"c2Unit": "उन जनसमूहों के लोग जिनकी मुख्य भाषा यही है — भाषा के सभी वक्ता नहीं",
"c3Title": "सबसे अधिक असेवित भाषाओं वाले देश",
"c3Sub": "{n} देश जिनमें सबसे अधिक भाषाओं के लिए कुछ नहीं मिला। बार की लंबाई उस देश में मौजूद हर भाषा है।",
"c3Unit": "देश में मौजूद भाषाएँ (सीमा-पार भाषा हर देश में गिनी जाती है)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "scritture",
"keyFilmStories": "film / storie",
"keyAudioEtc": "audio / presentazione / formazione",
"unresolvedCode": "codice fuori ISO 639-3 — non risolto, non misurato"
"unresolvedCode": "codice fuori ISO 639-3 — non risolto, non misurato",
"c1Title": "Che cosa è stato TROVATO, per tipo di risorsa",
"c1Sub": "Su tutte le {n} lingue del corpus. Ogni barra conta LINGUE, non gruppi etnici.",
"c1Unit": "lingue in cui la risorsa è stata trovata nelle fonti controllate",
"c2Title": "Popolazioni più numerose senza nulla trovato",
"c2Sub": "Le {n} lingue senza Scritture né film nelle fonti controllate, ordinate per la popolazione dei gruppi che le parlano.",
"c2Unit": "persone nei gruppi la cui lingua PRINCIPALE è questa — non i parlanti della lingua",
"c3Title": "Paesi con più lingue non servite",
"c3Sub": "I {n} paesi con più lingue per cui non è stato trovato nulla. La lunghezza della barra è ogni lingua presente in quel paese.",
"c3Unit": "lingue presenti nel paese (una lingua transfrontaliera conta in ciascuno)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "escrituras",
"keyFilmStories": "filme / histórias",
"keyAudioEtc": "áudio / apresentação / treinamento",
"unresolvedCode": "código fora da ISO 639-3 — não resolvido, não medido"
"unresolvedCode": "código fora da ISO 639-3 — não resolvido, não medido",
"c1Title": "O que foi ENCONTRADO, por tipo de recurso",
"c1Sub": "Em todas as {n} línguas do conjunto de dados. Cada barra conta LÍNGUAS, não etnias.",
"c1Unit": "línguas em que o recurso foi encontrado nas fontes consultadas",
"c2Title": "Maiores populações sem nada encontrado",
"c2Sub": "As {n} línguas sem Escrituras e sem filme nas fontes consultadas, ordenadas pela população das etnias que as falam.",
"c2Unit": "pessoas nas etnias cuja língua PRINCIPAL é esta — não falantes da língua",
"c3Title": "Países com mais línguas desatendidas",
"c3Sub": "Os {n} países com mais línguas sem nada encontrado. O comprimento da barra é cada língua presente nesse país.",
"c3Unit": "línguas presentes no país (uma língua transfronteiriça conta em cada um)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "scripturi",
"keyFilmStories": "film / povestiri",
"keyAudioEtc": "audio / prezentare / instruire",
"unresolvedCode": "cod în afara ISO 639-3 — nerezolvat, nemăsurat"
"unresolvedCode": "cod în afara ISO 639-3 — nerezolvat, nemăsurat",
"c1Title": "Ce s-a GĂSIT, după tipul resursei",
"c1Sub": "Pe toate cele {n} limbi din setul de date. Fiecare bară numără LIMBI, nu grupuri etnice.",
"c1Unit": "limbi în care resursa a fost găsită în sursele verificate",
"c2Title": "Cele mai mari populații fără nimic găsit",
"c2Sub": "Cele {n} limbi fără Scripturi și fără film în sursele verificate, ordonate după populația grupurilor care le vorbesc.",
"c2Unit": "persoane din grupurile a căror limbă PRINCIPALĂ este aceasta — nu vorbitorii limbii",
"c3Title": "Țări cu cele mai multe limbi neservite",
"c3Sub": "Cele {n} țări cu cele mai multe limbi pentru care nu s-a găsit nimic. Lungimea barei este fiecare limbă prezentă în acea țară.",
"c3Unit": "limbi prezente în țară (o limbă transfrontalieră se numără în fiecare)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "писание",
"keyFilmStories": "фильм / истории",
"keyAudioEtc": "аудио / презентация / обучение",
"unresolvedCode": "код вне ISO 639-3 — не определён, не измерен"
"unresolvedCode": "код вне ISO 639-3 — не определён, не измерен",
"c1Title": "Что НАЙДЕНО, по типу ресурса",
"c1Sub": "По всем {n} языкам в наборе данных. Каждый столбец считает ЯЗЫКИ, а не народы.",
"c1Unit": "языки, в которых ресурс найден в проверенных источниках",
"c2Title": "Самые многочисленные народы, для которых ничего не найдено",
"c2Sub": "{n} языков, для которых в проверенных источниках нет ни Писания, ни фильма, упорядоченные по численности говорящих на них народов.",
"c2Unit": "люди в народах, для которых это ОСНОВНОЙ язык, — не все говорящие на нём",
"c3Title": "Страны с наибольшим числом неохваченных языков",
"c3Sub": "{n} стран с наибольшим числом языков, для которых ничего не найдено. Длина столбца — каждый язык, присутствующий в этой стране.",
"c3Unit": "языки, присутствующие в стране (трансграничный язык считается в каждой)"
}
}

View file

@ -168,6 +168,15 @@
"keyScripture": "圣经",
"keyFilmStories": "影片 / 故事",
"keyAudioEtc": "音频 / 简介 / 训练",
"unresolvedCode": "ISO 639-3 之外的代码 — 未确定,未测量"
"unresolvedCode": "ISO 639-3 之外的代码 — 未确定,未测量",
"c1Title": "按资源类型统计已找到的内容",
"c1Sub": "涵盖数据中全部 {n} 种语言。每个条形统计的是语言,不是族群。",
"c1Unit": "在已检索的资源库中找到该资源的语言",
"c2Title": "一无所获的最大人口",
"c2Sub": "在已检索的资源库中既无圣经也无影片的 {n} 种语言,按使用这些语言的族群人口排序。",
"c2Unit": "以该语言为主要语言的族群人口 — 不是该语言的全部使用者",
"c3Title": "未得服事语言最多的国家",
"c3Sub": "一无所获语言最多的 {n} 个国家。条形长度为该国存在的全部语言。",
"c3Unit": "该国存在的语言(跨境语言在每个国家各计一次)"
}
}

View file

@ -110,7 +110,8 @@ export function resolveEmbedBase(profileConfig) {
* 3. bundleTreeRoot() + '/../assets' — derived from this bundle's own script URL, so a tree
* dropped on any host resolves correctly with no configuration at all.
* 4. './assets' — the historical default; correct on a leaf page and
* the only sane answer when nothing above is knowable (dev ESM, a hand-written host).
* the only sane answer when nothing above is knowable (a hand-written host). Under the dev
* server it is '/assets', where the dev server serves the pool.
*
* @param {object|null} [profileConfig] the parsed profile-config (already unref'd)
* @returns {string}
@ -156,6 +157,10 @@ export function resolveAssetsBase(profileConfig) {
}
}
// Under the dev server (Vite: import.meta.env.DEV; false in every build) the pool is served at the
// ROOT, /assets, and a dev page's own URL (/__leaf/<bundle>/<profile>) is not tree-shaped, so the
// page-relative default missed it. The built tree keeps './assets' (position-independent).
if (import.meta.env && import.meta.env.DEV) return '/assets'
return './assets'
}

View file

@ -39,9 +39,11 @@
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
"check:csv-shape:self-test": "node internal/check-csv-export-shape.mjs --self-test",
"check:dashboard-geocoder-provider": "node plugin-designs/research-dashboard/scripts/check-dashboard-geocoder-provider.mjs",
"check:deck-click": "node internal/check-deck-click.mjs",
"check:dep-names": "node internal/check-dependency-names.mjs",
"check:descriptions": "node internal/check-profile-descriptions.mjs",
"check:dev-assets": "node internal/check-dev-assets.mjs",
"check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs",
"check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test",
"check:done-evidence": "node internal/check-done-evidence.mjs",
@ -86,6 +88,7 @@
"check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
"check:label-joins": "node internal/check-label-joins.mjs",
"check:leaf-engine-per-provider": "node internal/check-leaf-engine-per-provider.mjs",
"check:leaf-english-leaks": "node internal/check-leaf-english-leaks.mjs",
"check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test",
"check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs",
@ -179,7 +182,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"library-index": "node internal/generate-library-index.mjs",
"packet-fixture": "node internal/generate-packet-fixture.mjs",
@ -242,6 +245,7 @@
"check:composite-locale-leaks": "a composite rendered in a non-English locale must not show a string the catalogue already translates. The criterion is a LOOKUP, not a category: English catalogue holds the string as a value, this locale holds a different value for that key, the page still renders English. The previous sweep split strings into chrome/data/third-party and scored single words like 'Region' and 'Prayer' as data, so 28 leaks on Big Picture and 41 on Simple Research counted as zero. Surfaces that a lookup cannot judge - vendor map chrome, table DATA cells, facet-rail values - are suppressed by NAME with a stated reason, never by word.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
"check:dashboard-geocoder-provider": "the research dashboard leaf at ?provider=maplibre makes ZERO requests to api.mapbox.com, and its search bars still suggest (built tree; browser gate). It was kept out of gates:post while the generated leaf hard-linked the Mapbox engine; the leaf now loads only the engine it is asked for.",
"check:dep-names": "every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",
"check:done-evidence": "a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",
@ -272,6 +276,7 @@
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
"check:legend-source": "the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",

View file

@ -32,7 +32,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Toggle list", openViews: "Open the list of views", expandList: "Expand list", collapseList: "Collapse list", collapse: "Collapse",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Prayer Walk Planner", 'tab.upg-neighbor': "UPG Neighbor", 'tab.key-cities': "Key Cities", 'tab.all-people-groups': "All People Groups", 'tab.country-posters': "Country Posters", 'tab.doxa-vision-card': "Doxa Vision Card",
'tab.prayer-walk-planner': "Prayer Walk Planner", 'tab.upg-neighbor': "UPG Neighbor", 'tab.key-cities': "Key Cities", 'tab.all-people-groups': "All People Groups", 'tab.pplr-data-maps': "PPLR Data Maps", 'tab.wall-of-people-groups': "Wall of People Groups", 'tab.country-posters': "Country Posters", 'tab.doxa-vision-card': "Doxa Vision Card",
desktopNotice: 'Big Picture only supports desktop — open this page on a desktop to use it.'
},
es: {
@ -52,7 +52,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Mostrar u ocultar la lista", openViews: "Abrir la lista de vistas", expandList: "Expandir la lista", collapseList: "Contraer la lista", collapse: "Contraer",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Caminatas de oración", 'tab.upg-neighbor': "Vecino UPG", 'tab.key-cities': "Ciudades clave", 'tab.all-people-groups': "Todos los pueblos", 'tab.country-posters': "Carteles por país", 'tab.doxa-vision-card': "Tarjeta de visión Doxa",
'tab.prayer-walk-planner': "Caminatas de oración", 'tab.upg-neighbor': "Vecino UPG", 'tab.key-cities': "Ciudades clave", 'tab.all-people-groups': "Todos los pueblos", 'tab.pplr-data-maps': "Mapas de datos PPLR", 'tab.wall-of-people-groups': "Muro de los pueblos", 'tab.country-posters': "Carteles por país", 'tab.doxa-vision-card': "Tarjeta de visión Doxa",
desktopNotice: 'Panorama general solo funciona en escritorio: abre esta página en una computadora para usarlo.'
},
fr: {
@ -72,7 +72,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Afficher ou masquer la liste", openViews: "Ouvrir la liste des vues", expandList: "Déplier la liste", collapseList: "Replier la liste", collapse: "Replier",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Marches de prière", 'tab.upg-neighbor': "Voisin UPG", 'tab.key-cities': "Villes clés", 'tab.all-people-groups': "Tous les peuples", 'tab.country-posters': "Affiches par pays", 'tab.doxa-vision-card': "Carte de vision Doxa",
'tab.prayer-walk-planner': "Marches de prière", 'tab.upg-neighbor': "Voisin UPG", 'tab.key-cities': "Villes clés", 'tab.all-people-groups': "Tous les peuples", 'tab.pplr-data-maps': "Cartes de données PPLR", 'tab.wall-of-people-groups': "Mur des peuples", 'tab.country-posters': "Affiches par pays", 'tab.doxa-vision-card': "Carte de vision Doxa",
desktopNotice: "Vue d'ensemble ne fonctionne que sur ordinateur : ouvrez cette page sur un ordinateur pour l'utiliser."
},
de: {
@ -92,7 +92,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Liste ein-/ausblenden", openViews: "Ansichtsliste öffnen", expandList: "Liste ausklappen", collapseList: "Liste einklappen", collapse: "Einklappen",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Gebetsspaziergänge", 'tab.upg-neighbor': "UPG-Nachbar", 'tab.key-cities': "Schlüsselstädte", 'tab.all-people-groups': "Alle Volksgruppen", 'tab.country-posters': "Länderposter", 'tab.doxa-vision-card': "Doxa-Visionskarte",
'tab.prayer-walk-planner': "Gebetsspaziergänge", 'tab.upg-neighbor': "UPG-Nachbar", 'tab.key-cities': "Schlüsselstädte", 'tab.all-people-groups': "Alle Volksgruppen", 'tab.pplr-data-maps': "PPLR-Datenkarten", 'tab.wall-of-people-groups': "Wand der Volksgruppen", 'tab.country-posters': "Länderposter", 'tab.doxa-vision-card': "Doxa-Visionskarte",
desktopNotice: 'Gesamtbild funktioniert nur am Desktop – öffnen Sie diese Seite an einem Computer, um es zu nutzen.'
},
pt: {
@ -112,7 +112,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Mostrar ou ocultar a lista", openViews: "Abrir a lista de visualizações", expandList: "Expandir a lista", collapseList: "Recolher a lista", collapse: "Recolher",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Caminhadas de oração", 'tab.upg-neighbor': "Vizinho UPG", 'tab.key-cities': "Cidades-chave", 'tab.all-people-groups': "Todos os povos", 'tab.country-posters': "Cartazes por país", 'tab.doxa-vision-card': "Cartão de visão Doxa",
'tab.prayer-walk-planner': "Caminhadas de oração", 'tab.upg-neighbor': "Vizinho UPG", 'tab.key-cities': "Cidades-chave", 'tab.all-people-groups': "Todos os povos", 'tab.pplr-data-maps': "Mapas de dados PPLR", 'tab.wall-of-people-groups': "Mural dos povos", 'tab.country-posters': "Cartazes por país", 'tab.doxa-vision-card': "Cartão de visão Doxa",
desktopNotice: 'Visão geral só funciona no computador — abra esta página em um computador para usá-la.'
},
it: {
@ -134,7 +134,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Mostra o nascondi l’elenco", openViews: "Apri l’elenco delle viste", expandList: "Espandi l’elenco", collapseList: "Comprimi l’elenco", collapse: "Comprimi",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Passeggiate di preghiera", 'tab.upg-neighbor': "Vicino UPG", 'tab.key-cities': "Città chiave", 'tab.all-people-groups': "Tutti i popoli", 'tab.country-posters': "Poster per paese", 'tab.doxa-vision-card': "Scheda visione Doxa",
'tab.prayer-walk-planner': "Passeggiate di preghiera", 'tab.upg-neighbor': "Vicino UPG", 'tab.key-cities': "Città chiave", 'tab.all-people-groups': "Tutti i popoli", 'tab.pplr-data-maps': "Mappe dati PPLR", 'tab.wall-of-people-groups': "Muro dei popoli", 'tab.country-posters': "Poster per paese", 'tab.doxa-vision-card': "Scheda visione Doxa",
desktopNotice: "Quadro d'insieme funziona solo su desktop: apri questa pagina su un computer per usarlo."
},
ro: {
@ -154,7 +154,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Afișează sau ascunde lista", openViews: "Deschide lista de vizualizări", expandList: "Extinde lista", collapseList: "Restrânge lista", collapse: "Restrânge",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Plimbări de rugăciune", 'tab.upg-neighbor': "Vecin UPG", 'tab.key-cities': "Orașe-cheie", 'tab.all-people-groups': "Toate popoarele", 'tab.country-posters': "Afișe pe țări", 'tab.doxa-vision-card': "Card de viziune Doxa",
'tab.prayer-walk-planner': "Plimbări de rugăciune", 'tab.upg-neighbor': "Vecin UPG", 'tab.key-cities': "Orașe-cheie", 'tab.all-people-groups': "Toate popoarele", 'tab.pplr-data-maps': "Hărți de date PPLR", 'tab.wall-of-people-groups': "Zidul popoarelor", 'tab.country-posters': "Afișe pe țări", 'tab.doxa-vision-card': "Card de viziune Doxa",
desktopNotice: 'Imaginea de ansamblu funcționează doar pe desktop — deschideți această pagină pe un computer pentru a o folosi.'
},
ru: {
@ -174,7 +174,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "Показать или скрыть список", openViews: "Открыть список представлений", expandList: "Развернуть список", collapseList: "Свернуть список", collapse: "Свернуть",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "Молитвенные прогулки", 'tab.upg-neighbor': "Сосед UPG", 'tab.key-cities': "Ключевые города", 'tab.all-people-groups': "Все народы", 'tab.country-posters': "Плакаты по странам", 'tab.doxa-vision-card': "Карточка видения Doxa",
'tab.prayer-walk-planner': "Молитвенные прогулки", 'tab.upg-neighbor': "Сосед UPG", 'tab.key-cities': "Ключевые города", 'tab.all-people-groups': "Все народы", 'tab.pplr-data-maps': "Карты данных PPLR", 'tab.wall-of-people-groups': "Стена народов", 'tab.country-posters': "Плакаты по странам", 'tab.doxa-vision-card': "Карточка видения Doxa",
desktopNotice: '«Общая картина» работает только на компьютере — откройте эту страницу на компьютере.'
},
ar: {
@ -194,7 +194,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "إظهار القائمة أو إخفاؤها", openViews: "فتح قائمة العروض", expandList: "توسيع القائمة", collapseList: "طي القائمة", collapse: "طي",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "جولات الصلاة", 'tab.upg-neighbor': "جار UPG", 'tab.key-cities': "المدن الرئيسية", 'tab.all-people-groups': "جميع الشعوب", 'tab.country-posters': "ملصقات الدول", 'tab.doxa-vision-card': "بطاقة رؤية Doxa",
'tab.prayer-walk-planner': "جولات الصلاة", 'tab.upg-neighbor': "جار UPG", 'tab.key-cities': "المدن الرئيسية", 'tab.all-people-groups': "جميع الشعوب", 'tab.pplr-data-maps': "خرائط بيانات PPLR", 'tab.wall-of-people-groups': "جدار الشعوب", 'tab.country-posters': "ملصقات الدول", 'tab.doxa-vision-card': "بطاقة رؤية Doxa",
desktopNotice: 'تعمل «الصورة الكاملة» على الحاسوب فقط — افتح هذه الصفحة على حاسوب لاستخدامها.'
},
hi: {
@ -214,7 +214,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "सूची दिखाएँ या छिपाएँ", openViews: "दृश्यों की सूची खोलें", expandList: "सूची फैलाएँ", collapseList: "सूची संक्षिप्त करें", collapse: "संक्षिप्त करें",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "प्रार्थना पदयात्रा", 'tab.upg-neighbor': "UPG पड़ोसी", 'tab.key-cities': "प्रमुख शहर", 'tab.all-people-groups': "सभी जनसमूह", 'tab.country-posters': "देश पोस्टर", 'tab.doxa-vision-card': "Doxa दर्शन कार्ड",
'tab.prayer-walk-planner': "प्रार्थना पदयात्रा", 'tab.upg-neighbor': "UPG पड़ोसी", 'tab.key-cities': "प्रमुख शहर", 'tab.all-people-groups': "सभी जनसमूह", 'tab.pplr-data-maps': "PPLR डेटा मानचित्र", 'tab.wall-of-people-groups': "जनसमूहों की दीवार", 'tab.country-posters': "देश पोस्टर", 'tab.doxa-vision-card': "Doxa दर्शन कार्ड",
desktopNotice: 'संपूर्ण चित्र केवल डेस्कटॉप पर काम करता है — इसे उपयोग करने के लिए यह पेज कंप्यूटर पर खोलें।'
},
zh: {
@ -234,7 +234,7 @@ export const STRINGS = {
// (plugin studio's catalog) so both shells name the same buttons the same way.
toggleList: "显示或隐藏列表", openViews: "打开视图列表", expandList: "展开列表", collapseList: "折叠列表", collapse: "折叠",
// Experimental bundles known today (rail-length names; a bundle not listed keeps its manifest title).
'tab.prayer-walk-planner': "祷告行走", 'tab.upg-neighbor': "UPG 邻居", 'tab.key-cities': "重点城市", 'tab.all-people-groups': "所有民族", 'tab.country-posters': "国家海报", 'tab.doxa-vision-card': "Doxa 异象卡",
'tab.prayer-walk-planner': "祷告行走", 'tab.upg-neighbor': "UPG 邻居", 'tab.key-cities': "重点城市", 'tab.all-people-groups': "所有民族", 'tab.pplr-data-maps': "PPLR 数据地图", 'tab.wall-of-people-groups': "民族之墙", 'tab.country-posters': "国家海报", 'tab.doxa-vision-card': "Doxa 异象卡",
desktopNotice: '全景仅支持桌面设备——请在电脑上打开此页面使用。'
}
}

View file

@ -55,10 +55,22 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
and the shipped English catalogue is the fallback rather than a broken key. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
/* ★ NUMBERS ARRIVE AS PARAMETERS, NEVER CONCATENATED. The subtitles carry a count, and a
sentence built by gluing a number between two fragments cannot be translated — word order
differs and several of these locales put the number where English does not. The catalogue
holds the whole sentence with a {n} placeholder instead.
THE FALLBACK HAS TO INTERPOLATE TOO. Without it a host that never installed i18n would render
the placeholder literally — "Across all {n} languages" on screen — which is worse than the
English sentence the fallback exists to protect. */
const interpolate = (str, params) =>
params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? params[k] : m)) : str
let tr = (key, params) => interpolate(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
tr = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
tr = (key, params) => {
const v = params ? i18n.t(key, params) : i18n.t(key)
return v === key ? interpolate(enLookup(key) ?? key, params) : v
}
} catch (_) { /* no i18n on this host */ }
@ -457,9 +469,9 @@ useShadowStyles(
v-if="active === 'coverage'"
:series="coverage"
:is-dark="isDark"
title="What was FOUND, by resource type"
:subtitle="`Across all ${fmt(nLang)} languages in the corpus. Each bar counts LANGUAGES, not people groups.`"
unit="languages where the resource was found in the pools checked"
:title="tr('gospel.charts.c1Title')"
:subtitle="tr('gospel.charts.c1Sub', { n: fmt(nLang) })"
:unit="tr('gospel.charts.c1Unit')"
:max="nLang"
:legend="[
{ label: tr('gospel.charts.keyScripture'), color: C.scripture },
@ -477,9 +489,9 @@ useShadowStyles(
v-else-if="active === 'gap'"
:series="topNone"
:is-dark="isDark"
title="Largest populations with nothing found"
:subtitle="`The ${TOP_N} languages with no scripture and no film in the pools checked, ranked by the population of the people groups who speak them.`"
unit="people in the people groups whose PRIMARY language this is — not speakers of the language"
:title="tr('gospel.charts.c2Title')"
:subtitle="tr('gospel.charts.c2Sub', { n: TOP_N })"
:unit="tr('gospel.charts.c2Unit')"
:legend="[
{ label: tr('gospel.charts.noResourceInPools'), color: C.none },
{ label: tr('gospel.charts.unresolvedCode'), color: C.unresolved },
@ -495,9 +507,9 @@ useShadowStyles(
v-else
:series="byCountry"
:is-dark="isDark"
title="Countries with the most unserved languages"
:subtitle="`The ${TOP_N} countries with the most languages that have nothing found. Bar length is every language present in that country.`"
unit="languages present in the country (a cross-border language counts in each)"
:title="tr('gospel.charts.c3Title')"
:subtitle="tr('gospel.charts.c3Sub', { n: TOP_N })"
:unit="tr('gospel.charts.c3Unit')"
:legend="[
{ label: tr('gospel.charts.nothingInPoolsLower'), color: C.none },
{ label: tr('gospel.charts.aResourceFound'), color: C.found },

View file

@ -203,6 +203,10 @@ const doxaTotal = ref(0) // every group on Doxa's roster, placed or not
const doxaUnplaced = ref(0) // on the roster, no coordinates in this build yet
const doxaByCountry = shallowRef({})
const pinCodes = ref({})
/* SUN — one signed language with Scripture in it, for Deaf groups whose own sign language has
nothing. Carried in the pin index beside the spoken bridges so the sheet and the access table
cannot disagree. */
const pinSun = ref(null)
const pinBridges = ref({}) // '<iso639-3>|<ISO3 country>' -> the in-country bridge, if any
/* Declared beside pinBridges. The previous edit USED this ref without declaring it —
a silent string replace that matched nothing — and the ReferenceError died inside the
@ -571,12 +575,20 @@ const signTally = computed(() => {
const pinTally = computed(() => {
const t = [0, 0, 0]
let hnr = 0
/* How many of the groups with NOTHING have somewhere to start anyway — a curated bridge, a
language their country actually speaks, or SUN for a Deaf group. Counted in the same pass
as the tiers, from the same rows, so the two numbers cannot drift apart. */
let wayIn = 0
for (const r of pinRows.value) {
t[r[6]]++
const c = pinCodes.value[r[4]]
if (r[6] !== 0 && c && canHear(c) && !hasTextScripture(c)) hnr++
if (r[6] === 0) {
const key = `${r[4]}|${r[3]}`
if (pinBridges.value[key] || pinCandidates.value[key] || (c && c.sg === 1 && pinSun.value)) wayIn++
}
}
return { none: t[0], partial: t[1], both: t[2], hnr, linked: t[1] + t[2], total: pinRows.value.length }
return { none: t[0], partial: t[1], both: t[2], hnr, linked: t[1] + t[2], total: pinRows.value.length, wayIn }
})
async function ensurePeopleGroups() {
@ -751,6 +763,34 @@ const groupsForCountry = computed(() => {
const withResources = computed(() => groupsForCountry.value.filter(hasAny))
const withoutResources = computed(() => groupsForCountry.value.filter((r) => !hasAny(r)))
/**
* ═══ THE COUNTRY'S GAP LIST SHOULD SAY WHERE TO START ═════════════════════════════════════
*
* The rows under "No resource found" carried a name, a language and a population, and nothing
* else — so the one thing a mobiliser opened the country for, "what do I do about these?",
* took one click per group. 369 of the 370 groups with nothing now have a way in, and it was
* reachable only by opening each pin in turn.
*
* TEXT, NOT A LINK, and structurally it could not be otherwise: these rows are <button>
* drill-ins and HTML forbids an anchor inside a button — the parser hoists it out and it stops
* being a link, which is why the rows that DO have resources are plain containers instead. The
* row opens the group sheet, and the sheet carries the bridge's real links.
*
* It names the bridge and nothing more. Saying "Bambara" here does not claim Bambara reaches
* this group — that is the local question the sheet spells out — but it does turn a list of
* dead ends into a list with a first move against each one.
*/
const wayInFor = (g) => {
const iso3 = selected.value?.iso3 || ''
const code = g.iso639_3 || ''
if (!code || !iso3) return ''
const c = pinCodes.value[code]
if (c && c.sg === 1) return pinSun.value ? 'SUN (signed)' : ''
const key = `${code}|${iso3}`
const b = pinBridges.value[key] || pinCandidates.value[key]
return b ? b.l : ''
}
// The header must count the SAME set the list below it shows. It used to read
// gospel-by-country.json, which is built from the curated 2,106-record subset, so after
// the list moved to the pins the card contradicted itself inside one view: "People
@ -850,6 +890,7 @@ async function load() {
pinCodes.value = pinData.codes || {}
pinBridges.value = pinData.bridges || {}
pinCandidates.value = pinData.bridgeCandidates || {}
pinSun.value = pinData.sun || null
/* Same seven-slot shape the rest of this file already speaks, so narrowing the roster
changes WHICH pins exist and nothing about how they are drawn, clicked or filtered. */
@ -1135,6 +1176,70 @@ const fmt = (n) => (Number(n) || 0).toLocaleString()
// The table lives on a SIBLING page, so "see all of these" is a URL. Built here rather
// than inline in the template so the group link and the country link cannot drift apart.
/**
* ═══ A BRIDGE NOBODY CAN FOLLOW IS NOT A WAY IN ═══════════════════════════════════════════
*
* Both bridge panels below NAMED a language and stopped there. For a group with nothing, the
* only link on the sheet was "Every resource in <their own language>" — which is, by
* definition, the empty table. MEASURED by clicking a gap pin: Syenara Senufo offered five
* "none found" chips, named Minianka as the way in, and the one thing to click led to nothing.
* So the sheet told a mobiliser where to go and then made them go and find it themselves.
*
* Nothing new has to be fetched. Both bridge records already carry the bridge's ISO 639-3
* code, and the pin index already holds that language's own verified links — Minianka is
* ebible.org/myk/ and jesusfilm.org/watch/jesus/minianka, on the same screened publishers and
* through the same doctrine scan as every other link here. The data was one lookup away.
*
* Joined on the CODE, never the name, for the reason the whole index is built that way.
*/
const bridgeResources = (b) => {
if (!b || !b.c) return null
const c = pinCodes.value[b.c]
if (!c) return null
/* ⚠ c.s IS NOT THE ONLY PLACE A READABLE SCRIPTURE LIVES, and reading only it lost the very
language this fix exists for: Bambara's text Bible is carried on the Faith Comes By Hearing
record (au.m names the media it holds), so the panel offered Bambara the film alone while a
full 66-book text sat one field away. Same field-list-goes-stale shape as the build script,
so the fallback is spelled out here rather than assumed. */
const fcbhText = c.au && /text/.test(c.au.m || '') ? c.au : null
return {
iso: b.c,
language: c.l || b.l || '',
scripture_url: c.s || (fcbhText ? fcbhText.u : ''),
scripture_title: c.sv || (fcbhText ? fcbhText.n : ''),
film_url: c.f || '',
film_title: c.fT || '',
}
}
/**
* The Deaf way in, for a group that has nothing filmed in its own sign language.
*
* WHY IT IS OFFERED ONLY WHEN THERE IS NOTHING. A group that already has Scripture in its own
* sign language does not need to learn a second one, and saying otherwise would read as a
* recommendation to leave their own language behind. Same rule the spoken bridges follow.
*
* It is a BRIDGE and says so: SUN has to be learned, and it closes no gap in their own language.
*/
const deafBridge = computed(() => {
const g = selectedGroup.value
if (!g || !g.is_sign || !pinSun.value) return null
const hasSomething = g.scripture_url || g.film_url || g.audio || g.bible_video ||
g.gospel_audio || g.scripture_earth
if (hasSomething) return null
const key = String(g.countries || '').toLowerCase().replace(/[^a-z]/g, '')
return { ...pinSun.value, organisation: pinSun.value.byCountry?.[key] || '' }
})
/* Resolved once per selection rather than on every reference in the template. */
const curatedBridgeRes = computed(() => bridgeResources(selectedGroup.value?.bridge))
const candidateBridgeRes = computed(() => bridgeResources(selectedGroup.value?.bridge_candidate))
/* The runner-up on a near-tie is named in the sentence above, so it needs links for exactly the
reason the primary does: a bridge nobody can follow is not a way in. Naming two languages and
linking one would make the second look like the lesser option when the whole point is that the
map will not choose between them. */
const altBridgeRes = computed(() => bridgeResources(selectedGroup.value?.bridge_candidate?.alt))
const tableLink = (q) => {
const p = new URLSearchParams({ tab: 'links', ...q })
return `../data-tables/index.html?${p.toString()}`
@ -1210,6 +1315,10 @@ useShadowStyles(
'padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px;min-height:24px}' +
'.gm-pinkey-h{font-size:14px;font-weight:600;margin-bottom:4px}' +
'.gm-caveat{margin-top:5px;font-size:13.5px;opacity:.72;line-height:1.4}' +
/* Quiet enough that the gap itself still reads first: the row is about what is MISSING, and
the way in is the footnote to it, not the headline. */
'.gm-pg-way{display:block;font-size:12.5px;color:#3b463d;opacity:.8;margin-top:2px}' +
'.is-dark .gm-pg-way{color:#d1d4cd}' +
'.gm-prose-note{opacity:.9;font-weight:600}' +
// DETAIL: a bottom sheet on a phone, a corner card on desktop.
// ON A PHONE THE SHEET IS THE PAGE, and the shared pin hint sits on top of it. The hint
@ -1445,6 +1554,19 @@ useShadowStyles(
<i class="gm-dot" :style="{ background: PIN_COLOR[0] }"></i>{{ t('gospel.legend.tier0') }}
<b>{{ fmt(pinTally.none) }}</b>
</button>
<!-- ═══ THE NUMBER BESIDE THE GAP IS THE ONE A MOBILISER CAME FOR ══════════════════
"370 with nothing found" is where the map used to stop, and it reads as a wall.
Every one of those groups now has somewhere to start — a curated bridge, a
language their own country speaks, or SUN for a Deaf group — and none of that was
visible without opening 370 pins one at a time.
It says "a way in", not "reached": a bridge has to be learnt, it is not Scripture
in their language, and the gap the red dot marks is unchanged. Counted from the
same rows as the number above it. English prose, like the rest of this block. -->
<p v-if="pinTally.none && pinTally.wayIn" class="gm-caveat" dir="ltr" style="margin:2px 0 0">
<b>{{ fmt(pinTally.wayIn) }} of those {{ fmt(pinTally.none) }}</b> have a possible way
in — a bridge language, or SUN for a Deaf group. Tap one to see it. A way in is not
the gospel in their own language; the gap is unchanged.
</p>
<!-- Not a colour, so it carries no dot: this cuts ACROSS the three tiers rather
than naming one of them. It is the translation-priority question. -->
<button type="button" class="gm-key gm-kbtn wide" :class="{ on: pinFilter === 'hnr' }"
@ -1826,6 +1948,22 @@ useShadowStyles(
<template v-else>the JESUS Film</template>.
<br><em>Bridge strength: {{ selectedGroup.bridge.st }}.</em> A second language is a
way in, not a translation — the gap in their own language is unchanged.
<!-- THE LINKS THAT MAKE IT FOLLOWABLE. Same chips, same publishers and the same
doctrine scan as the group's own resources above; only the language differs.
Rendered only when the index actually holds a URL, so a named bridge with
nothing behind it stays a name rather than becoming a dead chip. -->
<div v-if="curatedBridgeRes && (curatedBridgeRes.scripture_url || curatedBridgeRes.film_url)" class="gm-chips">
<a v-if="curatedBridgeRes.scripture_url" class="gm-chip ok wrap"
:href="curatedBridgeRes.scripture_url" target="_blank" rel="noopener">
{{ curatedBridgeRes.language }} · {{ curatedBridgeRes.scripture_title || t('gospel.res.scripture') }} ↗</a>
<a v-if="curatedBridgeRes.film_url" class="gm-chip ok wrap"
:href="curatedBridgeRes.film_url" target="_blank" rel="noopener">
{{ curatedBridgeRes.language }} · {{ curatedBridgeRes.film_title || t('gospel.res.jesusFilm') }} ↗</a>
<a class="gm-chip wrap"
:href="tableLink({ iso: curatedBridgeRes.iso, label: curatedBridgeRes.language })">
{{ t('gospel.sheet.everyResource') }} {{ curatedBridgeRes.language }} →</a>
</div>
</div>
<!-- THE WEAKER CASE, and it says so on its face. No curated bridge exists for this
group, but their country does have a language carrying Scripture and the film.
@ -1839,13 +1977,59 @@ useShadowStyles(
&& !selectedGroup.audio && !selectedGroup.gospel_audio && !selectedGroup.scripture_earth"
class="gm-sign" style="border-left-color:#d1d4cd" dir="ltr">
<b>Possibly a way in — worth checking locally.</b>
<br>Nothing was found in {{ selectedGroup.language || 'their language' }}. The largest
language in this country that does have Scripture and the JESUS Film is
<b>{{ selectedGroup.bridge_candidate.l }}</b>, spoken by
{{ selectedGroup.bridge_candidate.n }} people group<template v-if="selectedGroup.bridge_candidate.n !== 1">s</template>
here ({{ selectedGroup.bridge_candidate.pct }}% of this country's mapped population).
<br>Nothing was found in {{ selectedGroup.language || 'their language' }}.
<!-- TWO DIFFERENT DENOMINATORS, SAID PLAINLY. A CLDR candidate's percentage is a
share of the COUNTRY, which is what a reader assumes a percentage means. The
fallback's is a share of the PINNED population — a much smaller and invisible
denominator — so it is never printed as a bare percentage. -->
<template v-if="selectedGroup.bridge_candidate.src === 'cldr'">
<b>{{ selectedGroup.bridge_candidate.l }}</b> is spoken by
{{ selectedGroup.bridge_candidate.pct }}% of this country<template
v-if="selectedGroup.bridge_candidate.official"> and is an official language</template>,
and has Scripture and the JESUS Film.
<!-- A tie is not a ranking. Where a second language is within five points, both
are named rather than one being chosen by a rule the reader cannot see. -->
<template v-if="selectedGroup.bridge_candidate.alt">
So is <b>{{ selectedGroup.bridge_candidate.alt.l }}</b>, at
{{ selectedGroup.bridge_candidate.alt.pct }}%<template
v-if="selectedGroup.bridge_candidate.alt.official"> and official</template> —
the two are close enough that this map will not choose between them.
</template>
<div v-if="altBridgeRes && (altBridgeRes.scripture_url || altBridgeRes.film_url)" class="gm-chips">
<a v-if="altBridgeRes.scripture_url" class="gm-chip ok wrap"
:href="altBridgeRes.scripture_url" target="_blank" rel="noopener">
{{ altBridgeRes.language }} · {{ altBridgeRes.scripture_title || t('gospel.res.scripture') }} ↗</a>
<a v-if="altBridgeRes.film_url" class="gm-chip ok wrap"
:href="altBridgeRes.film_url" target="_blank" rel="noopener">
{{ altBridgeRes.language }} · {{ altBridgeRes.film_title || t('gospel.res.jesusFilm') }} ↗</a>
<a class="gm-chip wrap" :href="tableLink({ iso: altBridgeRes.iso, label: altBridgeRes.language })">
{{ t('gospel.sheet.everyResource') }} {{ altBridgeRes.language }} →</a>
</div>
</template>
<template v-else>
The largest language among the people groups mapped here that does have Scripture
and the JESUS Film is <b>{{ selectedGroup.bridge_candidate.l }}</b>, spoken by
{{ selectedGroup.bridge_candidate.n }} of them. That is a count of THIS MAP's
groups, not of the country — no published figure for the country was available.
</template>
<br><em>Not ranked and not recommended</em> — whether it actually reaches this group
is a local question, and the gap in their own language is unchanged.
<!-- THE LINKS THAT MAKE IT FOLLOWABLE. Same chips, same publishers and the same
doctrine scan as the group's own resources above; only the language differs.
Rendered only when the index actually holds a URL, so a named bridge with
nothing behind it stays a name rather than becoming a dead chip. -->
<div v-if="candidateBridgeRes && (candidateBridgeRes.scripture_url || candidateBridgeRes.film_url)" class="gm-chips">
<a v-if="candidateBridgeRes.scripture_url" class="gm-chip ok wrap"
:href="candidateBridgeRes.scripture_url" target="_blank" rel="noopener">
{{ candidateBridgeRes.language }} · {{ candidateBridgeRes.scripture_title || t('gospel.res.scripture') }} ↗</a>
<a v-if="candidateBridgeRes.film_url" class="gm-chip ok wrap"
:href="candidateBridgeRes.film_url" target="_blank" rel="noopener">
{{ candidateBridgeRes.language }} · {{ candidateBridgeRes.film_title || t('gospel.res.jesusFilm') }} ↗</a>
<a class="gm-chip wrap"
:href="tableLink({ iso: candidateBridgeRes.iso, label: candidateBridgeRes.language })">
{{ t('gospel.sheet.everyResource') }} {{ candidateBridgeRes.language }} →</a>
</div>
</div>
<p v-if="selectedGroup.scripture_title || selectedGroup.scripture_scope" class="gm-scope">
@ -1892,6 +2076,38 @@ useShadowStyles(
Society</b> and <b>DOOR International</b>, both of which need a key or an
agreement — this gap closes by asking them, not by searching harder.
</p>
<!-- ═══ AND HERE IS THE ONE WAY IN THAT EXISTS TODAY ════════════════════════════
The paragraph above explains the gap honestly and then leaves a mobiliser with
nothing to do, which for 93 groups and 17.5 million people is every Deaf group
on this map. A spoken bridge cannot reach them, so they were the only population
here with nothing AND no way in — structurally, not by bad luck.
SUN is a signed language built to be learnt ACROSS sign languages, with Scripture
in it. It is a bridge in the same sense the spoken ones are: it has to be learnt,
and it is no substitute for Scripture in their own sign language — which is why
it changes no tier and sits below the gap rather than closing it. -->
<div v-if="deafBridge" class="gm-sign" style="border-left-color:#92b195" dir="ltr">
<b>There is one signed language with Scripture in it.</b>
<br><b>SUN</b> is built to be learnt across sign languages rather than being tied to
one country, so it can be learnt by a Deaf community whose own sign language has
nothing. Published by {{ deafBridge.publisher }}.
<br><em>A bridge, not a translation</em> — it has to be learnt, and the gap in their
own sign language is unchanged.
<div class="gm-chips">
<a v-if="deafBridge.scripture" class="gm-chip ok wrap"
:href="deafBridge.scripture.u" target="_blank" rel="noopener">{{ deafBridge.scripture.t }} ↗</a>
<a v-if="deafBridge.stories" class="gm-chip ok wrap"
:href="deafBridge.stories.u" target="_blank" rel="noopener">{{ deafBridge.stories.t }} ↗</a>
<a v-if="deafBridge.about" class="gm-chip wrap"
:href="deafBridge.about" target="_blank" rel="noopener">About SUN ↗</a>
</div>
<!-- A named local partner is the difference between a resource and somebody to
call, so it is shown only where SUN itself says the work is already running. -->
<p v-if="deafBridge.organisation" class="gm-note" style="margin:6px 0 0">
SUN is already being taught in {{ selectedGroup.countries }} by
<b>{{ deafBridge.organisation }}</b>.
</p>
</div>
</div>
<!-- THE MAP'S OWN PROMPT, ANSWERED. Its toast has always said "someone still needs
@ -2059,6 +2275,7 @@ useShadowStyles(
{{ g.language }}<code class="gm-code">{{ g.iso639_3 || 'no code' }}</code>
· {{ fmt(g.population) }} people
</span>
<span v-if="wayInFor(g)" class="gm-pg-way">way in: {{ wayInFor(g) }}</span>
</button>
<div v-if="withoutResources.length > shown" class="gm-more">
Showing {{ shown }} of {{ withoutResources.length }}.

View file

@ -760,6 +760,26 @@ for (const p of slim) {
* reason GRN is skipped for them above.
*/
const bridgeCandidates = {}
/**
* ONE definition of "this language has a readable Scripture", used by both pickers below.
*
* ⚠ WRITTEN OUT TWICE FIRST, AND THE SECOND COPY WAS A FIELD SHORT — it checked s, se and d and
* forgot that Faith Comes By Hearing carries TEXT on the audio record (au.m says so). MEASURED:
* Bambara has the JESUS Film and a 66-book FCBH text Bible, and was filtered out of Mali's
* bridge candidates for lack of Scripture, handing the recommendation to French. Bambara is
* Mali's vehicular language. That is the field-list-goes-stale bug the surfaces gate exists for,
* reproduced inside a build script where no gate was watching.
*/
const hasReadableScripture = (c) =>
!!c && (!!c.s || !!c.se || !!(c.au && /text/.test(c.au.m || '')) ||
(c.d || []).some((d) => !/^Open Bible Stories/.test(d.s || '')))
/* What each country actually speaks, with each language's share OF THE COUNTRY. Absent input is
not fatal: every candidate then falls back to the pinned-population proxy, as before. */
let countryLangs = {}
try {
countryLangs = JSON.parse(readFileSync(resolve(REPO, 'data/tables/country-languages.json'), 'utf8')).byCountry || {}
} catch { /* no CLDR table in this tree */ }
{
const pool = {}
const cpop = {}
@ -773,7 +793,7 @@ const bridgeCandidates = {}
const slot = (e[code] ||= { n: 0, pop: 0, l: c.l || '', s: false, f: false })
slot.n += 1
slot.pop += pop
slot.s = slot.s || !!c.s || !!c.se || (c.d || []).some((d) => !/^Open Bible Stories/.test(d.s || ''))
slot.s = slot.s || hasReadableScripture(c)
slot.f = slot.f || !!c.f
}
for (const p of pins) {
@ -782,6 +802,42 @@ const bridgeCandidates = {}
if (codes[code] && codes[code].sg === 1) continue
const key = `${code}|${iso3}`
if (bridges[key] || bridgeCandidates[key]) continue
/* ═══ ASK WHAT THE COUNTRY SPEAKS, NOT WHAT THE UNREACHED SPEAK ═══════════════════════
* The pin-derived pick below answers "the biggest language among Doxa's UNREACHED groups
* here", which is not the language a community shares with its neighbours. For Syenara
* Senufo in Mali it proposed Minianka — one people group — while Mali's vehicular language
* is Bambara at 46% of the country. CLDR is asked first, and only it can supply a share OF
* THE COUNTRY, which is the denominator a reader assumes when they see a percentage.
* Still filtered by what this index can actually offer: a bridge with no Scripture and no
* film behind it is a name, not a way in. */
const spoken = (countryLangs[iso3] || [])
.filter((x) => x.c !== code && codes[x.c] && !!codes[x.c].f && hasReadableScripture(codes[x.c]))
/* Official status breaks a tie between equal shares — Mali's French and Bambara are both
46% — because the official one is the likelier second language to already be taught. */
const ranked = spoken.sort((a, b) => (b.pct - a.pct) || (b.official - a.official))
const cldrBest = ranked[0]
if (cldrBest) {
/* ⚠ WHEN TWO LANGUAGES ARE TIED, PICKING ONE IS AN EDITORIAL ACT, NOT A MEASUREMENT.
* Mali is exactly this: French 46% and official, Bambara 46% and not. French is the
* administrative language, Bambara is what people actually share in a market or a
* village, and which one bridges better depends on whether the reader is handing over
* a booklet or starting a conversation. The panel already says the choice is a local
* question; so where a runner-up is within five points, BOTH are shipped and the reader
* decides. A tie-break here would have quietly answered a question nobody asked it. */
const alt = ranked[1] && (cldrBest.pct - ranked[1].pct) <= 5 ? ranked[1] : null
bridgeCandidates[key] = {
l: codes[cldrBest.c]?.l || cldrBest.l,
c: cldrBest.c,
pct: cldrBest.pct,
official: cldrBest.official,
src: 'cldr',
...(alt ? { alt: { l: codes[alt.c]?.l || alt.l, c: alt.c, pct: alt.pct, official: alt.official } } : {}),
}
continue
}
/* FALLBACK, and it says so on the row. CLDR lists no language here that this index can
offer, so the old proxy is better than nothing — but its percentage is a share of the
PINNED population, a different denominator, and the surfaces read `src` to say which. */
const best = Object.entries(pool[iso3] || {})
.filter(([cc, s]) => cc !== code && s.s && s.f)
.sort((a, b) => b[1].pop - a[1].pop)[0]
@ -790,12 +846,47 @@ const bridgeCandidates = {}
bridgeCandidates[key] = {
l: best[1].l, c: best[0], n: best[1].n,
pct: tot ? Math.round((1000 * best[1].pop) / tot) / 10 : 0,
src: 'pins',
}
}
}
/**
* ═══ THE DEAF BRIDGE TRAVELS WITH THE INDEX ═══════════════════════════════════════════════
*
* SUN reached the access table and the CSV first, and the MAP — the surface a mobiliser
* actually opens — still showed Deaf groups a panel that explained their situation and offered
* no way out of it. That is the largest population on this map with nothing: 93 groups, 17.5
* million people.
*
* Carried here rather than fetched by the leaf, for the same reason the bridges are: one
* source, so the sheet and the table cannot disagree about what exists. It is a single small
* object, not a per-language join — SUN is ONE signed language for all of them, which is the
* whole point of it.
*
* Absent input is not fatal: the map simply shows what it showed before.
*/
let sun = null
try {
const raw = JSON.parse(readFileSync(resolve(REPO, 'data/tables/sun-deaf-index.json'), 'utf8'))
const pick = (k) => (raw.resources || []).find((r) => r.key === k)
const scripture = pick('new_testament') || (raw.resources || [])[0]
sun = {
publisher: raw.meta?.publisher || '',
scripture: scripture ? { u: scripture.url, t: scripture.label } : null,
stories: (() => { const r = pick('bible_stories'); return r ? { u: r.url, t: r.label } : null })(),
about: (() => { const r = pick('home'); return r ? r.url : '' })(),
/* Country -> the local Deaf church or organisation already teaching it. A named partner is
the difference between a resource and somebody to call. */
byCountry: Object.fromEntries((raw.projects || []).map((p) => [
String(p.country || '').toLowerCase().replace(/[^a-z]/g, ''), p.organisation,
])),
}
} catch { /* no SUN index in this tree: the Deaf panel stays as it was */ }
const out = {
sun,
bridges,
bridgeCandidates,
_note: 'Generated by scripts/build-people-group-pins.mjs. pins=[name,lat,lng,iso3,code,pop,tier]; URLs live in codes[] by ISO 639-3. No URL is invented here.',

View file

@ -0,0 +1,123 @@
/**
* fetch-country-languages.mjs — which languages are actually SPOKEN in a country, and by what
* share of it.
*
* WHY THIS EXISTS. The bridge CANDIDATE — the "possibly a way in" offered to a group with
* nothing — was chosen from Doxa's own pins: the language with the largest pinned population in
* that country that has Scripture and the film. Doxa's roster is 2,107 UNREACHED groups, so that
* is the biggest language among the unreached, which is not the language a community actually
* shares with its neighbours. Measured: for Syenara Senufo in Mali it proposed MINIANKA, one
* people group. Mali's actual vehicular language is Bambara, spoken by most of the country.
*
* Worse, the number shipped beside it read "8.8% of this country's mapped population" — a share
* of Doxa's unreached PINS, which a reader will take for a share of the country. A percentage
* measured against a denominator nobody can see is not a caveat, it is a wrong number.
*
* WHAT THIS USES. CLDR's territoryInfo, which carries, per territory, each language's share of
* the population and whether it is official. That is the published answer to "what is spoken
* here", maintained by Unicode, and it is the right denominator: the country.
*
* ⚠ PINNED, NOT `main`. cldr-json's main branch moves; a bridge recommendation changing because
* an upstream branch advanced is not something anyone would think to look for.
*
* THE JOIN. CLDR speaks BCP-47, this build speaks ISO 639-3, and the two meet through the Part1
* column of SIL's own iso-639-3 table — 184 languages carry a 639-1 code. A CLDR tag with no
* 639-3 equivalent is dropped rather than guessed; a guess here would put a language in a
* country on no authority at all.
*/
import { writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const OUT = resolve(REPO, 'data/tables/country-languages.json')
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36'
const CLDR = 'https://cdn.jsdelivr.net/npm/cldr-core@46.0.0/supplemental/territoryInfo.json'
const ISO = 'https://iso639-3.sil.org/sites/iso639-3/files/downloads/iso-639-3.tab'
/* Keyed by ISO3 on the way out, so the pin builder joins on the code it already has. The
mapping comes from the SAME pinned CLDR release as the language data, rather than a second
host with its own release cycle. */
const CODES = 'https://cdn.jsdelivr.net/npm/cldr-core@46.0.0/supplemental/codeMappings.json'
const get = async (url) => {
const r = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(90000) })
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
return r.text()
}
/* Part1 (639-1) -> Id (639-3), plus the reference name, from SIL's own table. */
const tab = await get(ISO)
const toIso3 = {}
const refName = {}
for (const line of tab.split('\n').slice(1)) {
const f = line.split('\t')
if (f.length < 7) continue
const [id, , , part1, , , name] = f
if (!id) continue
refName[id] = (name || '').trim()
if (part1 && part1.trim()) toIso3[part1.trim()] = id.trim()
}
const codeMap = JSON.parse(await get(CODES)).supplemental.codeMappings
const alpha3 = {}
for (const [terr, v] of Object.entries(codeMap)) {
if (/^[A-Z]{2}$/.test(terr) && v && v._alpha3) alpha3[terr] = v._alpha3
}
const cldr = JSON.parse(await get(CLDR)).supplemental.territoryInfo
const byCountry = {}
let dropped = 0
let noAlpha3 = 0
for (const [terr, info] of Object.entries(cldr)) {
/* Two-letter territories only: CLDR also carries numeric macro-regions like 001 (World),
which are not countries and would put "spoken in the World" into a bridge recommendation. */
if (!/^[A-Z]{2}$/.test(terr)) continue
const langs = []
for (const [tag, lp] of Object.entries(info.languagePopulation || {})) {
/* A tag like en_GB or zh_Hans carries a script or region; the base language is what a
speaker shares with a neighbour, so it is taken and the variant dropped. */
const base = tag.split(/[-_]/)[0]
const iso3 = toIso3[base] || (base.length === 3 ? base : null)
if (!iso3) { dropped++; continue }
const pct = Number(lp._populationPercent)
if (!Number.isFinite(pct)) continue
langs.push({
c: iso3,
l: refName[iso3] || base,
pct: Math.round(pct * 10) / 10,
official: !!lp._officialStatus,
})
}
if (!langs.length) continue
/* Largest share first: that is the order a reader would want to judge a bridge in. */
langs.sort((a, b) => b.pct - a.pct)
const key = alpha3[terr]
if (!key) { noAlpha3++; continue }
byCountry[key] = langs
}
const countries = Object.keys(byCountry).length
if (countries < 150) {
console.error(`COUNTRY-LANGUAGES: REFUSING TO WRITE — only ${countries} countries parsed.`)
console.error(' A thin file would silently narrow every bridge recommendation that reads it.')
process.exitCode = 1
} else {
writeFileSync(OUT, `${JSON.stringify({
meta: {
title: 'Languages spoken per country, with each share of the country',
sources: [CLDR, ISO],
generated: new Date().toISOString().slice(0, 10),
countries,
note: 'CLDR territoryInfo joined to ISO 639-3 through SIL\'s Part1 column. Percentages are ' +
'of the COUNTRY\'s population, which is the denominator a reader assumes. A CLDR tag ' +
'with no ISO 639-3 equivalent is dropped, never guessed.',
cldr_tags_dropped_no_iso639_3: dropped,
territories_dropped_no_alpha3: noAlpha3,
},
byCountry,
}, null, 1)}\n`)
console.log(`country-languages: ${countries} countries (keyed by ISO3), ${dropped} CLDR tag(s) dropped for having no ISO 639-3, ${noAlpha3} territory(ies) with no alpha-3`)
console.log(`wrote ${OUT}`)
}

View file

@ -63,6 +63,29 @@ export function ensureGeocoder(provider = 'mapbox') {
// `new mapboxgl.Popup` throws on the first pin click. Now the library's engineFor (one copy).
export { engineFor as glFor } from '@map/composables/useMapProvider.js'
// Resolves once every GeoJSON source on the map has LOADED and a frame has RENDERED after that,
// or after `timeoutMs` (a safety net, never a hang). The views flipped `dataReady` (which hides the
// LoadingGlobe) right after addSource(); the engine then still tiles tens of thousands of points in
// its worker, so the globe disappeared and the map sat EMPTY. Measured on the DX-life gallery: 0
// dots for 20 s, then 19,376 features from 30 s. Not 'idle': an animating map may never go idle.
export function whenGeojsonDrawn(map, timeoutMs = 45000) {
return new Promise((resolve) => {
if (!map) return resolve(false)
let done = false
const finish = (drawn) => { if (done) return; done = true; clearTimeout(timer); map.off('sourcedata', check); resolve(drawn) }
const timer = setTimeout(() => finish(false), timeoutMs)
const check = () => {
try {
const srcs = (map.getStyle() || {}).sources || {}
const ids = Object.keys(srcs).filter((k) => srcs[k].type === 'geojson')
if (ids.every((id) => map.isSourceLoaded(id))) { map.once('render', () => finish(true)); map.triggerRepaint() }
} catch (_) { /* style mid-swap: wait for the next sourcedata */ }
}
map.on('sourcedata', check)
check()
})
}
// Verification handle (the gospel-resources __grState contract): what the ACTIVE view's map
// actually did, so a check can assert "loaded" rather than inferring it from missing requests.
// window.__pplrMap() -> { engine, loaded, styleLayers, pplrLayers }

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
*
@ -118,6 +118,8 @@ onMounted(async () => {
.setLngLat(f.geometry.coordinates).setHTML(popupForPG(f.properties)).addTo(map.value)
})
status.value = `Ready · ${countryStats.size} countries colored by avg translation locales / 17 (all ${[...countryStats.values()].reduce((n, c) => n + c.pgCount, 0).toLocaleString('en-US')} PG-in-country rows)`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
*
@ -490,6 +490,8 @@ onMounted(async () => {
const coverage = Math.round(100 * matched / fc.features.length)
status.value = `Ready · ${fc.features.length.toLocaleString()} pins · ${coverage}% family coverage · top-25 = ${Math.round(100 * inTop25 / fc.features.length)}%`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`

View file

@ -5,7 +5,7 @@
* Individual pins at all zoom levels; click pin for full Rosetta popup; hover country for highlight.
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap } from '../utils/pplrData.js'
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -50,6 +50,8 @@ onMounted(async () => {
.addTo(map.value)
})
status.value = `Ready · ${fc.features.length.toLocaleString('en-US')} PG-in-country pins · click any pin`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-religion.vue — PG pins colored by primary religion.
*
@ -267,6 +267,8 @@ onMounted(async () => {
const dominant = RELIGION_GROUPS.reduce((a, b) => (counts[a.key] || 0) > (counts[b.key] || 0) ? a : b)
// Per user 2026-05-02: drop the "Ready" prefix — it was unclear and chewed up width.
status.value = `${pgData.value.features.length.toLocaleString()} pins · ${dominant.label} most common (${(counts[dominant.key]||0).toLocaleString()})`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`

View file

@ -13,7 +13,7 @@
* Click any pin → popup with full Rosetta table.
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap } from '../utils/pplrData.js'
import { glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -141,6 +141,8 @@ onMounted(async () => {
map.value.on('mouseleave', 'rosetta-pin', () => map.value.getCanvas().style.cursor = '')
status.value = `Ready · ${pgFc.features.length} pins · ${rosKeys.size} Rosetta samples (gold ★)`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-translations.vue — PG name in all 17 locales.
*
@ -92,6 +92,8 @@ onMounted(async () => {
})
status.value = `${fc.features.length.toLocaleString()} pins · click any pin to see all 17 locale names`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`

View file

@ -506,17 +506,38 @@ function chooserZoom(m) {
* Both setProjection calls land in ONE synchronous tick, so no frame renders in mercator; the
* probe asserts the projection is still 'globe' afterwards rather than trusting that.
*/
// The two engines disagree about BOTH halves of the projection API, and this read used to assume
// Mapbox's:
// Mapbox getProjection() -> { name: 'globe' } setProjection('mercator')
// MapLibre getProjection() -> { type: 'globe' } | undefined
// setProjection({ type: 'mercator' })
// MEASURED in maplibre-gl@5's own source: Map.getProjection() returns `this.stylesheet?.projection`,
// and the OpenFreeMap style we hand MapLibre declares no `projection` key at all — its top level is
// version, sources, sprite, glyphs, layers. So getProjection() is UNDEFINED there, and reading
// `.name` off it threw a TypeError. That read sat OUTSIDE the try, so the throw escaped
// fitTheirWay, escaped applySubject() — of which it is the last statement — and the camera was
// never framed. Opening a region dashboard under MapLibre therefore left the map wherever it was
// instead of flying to the region, which is exactly what the Driver reported.
const projectionOf = (m) => {
try { const p = m.getProjection && m.getProjection(); return (p && (p.name || p.type)) || null } catch (_) { return null }
}
// Mapbox takes the bare string; MapLibre v5 takes { type }. Writing the wrong shape is not a
// no-op — maplibre reads e?.type and would set undefined.
const setProjectionOn = (m, name) => {
try { m.setProjection(isMaplibreProvider ? { type: name } : name) } catch (_) {}
}
function fitTheirWay(m, bounds) {
let cam = null
const proj = (m.getProjection && m.getProjection().name) || null
const proj = projectionOf(m)
try {
if (proj && proj !== 'mercator') m.setProjection('mercator')
if (proj && proj !== 'mercator') setProjectionOn(m, 'mercator')
cam = m.cameraForBounds(bounds, FIT_OPTS)
} catch (e) {
console.warn('[RegionCountryMap] mercator fit unavailable, falling back to fitBounds:', e && e.message)
} finally {
// Restore in `finally` so a throw inside cameraForBounds can never leave the map flat.
try { if (proj && proj !== 'mercator') m.setProjection(proj) } catch (_) {}
if (proj && proj !== 'mercator') setProjectionOn(m, proj)
}
if (cam && cam.center && typeof cam.zoom === 'number') m.easeTo({ center: cam.center, zoom: cam.zoom, duration: 600 })
else m.fitBounds(bounds, { ...FIT_OPTS, duration: 600 })
@ -655,6 +676,21 @@ useShadowStyles(
'touch-action:pan-y;pointer-events:auto;background:transparent}' +
'.map-scroll-edge--left{left:0}' +
'.map-scroll-edge--right{right:0}' +
// ★ THE SCROLL STRIPS WERE SWALLOWING THE MAP CONTROLS (Driver, on Mapbox): zoom in, zoom out
// and fullscreen could not be clicked at all. The strips are 40px wide, full height,
// pointer-events:auto and z-index:2 — and BOTH engines place their control groups in the very
// corners the strips cover. Equal z-index means the later element in the DOM wins, and the
// strips are rendered after the map container, so they sat on top of every control.
// The strips must keep their pointer events (touch-action:pan-y is the whole affordance — a
// vertical drag scrolls the page instead of panning the map), so the fix is to lift the controls
// above them rather than to make the strips inert.
// ALL FOUR CORNERS, BOTH ENGINES, on purpose: the left strip covers the two left corners exactly
// as the right one covers the right, a control can be placed in any of them, and naming both
// prefixes is what makes this survive a provider swap — which is the point of the provider prop.
'.mapboxgl-ctrl-top-left,.maplibregl-ctrl-top-left,' +
'.mapboxgl-ctrl-top-right,.maplibregl-ctrl-top-right,' +
'.mapboxgl-ctrl-bottom-left,.maplibregl-ctrl-bottom-left,' +
'.mapboxgl-ctrl-bottom-right,.maplibregl-ctrl-bottom-right{ z-index:3; }' +
// ★ THEIR GLOBAL BOX-SIZING RESET, which no scoped style could ever have shown me.
// app/assets/styles/base/_reset.scss:3-8 is `*, *::before, *::after { box-sizing: border-box }`
// (a piccalil.li reset) and it applies to every element on their page, the legend swatch

View file

@ -17,8 +17,9 @@
*
* 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.
* was kept out of gates:post until the generator stopped hard-linking the engine (2026-09-25: the leaf
* now loads only the engine its provider asks for). It is wired into gates:post in that same landing,
* and still names the exact URLs when it fails.
*
* ★ 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

File diff suppressed because one or more lines are too long

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>all-people-groups · all-people-groups</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-hVWmJUfcmDvVx9RfLXMsJYA5Dd5TiBhLNzwAZtto5JQDW5siH5PJTd/5xWEGHCMA" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-Fk1cAFe2MbBsG2gfJQc2QNoG8CySIPOn0GWATpJ1uBlH51zspyobkOkzWAAXhn3H" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"profile":"all-people-groups","dataSource":"pray-tools","instanceId":"all-people-groups-all-people-groups","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-W33t3yGdG/uWHVWWqezPkJjFDX+9DzWfcWX8k8/9pLvFzj3WgAzorPCLXHp7nn/w" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-rGZtmnXJNSJwdxwKqNsG3ATtfDLSMPsPtF7QwR8kjUZIZOz/l0hDTNMWLbaE5Ayq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"connectivity-list","dataSource":"pray-tools","instanceId":"cities-internet-connectivity-list","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>connectivity-map · cities-internet</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-W33t3yGdG/uWHVWWqezPkJjFDX+9DzWfcWX8k8/9pLvFzj3WgAzorPCLXHp7nn/w" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-rGZtmnXJNSJwdxwKqNsG3ATtfDLSMPsPtF7QwR8kjUZIZOz/l0hDTNMWLbaE5Ayq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"pinHint":false,"profile":"connectivity-map","dataSource":"pray-tools","instanceId":"cities-internet-connectivity-map","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-W33t3yGdG/uWHVWWqezPkJjFDX+9DzWfcWX8k8/9pLvFzj3WgAzorPCLXHp7nn/w" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-rGZtmnXJNSJwdxwKqNsG3ATtfDLSMPsPtF7QwR8kjUZIZOz/l0hDTNMWLbaE5Ayq" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"data-tables","dataSource":"pray-tools","instanceId":"cities-internet-data-tables","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>countries-map · country-posters</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-KtF4c17Xk0HixYcvWc2mk2Sga0ew+QVYsEo9GMOMPgzX4PAdKcuzbroMgjKu9TPG" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Qk/vQqU8IjKhxJLq5rZ4ccJSQTFGWGyERKNUlzviZe2x1ifcRBbmu+gPNRbD3nuK" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"profile":"countries-map","dataSource":"pray-tools","instanceId":"country-posters-countries-map","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-KtF4c17Xk0HixYcvWc2mk2Sga0ew+QVYsEo9GMOMPgzX4PAdKcuzbroMgjKu9TPG" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Qk/vQqU8IjKhxJLq5rZ4ccJSQTFGWGyERKNUlzviZe2x1ifcRBbmu+gPNRbD3nuK" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"country-poster-wall","dataSource":"pray-tools","instanceId":"country-posters-country-poster-wall","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>country-posters-mfe · country-posters</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
@ -40,7 +41,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-KtF4c17Xk0HixYcvWc2mk2Sga0ew+QVYsEo9GMOMPgzX4PAdKcuzbroMgjKu9TPG" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Qk/vQqU8IjKhxJLq5rZ4ccJSQTFGWGyERKNUlzviZe2x1ifcRBbmu+gPNRbD3nuK" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -59,7 +60,11 @@
var BASE_CONFIG = {"pinHint":false,"profile":"country-posters-mfe","dataSource":"pray-tools","instanceId":"country-posters-country-posters-mfe","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>demo-map · demo-map</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../demo-map.js" integrity="sha384-B9GTF53Kly+NAnwh7X9H2eiXIp2Gr/tatFhWfyi8OQdqB8iQerjl2635eRWBTmpc" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-pQboGJuKCps0vTF6EI1gPDLlyZuLoDZTpH3HbbV7ROXstB6UmAVdvrzDk4K0uh4G" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"profile":"demo-map","dataSource":"pray-tools","instanceId":"demo-map-demo-map","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -27,7 +27,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../../demo-map.js" integrity="sha384-B9GTF53Kly+NAnwh7X9H2eiXIp2Gr/tatFhWfyi8OQdqB8iQerjl2635eRWBTmpc" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-pQboGJuKCps0vTF6EI1gPDLlyZuLoDZTpH3HbbV7ROXstB6UmAVdvrzDk4K0uh4G" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -46,7 +46,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"demo-map","provider":"maplibre","instanceId":"demo-maplibre"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-cxHilsRqjhGMEhbGr08ZhIa8/nLXzSLB6XqOhv5YcYfoqQhxiOq1pc5tJ7Ia58pB" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-OgWFHE2O/j6eIlfJW2ohSIlPJrQvY3gBw46XjMOXFivfDfF0cvV6qB3ycJwOmPoQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-maps-spa","provider":"maplibre","instanceId":"doxa-maps-spa-app","title":"Research Gallery"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-cxHilsRqjhGMEhbGr08ZhIa8/nLXzSLB6XqOhv5YcYfoqQhxiOq1pc5tJ7Ia58pB" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-OgWFHE2O/j6eIlfJW2ohSIlPJrQvY3gBw46XjMOXFivfDfF0cvV6qB3ycJwOmPoQ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"doxa-maps-spa","dataSource":"pray-tools","instanceId":"doxa-maps-spa-doxa-maps-spa","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Adoption Map · doxa-simple-map</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-WsIV9Fm9rPNo2A9e3lavYZvhImkiGl1gUVZdmxiLzt2S3ZTLh3haZDG++0pYoAx+" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-i2FnC9KrSn/cnEHqCtaXIWMZKEKMWOp54vyhAMGD3zE4sZUIg1g/j3Smz5ocfUkh" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-simple-map","instanceId":"embed-adoption","dataSource":"pray-tools","tabs":[{"id":"adoption","colorStrategy":"adoption","legend":"adoption","popup":"adoption"}]};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Engagement Map · doxa-simple-map</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-WsIV9Fm9rPNo2A9e3lavYZvhImkiGl1gUVZdmxiLzt2S3ZTLh3haZDG++0pYoAx+" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-i2FnC9KrSn/cnEHqCtaXIWMZKEKMWOp54vyhAMGD3zE4sZUIg1g/j3Smz5ocfUkh" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-simple-map","instanceId":"embed-engagement","dataSource":"pray-tools","tabs":[{"id":"engagement","colorStrategy":"engagement","legend":"engagement","popup":"engagement"}]};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-simple-map · doxa-simple-map</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../doxa-simple-map.js" integrity="sha384-WsIV9Fm9rPNo2A9e3lavYZvhImkiGl1gUVZdmxiLzt2S3ZTLh3haZDG++0pYoAx+" crossorigin="anonymous"></script>
<script src="../doxa-simple-map.js" integrity="sha384-i2FnC9KrSn/cnEHqCtaXIWMZKEKMWOp54vyhAMGD3zE4sZUIg1g/j3Smz5ocfUkh" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"profile":"doxa-simple-map","dataSource":"pray-tools","instanceId":"doxa-simple-map-doxa-simple-map","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prayer Map · doxa-simple-map</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-WsIV9Fm9rPNo2A9e3lavYZvhImkiGl1gUVZdmxiLzt2S3ZTLh3haZDG++0pYoAx+" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-i2FnC9KrSn/cnEHqCtaXIWMZKEKMWOp54vyhAMGD3zE4sZUIg1g/j3Smz5ocfUkh" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-simple-map","instanceId":"embed-prayer","dataSource":"pray-tools","tabs":[{"id":"prayer","colorStrategy":"prayer","legend":"prayer","popup":"prayer"}]};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -45,7 +45,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-vision-card","provider":"maplibre","instanceId":"vision-card-default"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -45,7 +45,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"doxa-vision-card","provider":"maplibre","instanceId":"vision-card-frontier","accent":"#22d3ee","title":"The last frontier of the gospel.","tagline":"Frontier people groups: fewer than one person in a thousand is an evangelical believer."};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"doxa-vision-card","dataSource":"pray-tools","instanceId":"doxa-vision-card-doxa-vision-card","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"golden-path-check","dataSource":"pray-tools","instanceId":"golden-path-check-golden-path-check","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-e8fVkQLYt1CmQJcdTAxPz8aMlM7w8TfzEpV5MZ3ZXVSnJ13TH+mB+GSHxibjFx/y" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-t3yOm+SFRl+f76jNfvUMMCoQzemnJVtag23epn54Vla/wPHj4xfyYsBC1uYcvmHT" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"charts","dataSource":"pray-tools","instanceId":"gospel-resources-charts","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-e8fVkQLYt1CmQJcdTAxPz8aMlM7w8TfzEpV5MZ3ZXVSnJ13TH+mB+GSHxibjFx/y" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-t3yOm+SFRl+f76jNfvUMMCoQzemnJVtag23epn54Vla/wPHj4xfyYsBC1uYcvmHT" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"data-tables","dataSource":"pray-tools","instanceId":"gospel-resources-data-tables","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gospel-dashboard · gospel-resources</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
@ -40,7 +41,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-e8fVkQLYt1CmQJcdTAxPz8aMlM7w8TfzEpV5MZ3ZXVSnJ13TH+mB+GSHxibjFx/y" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-t3yOm+SFRl+f76jNfvUMMCoQzemnJVtag23epn54Vla/wPHj4xfyYsBC1uYcvmHT" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -59,7 +60,11 @@
var BASE_CONFIG = {"profile":"gospel-dashboard","dataSource":"pray-tools","instanceId":"gospel-resources-gospel-dashboard","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-e8fVkQLYt1CmQJcdTAxPz8aMlM7w8TfzEpV5MZ3ZXVSnJ13TH+mB+GSHxibjFx/y" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-t3yOm+SFRl+f76jNfvUMMCoQzemnJVtag23epn54Vla/wPHj4xfyYsBC1uYcvmHT" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"resources-list","dataSource":"pray-tools","instanceId":"gospel-resources-resources-list","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>resources-map · gospel-resources</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <gospel-resources-map> custom element. -->
<script src="../gospel-resources.js" integrity="sha384-e8fVkQLYt1CmQJcdTAxPz8aMlM7w8TfzEpV5MZ3ZXVSnJ13TH+mB+GSHxibjFx/y" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-t3yOm+SFRl+f76jNfvUMMCoQzemnJVtag23epn54Vla/wPHj4xfyYsBC1uYcvmHT" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"pinHint":false,"profile":"resources-map","dataSource":"pray-tools","instanceId":"gospel-resources-resources-map","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-E2LWHzMGZQ8jt+E+jYmE4FQsphkFL7xwhBVKxoNp2V6rPg4PRwUWpCmbPWLQqCa4" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-9T0q3d1bM52U2LvFYndlObd2Z+lH6aR07MzBErTR4aKfMwLD8ZMnuHN7WIc7pU3q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"poster-wall","dataSource":"pray-tools","instanceId":"my-upg-100-list-poster-wall","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upg-100-list · my-upg-100-list</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
@ -29,7 +30,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-E2LWHzMGZQ8jt+E+jYmE4FQsphkFL7xwhBVKxoNp2V6rPg4PRwUWpCmbPWLQqCa4" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-9T0q3d1bM52U2LvFYndlObd2Z+lH6aR07MzBErTR4aKfMwLD8ZMnuHN7WIc7pU3q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -48,7 +49,11 @@
var BASE_CONFIG = {"profile":"upg-100-list","dataSource":"pray-tools","instanceId":"my-upg-100-list-upg-100-list","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -5,13 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>upg-100-mfe · my-upg-100-list</title>
<link rel="icon" href="data:,">
<!-- Mapbox peer loaded ONCE here; the map custom element mounts in this page and
uses window.mapboxgl directly (same realm → no cross-realm container errors). -->
<link rel="preconnect" href="https://api.mapbox.com" crossorigin />
<link href="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css" rel="stylesheet" />
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->
<style>
/* SCROLL-PAGE leaf (cap-4 nested-elements-mfe composite: a StackLayout scroll-stack). The DOCUMENT
owns the scroll — the element takes its content's NATURAL height and grows the page, so a reader
@ -40,7 +41,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-E2LWHzMGZQ8jt+E+jYmE4FQsphkFL7xwhBVKxoNp2V6rPg4PRwUWpCmbPWLQqCa4" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-9T0q3d1bM52U2LvFYndlObd2Z+lH6aR07MzBErTR4aKfMwLD8ZMnuHN7WIc7pU3q" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -59,7 +60,11 @@
var BASE_CONFIG = {"profile":"upg-100-mfe","dataSource":"pray-tools","instanceId":"my-upg-100-list-upg-100-mfe","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = false;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = false || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

File diff suppressed because one or more lines are too long

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-P36N4rI/QkK68QpPw1OHfxGIYeYfpr+1tX8Bxve+B98JfWFajRYKQeaRi5TgPVCU" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-NxK7wM4VhKnvgdeGmcwCcPGvNkQq4dZRXDkh8eUWhtcEuz4s7EFeQQjxA1PciJVt" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"people-groups-grid","provider":"maplibre","instanceId":"people-groups-grid-driven","pray-base-url":"https://pray.doxa.life","show-search":"false","show-filters":"false"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../people-groups-grid.js" integrity="sha384-P36N4rI/QkK68QpPw1OHfxGIYeYfpr+1tX8Bxve+B98JfWFajRYKQeaRi5TgPVCU" crossorigin="anonymous"></script>
<script src="../people-groups-grid.js" integrity="sha384-NxK7wM4VhKnvgdeGmcwCcPGvNkQq4dZRXDkh8eUWhtcEuz4s7EFeQQjxA1PciJVt" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"people-groups-grid","dataSource":"pray-tools","instanceId":"people-groups-grid-people-groups-grid","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -26,7 +26,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-P36N4rI/QkK68QpPw1OHfxGIYeYfpr+1tX8Bxve+B98JfWFajRYKQeaRi5TgPVCU" crossorigin="anonymous"></script>
<script src="../../people-groups-grid.js" integrity="sha384-NxK7wM4VhKnvgdeGmcwCcPGvNkQq4dZRXDkh8eUWhtcEuz4s7EFeQQjxA1PciJVt" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"people-groups-grid","provider":"maplibre","instanceId":"people-groups-grid","pray-base-url":"https://pray.doxa.life"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -45,7 +45,11 @@
var BASE_CONFIG = {"profile":"plugin-studio","dataSource":"pray-tools","instanceId":"plugin-studio-plugin-studio","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

View file

@ -45,7 +45,11 @@
var BASE_CONFIG = {"assetsBase":"../../../assets","profile":"plugin-studio","provider":"maplibre","instanceId":"plugin-studio","title":"Plugin Studio"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = true || RUNTIME_PROVIDER === 'maplibre';
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.

Some files were not shown because too many files have changed in this diff Show more