Integrate main into bundler-2/work

# Conflicts:
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid.js
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/default/index.html
#	public/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html
#	public/doxa-maps/doxa-maps-spa/manifest.json
#	public/doxa-maps/doxa-research-map/doxa-research-map.js
#	public/doxa-maps/doxa-research-map/research-map/index.html
#	public/doxa-maps/doxa-research-map/research-map/maplibre/index.html
#	public/doxa-maps/index.html
#	public/doxa-maps/manifest.json
This commit is contained in:
doxa-maps-88655c70 2026-09-25 10:20:57 +00:00
commit 78efae4eae
117 changed files with 1834 additions and 1018 deletions

View file

@ -48,16 +48,19 @@ the Research Gallery.
<table>
<tr>
<td width="20%" valign="top"><img src="documentation/readme-images/prod-regions-phone.png" alt="Regions on a phone"><br><b>Regions</b></td>
<td width="20%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
<td width="20%" valign="top"><img src="documentation/readme-images/prod-prayer-phone.png" alt="Prayer Progress on a phone"><br><b>Prayer Progress</b></td>
<td width="20%" valign="top"><img src="documentation/readme-images/prod-cities-internet-phone.png" alt="Cities with Internet on a phone"><br><b>Cities with Internet</b></td>
<td width="20%" valign="top"><img src="documentation/readme-images/prod-gospel-resources-phone.png" alt="Gospel Resources on a phone"><br><b>Gospel Resources</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-simple-research-phone.png" alt="Simple Research on a phone"><br><b>Simple Research</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-regions-phone.png" alt="Regions on a phone"><br><b>Regions</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/deploy2-mobile-upg100.png" alt="UPG-100 List on a phone"><br><b>UPG-100 List</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-prayer-phone.png" alt="Prayer Progress on a phone"><br><b>Prayer Progress</b></td>
</tr>
<tr>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-cities-internet-phone.png" alt="Cities with Internet on a phone"><br><b>Cities with Internet</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-gospel-resources-phone.png" alt="Gospel Resources on a phone"><br><b>Gospel Resources</b></td>
<td width="25%" valign="top"><img src="documentation/readme-images/prod-research-gallery-phone.png" alt="Research Gallery on a phone"><br><b>Research Gallery</b></td>
<td width="25%" valign="top"><b>Big Picture</b><br><sub>a desktop dashboard by design — on a phone, Simple Research is the dashboard, and the Research Gallery opens on it.</sub></td>
</tr>
</table>
<sub>Big Picture is a desktop dashboard by design — on phones, Simple Research is the dashboard. Simple Research's phone layout is still being finished.</sub>
### Every capability ships every way
| Capability | Example | iframe | Mobile app | JavaScript embed | WordPress / Disciple Tools | Desktop |

View file

@ -1431,12 +1431,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/alk",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -1728,12 +1728,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_strength": "strong",
"bridge_groups_speaking": 25,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -3708,12 +3708,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_strength": "strong",
"bridge_groups_speaking": 65,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -12288,12 +12288,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_strength": "strong",
"bridge_groups_speaking": 25,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -19581,12 +19581,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_strength": "strong",
"bridge_groups_speaking": 65,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -33771,12 +33771,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/ngt",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -38292,12 +38292,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_strength": "strong",
"bridge_groups_speaking": 25,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -38556,12 +38556,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/lbo",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -47697,12 +47697,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_strength": "strong",
"bridge_groups_speaking": 65,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -48555,12 +48555,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nev",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,

View file

@ -4991,7 +4991,7 @@
"religion": "Islam - Sunni",
"population": 5500,
"people_praying": 0,
"people_committed": 0,
"people_committed": 1,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -16751,7 +16751,7 @@
"religion": "Islam - Sunni",
"population": 57500,
"people_praying": 2,
"people_committed": 0,
"people_committed": 1,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -34055,7 +34055,7 @@
"religion": "Hinduism",
"population": 700,
"people_praying": 1,
"people_committed": 2,
"people_committed": 3,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -34319,7 +34319,7 @@
"religion": "Islam - Folk",
"population": 12000,
"people_praying": 1,
"people_committed": 4,
"people_committed": 3,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -43463,7 +43463,7 @@
"religion": "Islam - Sunni",
"population": 23000,
"people_praying": 0,
"people_committed": 1,
"people_committed": 2,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",

View file

@ -1,7 +1,7 @@
{
"meta": {
"title": "Gospel resource link verification",
"generated": "2026-09-25T03:43:08.467Z",
"generated": "2026-09-25T09:56:59.860Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": {
@ -96,12 +96,12 @@
]
},
"summary": {
"records": 8516,
"checkedThisPass": 3,
"records": 8524,
"checkedThisPass": 8,
"carriedForward": 74,
"pruned": 0,
"broken": 74,
"verified": 8437,
"verified": 8445,
"unknown": 5
},
"byHost": {
@ -142,7 +142,7 @@
"verified": 921
},
"fivefish.org": {
"verified": 1022
"verified": 1030
}
}
},
@ -42726,6 +42726,46 @@
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/the-savior.html/arabic-najdi.html)",
"at": "2026-09-25T03:43:07.944Z"
},
"https://fivefish.org/lma": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/12954?language=Limba%2C%20East (the site answers 200 for any path)",
"at": "2026-09-25T09:55:49.572Z"
},
"https://fivefish.org/nev": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/23092?language=Nyaheun (the site answers 200 for any path)",
"at": "2026-09-25T09:55:59.632Z"
},
"https://fivefish.org/ngt": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/23045?language=Kriang (the site answers 200 for any path)",
"at": "2026-09-25T09:56:09.574Z"
},
"https://fivefish.org/alk": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/23050?language=Alak (the site answers 200 for any path)",
"at": "2026-09-25T09:56:19.687Z"
},
"https://fivefish.org/nos": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/4976?language=Nisu%2C%20Eastern (the site answers 200 for any path)",
"at": "2026-09-25T09:56:29.456Z"
},
"https://fivefish.org/lbo": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/12797?language=Laven (the site answers 200 for any path)",
"at": "2026-09-25T09:56:39.531Z"
},
"https://fivefish.org/jle": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/14912?language=Ngile (the site answers 200 for any path)",
"at": "2026-09-25T09:56:49.486Z"
},
"https://fivefish.org/psi": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)",
"at": "2026-09-25T09:56:59.503Z"
}
}
}

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.7 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: 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.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 KiB

After

Width:  |  Height:  |  Size: 758 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

View file

@ -1,35 +1,35 @@
{
"cap1-single-element": {
"bundle": "all-people-groups",
"sha256": "33b9b1e760d930f5a92c0b2c194c2ab0542ffe6e0d84ac6a0896587e551b18fc"
"sha256": "0ede35b98a92ea2a56fa5b6302eb3dd827d9de4c636f82f663f9c0f4d8b24890"
},
"cap2-adoption": {
"bundle": "doxa-simple-map",
"sha256": "c46bc416c687dc4e41d5bae5a387995aec65d4928cde3aa55e0188c295c50546"
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
},
"cap2-engagement": {
"bundle": "doxa-simple-map",
"sha256": "c46bc416c687dc4e41d5bae5a387995aec65d4928cde3aa55e0188c295c50546"
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
},
"cap2-prayer": {
"bundle": "doxa-simple-map",
"sha256": "c46bc416c687dc4e41d5bae5a387995aec65d4928cde3aa55e0188c295c50546"
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
},
"cap3-monolithic-research": {
"bundle": "doxa-research-map",
"sha256": "9cbe76cb74f992dbbc1dc61c50270f98f787cb983d402b5ee6cc6a26bc6878a4"
"sha256": "3391075b32dd710b7f810e3097074a4d453c7720a6963e101ddec99ad4186d29"
},
"cap4-upg100-list": {
"bundle": "my-upg-100-list",
"sha256": "95ee299d03809f7cc9bb9c84b7f68b6706ffaa482873900289028fede38aa9c6"
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
},
"cap4-upg100-mfe": {
"bundle": "my-upg-100-list",
"sha256": "95ee299d03809f7cc9bb9c84b7f68b6706ffaa482873900289028fede38aa9c6"
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
},
"cap4-upg100-poster-wall": {
"bundle": "my-upg-100-list",
"sha256": "95ee299d03809f7cc9bb9c84b7f68b6706ffaa482873900289028fede38aa9c6"
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
},
"cap5-doxa-maps-spa": {
"bundle": "doxa-maps-spa",
@ -41,11 +41,11 @@
},
"deploy2-mobile-upg100": {
"bundle": "my-upg-100-list",
"sha256": "95ee299d03809f7cc9bb9c84b7f68b6706ffaa482873900289028fede38aa9c6"
"sha256": "fab621d68bdad964da2be67135d0385d40417c6180f008f36cbc0f50076f5e4a"
},
"prod-big-picture": {
"bundle": "dashboard-plus-grid",
"sha256": "3c1fc9c4af52752e545365676d630e9dfee3a5cd67018ca5181d499304742f61"
"sha256": "d3eb3468e6f42c3346885a665102dd10965a98a5e045f50b0d15c14f52a05e56"
},
"prod-cities-internet": {
"bundle": "cities-internet",
@ -57,26 +57,34 @@
},
"prod-gospel-resources": {
"bundle": "gospel-resources",
"sha256": "abf213dca851c7ef0d3dd94cb9456f4dd01b1b8e9dea4acf65452f56fd0c5879"
"sha256": "943fa394060c009d88a3a8e17651980e9c40077be315fb69c98fed92aa17dce0"
},
"prod-gospel-resources-phone": {
"bundle": "gospel-resources",
"sha256": "abf213dca851c7ef0d3dd94cb9456f4dd01b1b8e9dea4acf65452f56fd0c5879"
"sha256": "943fa394060c009d88a3a8e17651980e9c40077be315fb69c98fed92aa17dce0"
},
"prod-prayer-phone": {
"bundle": "doxa-simple-map",
"sha256": "c46bc416c687dc4e41d5bae5a387995aec65d4928cde3aa55e0188c295c50546"
"sha256": "6aa091deb995128b21dbeaa49bd4988c60fe61092d1859b1bdb83dba49d7ce20"
},
"prod-regions": {
"bundle": "regions-dashboard",
"sha256": "95ea2a4720a0c2081f3f3c757b658f9a5b4358b1943d11aefb2b3566df6e88b6"
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
},
"prod-regions-phone": {
"bundle": "regions-dashboard",
"sha256": "95ea2a4720a0c2081f3f3c757b658f9a5b4358b1943d11aefb2b3566df6e88b6"
"sha256": "a9b91cd0428834cdbdc711f5be2921c08fc2f2b057081f26c21812e56cfa114e"
},
"prod-research-gallery-phone": {
"bundle": "doxa-maps-spa",
"sha256": "3d5467fd47ada47979fb878a5a355329ddc3ad88071ec947bf6c4a61d53df143"
},
"prod-simple-research": {
"bundle": "research-map-plus-grid",
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
},
"prod-simple-research-phone": {
"bundle": "research-map-plus-grid",
"sha256": "458139bb4b1da0e79fefeb3c671b78d81da4ef832e411e54789915ca07007ee7"
}
}

View file

@ -19,9 +19,9 @@
* Deliberately NOT fatal on its own: the caller decides whether to start its own server
* instead, or to skip. What it must never do is quietly continue.
*/
import { readFileSync } from 'node:fs'
import { readFileSync, existsSync, realpathSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { resolve, dirname, join } from 'node:path'
const MANIFEST = 'doxa-maps/manifest.json'
@ -123,3 +123,48 @@ export async function requireThisTree(base, repo, label) {
}
return verdict
}
/**
* The same guard for gates that READ A CHECKOUT instead of talking to a server.
*
* check-tabs-localized and check-poster-prints read library/ source from DOXA_REPO, which exists
* so a gate can be pointed at ANOTHER repo on purpose. That is also exactly how a leaked env var
* from someone else's shell makes a gate grade their tree while reporting as ours. So pointing
* elsewhere is an explicit opt-in (DOXA_REPO_MEASURE_OTHER=1); without it the gate exits 75.
*
* @param {string} repoPath the checkout the gate is about to read
* @param {string} repo this gate's own repo root
* @param {string} label the gate's name, for the message
*/
export function requireThisRepo(repoPath, repo, label) {
const real = (p) => { try { return realpathSync(p) } catch { return String(p) } }
if (real(repoPath) === real(repo)) return { ok: true, why: 'this checkout' }
if (process.env.DOXA_REPO_MEASURE_OTHER === '1') {
console.log(`${label}: measuring ANOTHER checkout on purpose (DOXA_REPO_MEASURE_OTHER=1): ${repoPath}`)
return { ok: true, why: 'explicit opt-in' }
}
console.log(`${label}: NOT MEASURED — DOXA_REPO points at ${repoPath}, not this checkout (${repo}).`)
console.log(' Unset DOXA_REPO, or set DOXA_REPO_MEASURE_OTHER=1 if measuring that checkout is the intent.')
process.exit(75)
}
/**
* For a gate handed a FILE to render: refuse a file that lives inside a DIFFERENT git checkout.
* A file outside any checkout (a /tmp or _qa-cycle output) cannot be attributed and is allowed,
* with a note; one inside another worktree is that worktree's build, not ours.
*/
export function requirePathInThisCheckout(file, repo, label) {
const real = (p) => { try { return realpathSync(p) } catch { return String(p) } }
let dir = dirname(real(file))
while (dir && dir !== dirname(dir)) {
if (existsSync(join(dir, '.git'))) {
if (real(dir) === real(repo)) return { ok: true, why: 'inside this checkout' }
console.log(`${label}: NOT MEASURED — ${file} belongs to another checkout (${dir}), not this one (${repo}).`)
process.exit(75)
}
dir = dirname(dir)
}
console.log(`${label}: note — ${file} is outside any checkout, so which build produced it cannot be checked.`)
return { ok: true, why: 'outside any checkout' }
}

View file

@ -23,13 +23,16 @@ set -u
SLOTS="${BROWSER_SLOTS:-2}"
DIR="${BROWSER_SLOT_DIR:-/tmp}"
BASE="$DIR/doxa-maps-browser-gate.lock"
# SLOT_BASE, never BASE: a variable the CALLER exported stays exported in bash, so assigning BASE
# here rewrote the caller's BASE for every gate this runs. Seven gates read process.env.BASE, and
# check:upg-map-data-painted measured /tmp/doxa-maps-browser-gate.lock/... and SKIPPED with exit 0.
SLOT_BASE="$DIR/doxa-maps-browser-gate.lock"
FLOOR_MB="${BROWSER_MEM_FLOOR_MB:-2500}"
WAIT_S="${BROWSER_LOCK_WAIT:-900}"
MEM_WAIT_S="${BROWSER_MEM_WAIT:-300}"
BUSY=75
slot_path() { [ "$1" = "1" ] && echo "$BASE" || echo "$BASE$1"; }
slot_path() { [ "$1" = "1" ] && echo "$SLOT_BASE" || echo "$SLOT_BASE$1"; }
mem_available_mb() {
awk '/^MemAvailable:/ { printf "%d", $2 / 1024 }' /proc/meminfo 2>/dev/null || echo 999999
@ -58,10 +61,22 @@ while [ "$i" -le "$SLOTS" ]; do
i=$((i + 1))
done
# Everything busy: queue on slot 1, and report a timeout as UNMEASURED rather than as a verdict.
exec 9>"$(slot_path 1)"
if flock -w "$WAIT_S" 9; then
exec "$@"
fi
# Everything busy: keep POLLING EVERY SLOT until the deadline, and report a timeout as UNMEASURED
# rather than as a verdict. This used to queue on slot 1 alone (flock -w on slot 1), so a slot that
# freed up was never taken: measured 2026-09-25 (dx-shepherd), ten waiters were piled on slot 1 while
# slot 2 sat free, and with slot 1 held 12 s and slot 2 freed at ~3 s a caller waited 11.5 s — the
# two-slot semaphore had collapsed into ONE queue. Polling takes the first slot that frees (~4 s in
# the same test). Trade-off, accepted: polling is not first-come-first-served, so under heavy
# contention a waiter can lose a few rounds; an idle slot beside a queue is strictly worse.
deadline=$(( $(date +%s) + WAIT_S ))
while [ "$(date +%s)" -lt "$deadline" ]; do
i=1
while [ "$i" -le "$SLOTS" ]; do
exec 9>"$(slot_path "$i")" 2>/dev/null || true
if flock -n 9; then exec "$@"; fi
i=$((i + 1))
done
sleep 2
done
echo "browser-slot: no slot within ${WAIT_S}s (${SLOTS} slots, floor ${FLOOR_MB}MB) — not measuring" >&2
exit "$BUSY"

View file

@ -7,127 +7,100 @@
* live in a fresh context, and published. All true — and insufficient. `big-picture` (cap-4,
* tab-1 of the cap-5 SPA) and `dashboard-plus-grid` NEST that grid and COMPILE IT IN, so both
* kept shipping the PRE-FIX code. The surface that was proved is not the surface anyone opens.
*
* ★ A COMPOSITE IS A COPY TAKEN AT ITS OWN BUILD TIME, NOT A POINTER TO ITS CHILD.
* So a child's source newer than a parent's bundle means the parent is serving stale code.
*
* WHY NOT GREP FOR A MARKER CLASS
* That is how the bug was originally found, but it does not generalise: it needs a human to
* remember a string unique to each fix. Worse, the OBVIOUS choice misleads — grepping the
* pre-existing `filters__toggle` reported "present" on all three bundles and HID the staleness;
* only the fix-introduced `filters__toggle-count` exposed it. A gate that depends on choosing
* the right marker will eventually be handed the wrong one. MTIME needs no such choice.
* ★ WHY IT NOW REBUILDS AND COMPARES BYTES, NOT MTIMES (2026-09-25). The first version compared
* the newest child SOURCE mtime with the parent BUNDLE mtime. File times are not build facts:
* a git merge or checkout rewrites sources with checkout-time mtimes, so rega's publish of
* beb13da3 stopped on "doxa-maps-spa / plugin-studio / stack-demo STALE vs gospel-resources /
* research-dashboard" although a rebuild reproduced the committed bytes exactly — and two of
* those parents mount their children BY TAG at runtime, so they compile none of the child in
* and CANNOT be stale with respect to it. It was also gameable the other way: checking out the
* parents makes their mtimes new and the gate passes on stale code.
* The question is "does the committed bundle equal what its source builds TODAY?", so that is
* what is asked: each composite is rebuilt into a temp dir (vite.config.js DOXA_OUT_ROOT — never
* public/) and its sha256 compared with the committed bundle. Builds are deterministic (two runs
* → identical bytes, verified), so a difference is real: the committed parent predates a source
* change it compiles in — its own, or a child's. A child it only mounts by tag cannot move it.
*
* HOW IT WORKS
* 1. read every plugin-designs/<b>/<b>.meta.json `composes: []` — the AUTHORITATIVE declaration
* of nesting (the manifest has no `composes` field).
* 2. resolve each entry to a bundle: it is either a bundle DIRECTORY or an ELEMENT TAG owned by
* one (manifest `bundles[].elements`). A tag that resolves to nothing is REPORTED, never
* silently skipped — an unresolved edge is an unchecked edge.
* 3. for each parent→child edge compare the NEWEST child source mtime against the parent's
* built bundle mtime. Child newer ⇒ FAIL.
* 1. every plugin-designs/<b>/<b>.meta.json with `composes: [...]` is a composite parent (entries
* are still resolved and an unresolvable one is REPORTED — an unresolved edge is an unchecked
* edge);
* 2. BUNDLE=<parent> DOXA_OUT_ROOT=<tmp> vite build, then sha256(tmp) vs sha256(public/…/<b>.js);
* 3. differ ⇒ STALE (FAIL); no committed bundle ⇒ reported, not built ⇒ FAIL.
* Usage: node internal/check-composite-freshness.mjs [--only a,b] (~5-15 s per composite)
*
* SELF-EDGES ARE SKIPPED: big-picture composes `bp-research-header`, which resolves back to
* big-picture. A bundle cannot be stale with respect to itself.
*
* NEGATIVE-TESTED: `touch plugin-designs/people-groups-grid/people-groups-grid.vue` makes this
* FAIL naming big-picture + dashboard-plus-grid + doxa-maps-spa + plugin-studio + stack-demo;
* a full `bun run build` returns it to PASS. A gate without a negative test is not a gate.
* NEGATIVE-TESTED 2026-09-25: an edit to people-groups-grid.vue with no rebuild fails the
* composites that compile it in; `touch` on every child source (no content change) still PASSES on
* a fresh tree — the mtime false alarm is gone.
*/
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs'
import { join, dirname, resolve } from 'node:path'
import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DESIGNS = join(ROOT, 'plugin-designs')
const BUILT = join(ROOT, 'public', 'doxa-maps')
const MANIFEST = join(BUILT, 'manifest.json')
const TREE = join(ROOT, 'public', 'doxa-maps')
const argv = process.argv.slice(2)
const oi = argv.indexOf('--only')
const ONLY = oi >= 0 && argv[oi + 1] ? new Set(argv[oi + 1].split(',')) : null
if (!existsSync(MANIFEST)) {
console.log('COMPOSITE-FRESHNESS: SKIP — no manifest yet (run `bun run build` first).')
if (!existsSync(join(TREE, 'manifest.json'))) {
console.log('COMPOSITE-FRESHNESS: SKIP — no built tree (public/doxa-maps/manifest.json).')
process.exit(0)
}
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'))
const manifest = JSON.parse(readFileSync(join(TREE, 'manifest.json'), 'utf8'))
// tag -> owning bundle, so a composes entry naming an ELEMENT still resolves.
const tagToBundle = new Map()
for (const b of manifest.bundles || []) {
for (const e of b.elements || []) {
const tag = typeof e === 'string' ? e : e?.tag
if (tag) tagToBundle.set(tag, b.bundle)
}
}
const byTag = new Map()
for (const b of manifest.bundles || []) for (const t of b.elements || []) byTag.set(t, b.bundle)
const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js'))))
// A bare PROFILE name of the same bundle (gospel-resources composes "resources-map", "data-tables" —
// its own sibling profiles) resolves to that bundle, as the studio resolves it.
const profilesOf = new Map((manifest.bundles || []).map((b) => [b.bundle, new Set((b.profiles || []).map((p) => p.profile))]))
const resolveEntry = (e, parent) => { const s = String(e); const head = s.split('/')[0]
if (bundles.has(head)) return head
if (parent && profilesOf.get(parent) && profilesOf.get(parent).has(s)) return parent
return byTag.get(s) || null }
/** Newest mtime under a bundle's SOURCE dir (its .vue/.js/.json — not node_modules). */
function newestSource(bundle) {
const dir = join(DESIGNS, bundle)
if (!existsSync(dir)) return null
let newest = 0, file = null
const walk = (d) => {
for (const ent of readdirSync(d, { withFileTypes: true })) {
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue
const p = join(d, ent.name)
if (ent.isDirectory()) { walk(p); continue }
if (!/\.(vue|js|mjs|json|css)$/.test(ent.name)) continue
const m = statSync(p).mtimeMs
if (m > newest) { newest = m; file = p.slice(ROOT.length + 1) }
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
const tmp = mkdtempSync(join(tmpdir(), 'composite-freshness-'))
const failures = [], unresolved = [], fresh = []
let composites = 0
try {
for (const bundle of [...bundles].sort()) {
if (ONLY && !ONLY.has(bundle)) continue
let composes = null
for (const f of readdirSync(join(DESIGNS, bundle)).filter((f) => f.endsWith('.meta.json'))) {
try { const c = JSON.parse(readFileSync(join(DESIGNS, bundle, f), 'utf8')).composes; if (Array.isArray(c) && c.length) composes = (composes || []).concat(c) } catch { /* not JSON: not a declaration */ }
}
}
walk(dir)
return newest ? { mtime: newest, file } : null
}
function builtBundle(bundle) {
const p = join(BUILT, bundle, `${bundle}.js`)
return existsSync(p) ? { mtime: statSync(p).mtimeMs, file: p.slice(ROOT.length + 1) } : null
}
const failures = []
const unresolved = []
let edges = 0, composites = 0
for (const ent of readdirSync(DESIGNS, { withFileTypes: true })) {
if (!ent.isDirectory()) continue
const bundle = ent.name
const meta = join(DESIGNS, bundle, `${bundle}.meta.json`)
if (!existsSync(meta)) continue
let composes
try { composes = JSON.parse(readFileSync(meta, 'utf8')).composes } catch { continue }
if (!Array.isArray(composes) || !composes.length) continue
composites++
const parentBuilt = builtBundle(bundle)
if (!parentBuilt) continue // never built — not this gate's concern
for (const rawEntry of composes) {
// a composes entry may be "bundle", "bundle/profile", or an element tag
const head = String(rawEntry).split('/')[0]
const child = existsSync(join(DESIGNS, head)) ? head : tagToBundle.get(head)
if (!child) { unresolved.push(`${bundle} composes "${rawEntry}" — resolves to no bundle`); continue }
if (child === bundle) continue // self-edge (own element); cannot be stale vs itself
edges++
const src = newestSource(child)
if (!src) continue
if (src.mtime > parentBuilt.mtime) {
const lagS = Math.round((src.mtime - parentBuilt.mtime) / 1000)
failures.push(
`${bundle} is STALE vs its child ${child}\n` +
` child source : ${src.file}\n` +
` parent bundle: ${parentBuilt.file}\n` +
` child is ${lagS}s newer — the parent compiled an OLD copy. Run a FULL \`bun run build\`.`
)
if (!composes) continue
for (const e of composes) if (!resolveEntry(e, bundle)) unresolved.push(`${bundle} composes "${e}" — resolves to no bundle`)
composites++
const committed = join(TREE, bundle, `${bundle}.js`)
if (!existsSync(committed)) { failures.push(`${bundle}: no committed bundle at public/doxa-maps/${bundle}/${bundle}.js`); continue }
const out = join(tmp, bundle)
const r = spawnSync('bun', ['x', 'vite', 'build'], { cwd: ROOT, env: { ...process.env, BUNDLE: bundle, DOXA_OUT_ROOT: out }, encoding: 'utf8' })
const built = join(out, bundle, `${bundle}.js`)
if (r.status !== 0 || !existsSync(built)) {
failures.push(`${bundle}: the rebuild FAILED (exit ${r.status}) — ${String(r.stderr || r.stdout).trim().split('\n').slice(-2).join(' / ').slice(0, 200)}`)
continue
}
if (sha(built) !== sha(committed)) {
failures.push(`${bundle} is STALE — the committed bundle is not what its source builds today (committed ${sha(committed).slice(0, 12)}, rebuilt ${sha(built).slice(0, 12)}). Rebuild it: BUNDLE=${bundle} bun x vite build, then bun run fix:sri.`)
} else fresh.push(bundle)
}
}
} finally { rmSync(tmp, { recursive: true, force: true }) }
console.log(`COMPOSITE-FRESHNESS: ${composites} composite(s), ${edges} parent->child edge(s) checked.`)
console.log(`COMPOSITE-FRESHNESS: ${composites} composite(s) rebuilt into a temp dir and compared byte for byte with the committed bundle.`)
if (fresh.length) console.log(` ✓ ${fresh.join(', ')}`)
for (const u of unresolved) console.log(` ! UNRESOLVED ${u}`)
if (failures.length) {
console.log(`\nCOMPOSITE-FRESHNESS: FAIL — ${failures.length} composite(s) carry a stale child:`)
console.log(`\nCOMPOSITE-FRESHNESS: FAIL — ${failures.length} composite(s):`)
for (const f of failures) console.log(` ✗ ${f}`)
console.log('\n A composite is a COPY taken at its own build time, not a pointer to its child.')
process.exit(1)
}
console.log('COMPOSITE-FRESHNESS: PASS — every composite was built after the children it nests.')
console.log('COMPOSITE-FRESHNESS: PASS — every committed composite is exactly what its source builds today.')

View file

@ -28,6 +28,7 @@
*/
import { chromium } from 'playwright'
import { serveReaped } from './_serve-reaped.mjs'
import { servesThisTree } from './_serves-this-tree.mjs'
import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { tmpdir } from 'node:os'
@ -38,7 +39,9 @@ const REPO = resolve(HERE, '..')
const PUBLIC = resolve(REPO, 'public')
const LEAF = 'doxa-maps/gospel-resources/resources-map/index.html'
const leafPort = 5810 + Math.floor(Math.random() * 60)
// EMBED_BASEMAP_PORT pins the port — used to prove the identity guard below (occupy it with another
// tree's server first; the gate must refuse rather than measure that tree).
const leafPort = Number(process.env.EMBED_BASEMAP_PORT) || (5810 + Math.floor(Math.random() * 60))
const hostPort = leafPort + 1
/** Mapbox endpoints that actually draw the map. Telemetry is not evidence of anything. */
@ -82,7 +85,15 @@ const stop = () => {
if (!(await waitFor(`http://127.0.0.1:${leafPort}/${LEAF}`)) || !(await waitFor(`http://127.0.0.1:${hostPort}/index.html`))) {
console.error('EMBED-BASEMAP: NOT MEASURED — a server did not come up.')
stop()
process.exit(1)
process.exit(75) // a precondition, not a product failure (was 1, which read as a red gate)
}
// ★ THE PORT IS RANDOM, NOT OS-ASSIGNED: if another agent's server already holds it, ours fails to
// start and waitFor() above succeeds against THEIRS. Prove the leaf server is this build.
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
{
// servesThisTree (not requireThisTree) so this gate stops ITS OWN two servers before exiting.
const t = await servesThisTree(`http://127.0.0.1:${leafPort}`, REPO)
if (/DIFFERENT build/.test(t.why)) { console.log('EMBED-BASEMAP: NOT MEASURED — ' + t.why); stop(); process.exit(75) }
}
const browser = await chromium.launch({

View file

@ -0,0 +1,86 @@
/**
* check-gospel-all.mjs — everything this bundle owns, in one command.
*
* WHY THIS EXISTS. gospel-resources has ten gates of its own and every one of them was
* hand-run. That is how a dead bundle shipped: a duplicate module killed all five leaves,
* and the one gate I happened to run that day was pointed at the map. `check:no-orphan-gates`
* was right that they were all reachable — reachable is not the same as run.
*
* It is NOT wired into gates:pre/post on purpose. Three of these need a browser, the shared
* wave holds browser gates under load, and a bundle owner should not be able to slow every
* other agent's build by adding to a list they do not own. This is the bundle's own front
* door instead: one command, honest about what it could not measure.
*
* STATIC FIRST, THEN BROWSER. The static gates are sub-second and catch most drift, so they
* run first and a failure there stops the run before a browser is launched — there is no
* point spending a scarce browser slot to confirm a table is inconsistent.
*
* NOT MEASURED IS NOT PASS. A browser gate that cannot get a slot exits 75 and is reported
* as NOT MEASURED, never folded into the pass. Anything else would turn a busy machine into
* a green tick.
*/
import { spawnSync } from 'node:child_process'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const SLOT = resolve(HERE, 'browser-slot.sh')
const STATIC = [
['check:no-conflict-markers', 'nothing tracked is mid-merge'],
['check:no-conflict-markers:poison', '...and that check can fail'],
['check:coverage-consistent', 'the surfaces agree about who has what'],
['check:coverage-consistent:poison', '...and that check can fail'],
['check:surfaces-read-the-tier', 'one place decides "this group has nothing"'],
['check:stale-sweep-claim', 'no caveat outliving its truth'],
['check:click-sheet', 'the country sheet resolves, links are absolute'],
['check:snapshot-guard', 'no fetcher writes around the guard'],
]
const BROWSER = [
['check:leaves-mount', 'all five leaves actually mount'],
['check:leaves-mount:poison', '...and that check can fail'],
['check:composed-filters', 'the composite filters instead of navigating'],
]
const run = (script, viaSlot) => {
const cmd = viaSlot ? SLOT : 'bun'
const args = viaSlot ? ['bun', 'run', script] : ['run', script]
const r = spawnSync(cmd, args, { cwd: REPO, encoding: 'utf8' })
return { code: r.status ?? 1, out: `${r.stdout || ''}${r.stderr || ''}` }
}
const failed = []
const notMeasured = []
let passed = 0
for (const [script, what] of STATIC) {
const r = run(script, false)
if (r.code === 0) { passed++; console.log(` ok ${script.padEnd(34)} ${what}`) }
else { failed.push([script, r.out.trim().split('\n').slice(-3).join(' | ')]); console.log(` FAIL ${script.padEnd(34)} ${what}`) }
}
if (failed.length) {
console.error(`\nGOSPEL-ALL: FAIL — ${failed.length} static gate(s); not launching a browser to confirm it.`)
for (const [s, tail] of failed) console.error(` ${s}: ${tail}`)
process.exit(1)
}
for (const [script, what] of BROWSER) {
const r = run(script, true)
if (r.code === 0) { passed++; console.log(` ok ${script.padEnd(34)} ${what}`) }
else if (r.code === 75) { notMeasured.push(script); console.log(` -- ${script.padEnd(34)} NOT MEASURED (no browser slot)`) }
else { failed.push([script, r.out.trim().split('\n').slice(-3).join(' | ')]); console.log(` FAIL ${script.padEnd(34)} ${what}`) }
}
console.log('')
if (failed.length) {
console.error(`GOSPEL-ALL: FAIL — ${failed.length} gate(s) red, ${passed} green, ${notMeasured.length} not measured.`)
for (const [s, tail] of failed) console.error(` ${s}: ${tail}`)
process.exit(1)
}
if (notMeasured.length) {
console.log(`GOSPEL-ALL: INCOMPLETE — ${passed} green, ${notMeasured.length} NOT MEASURED (${notMeasured.join(', ')}).`)
console.log(' Not a pass. Re-run when a browser slot is free.')
process.exit(75)
}
console.log(`GOSPEL-ALL: PASS — ${passed} gate(s), static and browser.`)

View file

@ -59,7 +59,7 @@ try {
p.on('pageerror', (e) => bad.push(`pageerror: ${String(e.message).slice(0, 120)}`))
await p.goto(`${BASE}${PATHNAME}`, { waitUntil: 'networkidle', timeout: 90000 })
await p.waitForFunction(() => window.__grState && window.__grState().pins > 0, { timeout: 60000 })
await p.waitForFunction(() => window.__grState && window.__grState().pins > 0, null, { timeout: 60000 })
await p.waitForTimeout(8000)
const read = () => p.evaluate(() => {

View file

@ -0,0 +1,112 @@
/**
* check-gospel-leaves-mount.mjs — did the element actually MOUNT, on every leaf?
*
* ═══ THE FAILURE THIS EXISTS FOR, WHICH I SHIPPED INTO MY OWN WORKING TREE ══════════════
* An edit used a ref it never declared. The ReferenceError was thrown during setup, INSIDE
* the custom-element boundary, where it was swallowed: the page loaded, every asset returned
* 200, `pageerror` fired zero times, `console.error` fired zero times, and the element sat in
* the DOM with an empty shadow root. A perfectly healthy-looking dead page.
*
* Nothing I had would have said so on its own. The rich gates would have — verify-gospel-map
* asserts on pin counts — but they are slow, they need a token and a basemap, and they are
* not run on every leaf. What was missing was the cheap, unambiguous question: IS ANYTHING
* THERE. This asks exactly that, on all five leaves, in a few seconds each.
*
* WHAT IT ASSERTS PER LEAF, in the order a failure actually happens:
* 1. the custom element tag is DEFINED (the bundle ran at all)
* 2. the host element exists in the document (the leaf HTML embeds it)
* 3. it has a shadowRoot (defineCustomElement attached one)
* 4. the shadow root has real content (setup did not throw)
* 5. it renders visible text (it is not an empty shell)
* Any one of those failing is reported with its own sentence, because "the leaf is broken"
* is useless and "the shadow root is empty, so setup threw" is a lead.
*
* ZERO ERRORS IS NOT THE SAME AS WORKING, and that is the whole lesson. This deliberately
* does NOT trust the absence of console errors; it looks for presence of output.
*
* Run with --poison to prove it can fail: it asks the same questions of a URL that serves no
* element at all, and requires every leaf-shaped assertion to go red.
*/
import { chromium } from 'playwright'
import { serveReaped } from './_serve-reaped.mjs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const PUBLIC = resolve(HERE, '../public')
const PORT = Number(process.env.PORT || 5399)
const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`
const POISON = process.argv.includes('--poison')
const TAG = 'gospel-resources-map'
const LEAVES = ['resources-map', 'resources-list', 'data-tables', 'charts', 'gospel-dashboard']
let srv = null
if (!process.env.BASE_URL) srv = serveReaped(PUBLIC, PORT)
const probe = async (page, url) => {
await page.goto(url, { waitUntil: 'networkidle', timeout: 90000 })
/* Give setup a real chance; these leaves fetch tables before they render. */
await page.waitForTimeout(9000)
return page.evaluate((tag) => {
const defined = !!customElements.get(tag)
const host = document.querySelector(tag)
const sr = host ? host.shadowRoot : null
const text = sr ? (sr.textContent || '').replace(/\s+/g, ' ').trim() : ''
return {
defined,
hostPresent: !!host,
hasShadow: !!sr,
shadowChildren: sr ? sr.childElementCount : 0,
/* CSS injected into the shadow root counts as children, so text is the real signal. */
visibleTextLength: text.replace(/\{[^}]*\}/g, '').length,
}
}, TAG)
}
const failuresFor = (leaf, r) => {
const out = []
if (!r.defined) out.push(`${leaf}: <${TAG}> is not defined — the bundle never ran.`)
else if (!r.hostPresent) out.push(`${leaf}: the leaf HTML does not embed <${TAG}>.`)
else if (!r.hasShadow) out.push(`${leaf}: the element has no shadowRoot — it was never upgraded.`)
else if (!r.shadowChildren) out.push(`${leaf}: the shadow root is EMPTY — setup threw, and the boundary swallowed it.`)
else if (r.visibleTextLength < 40) out.push(`${leaf}: the shadow root renders ${r.visibleTextLength} characters of text — an empty shell.`)
return out
}
let browser
try {
browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
if (POISON) {
/* A URL that serves no element at all must fail EVERY leaf-shaped assertion. */
const r = await probe(page, `${BASE}/doxa-maps/index.html`)
const f = failuresFor('poison', r)
if (!f.length) {
console.error('LEAVES-MOUNT: SELF-TEST FAIL — a page with no gospel element passed as mounted.')
process.exit(1)
}
console.log(`LEAVES-MOUNT --poison: caught it — ${f[0]}`)
console.log('LEAVES-MOUNT: SELF-TEST PASS — the check fails as designed.')
process.exit(0)
}
const bad = []
for (const leaf of LEAVES) {
const r = await probe(page, `${BASE}/doxa-maps/gospel-resources/${leaf}/index.html`)
const f = failuresFor(leaf, r)
bad.push(...f)
console.log(` ${f.length ? 'FAIL' : 'ok '} ${leaf.padEnd(18)} defined=${r.defined} shadow=${r.hasShadow} children=${r.shadowChildren} text=${r.visibleTextLength}`)
}
if (bad.length) {
console.error(`LEAVES-MOUNT: FAIL — ${bad.length} leaf/leaves did not mount:`)
for (const b of bad) console.error(` ${b}`)
console.error(' A page can return 200 for every asset and report zero console errors while')
console.error(' being completely dead: a throw during setup dies inside the element boundary.')
process.exit(1)
}
console.log(`LEAVES-MOUNT: PASS — all ${LEAVES.length} leaves mount and render.`)
} finally {
await browser?.close()
srv?.stop()
}

View file

@ -28,6 +28,9 @@
// Usage: node internal/check-legend-export-reachable.mjs [leafUrl]
// SKIPs when nothing is served — this needs the built tree.
import { chromium } from 'playwright'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { requireThisTree } from './_serves-this-tree.mjs'
const DEFAULT_LEAF = (process.env.BASE_URL || 'http://127.0.0.1:4173')
+ '/doxa-maps/my-upg-100-list/upg-100-list/index.html'
@ -43,6 +46,8 @@ if (!reachable) {
console.log(' Serve the built tree: python3 -m http.server 4173 --directory public (or set BASE_URL)')
process.exit(0)
}
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
await requireThisTree(LEAF, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'LEGEND-EXPORT-REACHABLE')
const WALK = `(root, out) => { out = out || []; const rec = (r) => { for (const e of r.querySelectorAll('*')) { out.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return out }`

View file

@ -0,0 +1,68 @@
/**
* check-no-conflict-markers.mjs — nothing tracked may carry an unresolved merge.
*
* THE INCIDENT. A merge commit from this worktree landed the BUILT bundle
* public/doxa-maps/gospel-resources/gospel-resources.js with 21 conflict markers in it —
* both sides concatenated, 1.13 MB. The browser threw "Identifier Ge has already been
* declared" and every gospel leaf rendered dead, while the SRI hash still matched, every
* request still returned 200, and no probe could see an error.
*
* THE CAUSE WAS A LANDING SCRIPT, NOT A BAD MERGE. The loop resolved the one file it
* expected to conflict and then ran `git add -A public`, which stages EVERY file under
* public/ — including the ones still holding markers. A one-line convenience that turns a
* conflict git correctly refused to guess at into a committed artefact.
*
* AND THE FIRST DIAGNOSIS WAS WRONG. I attributed the duplicate identifier to a module
* imported twice and re-routed an import to "fix" it. That change was harmless but it was
* not the cause; the rebuild that came with it is what repaired the file. A plausible cause
* that a fix appears to confirm is the easiest kind of wrong answer, and the only defence is
* a check that names the real condition.
*
* THE RULE, for a BUILT file especially: never hand-merge, never take both. Take either
* side, REBUILD, commit the rebuild. Then run this before pushing.
*/
import { execSync } from 'node:child_process'
import { readFileSync } 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 POISON = process.argv.includes('--poison')
/* Built from char codes so this file never contains a marker and never trips itself. */
const OPEN = String.fromCharCode(60).repeat(7)
const MID = String.fromCharCode(61).repeat(7)
const CLOSE = String.fromCharCode(62).repeat(7)
const MARKER = new RegExp('^(' + OPEN + '|' + MID + '$|' + CLOSE + ')', 'm')
const scan = (text) => MARKER.test(text)
if (POISON) {
const fake = 'a\n' + OPEN + ' HEAD\nmine\n' + MID + '\ntheirs\n' + CLOSE + ' other\n'
if (!scan(fake)) {
console.error('NO-CONFLICT-MARKERS: SELF-TEST FAIL — a textbook conflict was not detected.')
process.exit(1)
}
console.log('NO-CONFLICT-MARKERS: SELF-TEST PASS — a textbook conflict is detected.')
process.exit(0)
}
const tracked = execSync('git ls-files -z', { cwd: REPO, maxBuffer: 1 << 28 })
.toString('utf8').split(String.fromCharCode(0)).filter(Boolean)
const hits = []
for (const f of tracked) {
let text
try { text = readFileSync(resolve(REPO, f), 'utf8') } catch { continue }
if (text.indexOf(String.fromCharCode(0)) >= 0) continue
if (scan(text)) hits.push(f)
}
if (hits.length) {
console.error('NO-CONFLICT-MARKERS: FAIL — ' + hits.length + ' tracked file(s) carry an unresolved merge:')
for (const f of hits.slice(0, 10)) console.error(' ' + f)
console.error(' For a BUILT file: take either side, REBUILD the bundle, commit the rebuild.')
console.error(' Never "add -A" a directory mid-merge — it stages the markers with everything else.')
process.exit(1)
}
console.log('NO-CONFLICT-MARKERS: PASS — ' + tracked.length + ' tracked file(s), none mid-merge.')

View file

@ -29,7 +29,8 @@
import { chromium } from 'playwright'
import { existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { pathToFileURL } from 'node:url'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { requirePathInThisCheckout } from './_serves-this-tree.mjs'
const target = process.argv[2] || process.env.PACKET_HTML
if (!target) {
@ -43,6 +44,8 @@ if (!existsSync(file)) {
console.error(`PACKET-RENDER: FAIL — no such packet: ${file}`)
process.exit(1)
}
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
requirePathInThisCheckout(file, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'PACKET-RENDER')
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1500, height: 1941 } })

View file

@ -29,6 +29,8 @@ const REPO = process.env.DOXA_REPO
// other builder. DOXA_REPO still wins; only the default is made portable, using the idiom
// check-no-absolute-paths.mjs prints in its own failure text.
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
{ const { requireThisRepo } = await import('./_serves-this-tree.mjs'); requireThisRepo(REPO, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'POSTER-PRINTS') }
const src = readFileSync(resolve(REPO, 'library/composables/usePosterPrints.js'), 'utf8')
const frame = readFileSync(resolve(REPO, 'library/composables/printInPlace.js'), 'utf8')
// The composable dynamically imports its renderers by RELATIVE path. Under a blob

View file

@ -98,6 +98,20 @@ const MARKERS = [
landed: 'rega, 2026-09-25',
why: 'Arabic, Hebrew and Persian map labels are shaped on MapLibre (keyless) maps, not drawn as disconnected glyphs',
},
{
// Basemap place names follow the reader's language (maps/localize-labels.js), applied from
// useMapInstance on every style.load. The built marker is the coalesce expression's literal
// prefix, which survives minification; staleRe is initializeMap's own error string (same logic
// as rtl-text-maplibre: a bundle that compiles initializeMap without the marker is stale).
id: 'basemap-labels-localized',
component: 'library/composables/useMapInstance.js',
sourceRe: /localizeBasemapLabels\(map\.value, readerLang\(\)\)/,
builtRe: /\["coalesce",\["get","name:"/,
staleRe: /GL JS script to your page/g,
marker: 'localizeBasemapLabels(map.value, readerLang()) (basemap labels localized)',
landed: 'rega, 2026-09-25',
why: 'country, city and street names on the basemap render in the reader\'s language (Mapbox setLanguage, MapLibre name:<lang>)',
},
]
// A marker is a literal (`marker`, matched with includes) unless the entry supplies regexes.
const inSource = (m, src) => (m.sourceRe ? m.sourceRe.test(src) : src.includes(m.marker))

View file

@ -47,6 +47,8 @@ import { chromium } from 'playwright'
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
{ const { requireThisRepo } = await import('./_serves-this-tree.mjs'); requireThisRepo(REPO, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'TABS-LOCALIZED') }
const LOCALES = join(REPO, 'library/i18n/locales')
// The tab ids research-map renders, in order, with the KEY each resolves through.

View file

@ -58,9 +58,12 @@ const MIN_ZONE_RATIO = 0.9
let reachable = false
try { const r = await fetch(urls[0], { signal: AbortSignal.timeout(5000) }); reachable = r.ok } catch { /* precondition */ }
if (!reachable) {
console.log(`UPG-MAP-DATA: SKIP — nothing served at ${urls[0]}.`)
console.log(' Serve the built tree: python3 -m http.server 4173 --directory public (or set BASE)')
process.exit(0)
// NOT MEASURED, exit 75 — never exit 0. Through browser-slot.sh this line once read
// "SKIP — nothing served at /tmp/doxa-maps-browser-gate.lock/…" (the slot had overwritten BASE)
// and EXITED 0, so a wave counted a gate that measured nothing as green.
console.log(`UPG-MAP-DATA: NOT MEASURED — nothing served at ${urls[0]}.`)
console.log(' Serve THIS checkout (internal/serve-strict.mjs on your own port) and pass BASE=http://127.0.0.1:<port>/doxa-maps')
process.exit(75)
}
// ★ "ANSWERS 200" AND "IS SERVING THIS BUILD" ARE DIFFERENT QUESTIONS. This gate reads the counter
@ -70,15 +73,16 @@ if (!reachable) {
// See _serves-this-tree.mjs (bundler-2, BUG-BROWSER-GATES-CAN-MEASURE-ANOTHER-WORKTREES-TREE):
// :4173 on this box was once held by a different worktree and a gate reported three confident runs
// about code nobody had changed.
// ⚠ NOT MEASURED rather than SKIP, exit 2: "nothing to check here" and "I cannot attribute what I
// saw" are different facts, and only the second one should stop you trusting the numbers.
// ⚠ NOT MEASURED, exit 75 — the repo's EX_TEMPFAIL for "not measured" (a9550858), which the wave
// runner reports as ⊘. It was exit 2 here, a code nothing else in the repo reads that way.
// This check is also what makes the :4173 default safe: another agent's tree there is refused.
const TREE_BASE = String(urls[0]).split('/doxa-maps/')[0]
const treeCheck = await servesThisTree(TREE_BASE, ROOT)
if (!treeCheck.ok) {
console.error('UPG-MAP-DATA: NOT MEASURED — the server is not serving this checkout.')
console.error(` ${treeCheck.why}`)
console.error(' The polygon and pin counts below would describe someone else\'s build.')
process.exit(2)
process.exit(75)
}
const WALK = `(root) => { const o = []; const rec = (r) => { for (const e of r.querySelectorAll('*')) { o.push(e); if (e.shadowRoot) rec(e.shadowRoot) } }; rec(root); return o }`

View file

@ -47,6 +47,9 @@ for (const k of Object.keys(expect)) {
const i = process.argv.indexOf('--url')
if (i > -1) {
// Measure THIS build, or measure nothing (exit 75 = NOT MEASURED; see a9550858 / _serves-this-tree.mjs).
const { requireThisTree } = await import('./_serves-this-tree.mjs')
await requireThisTree(process.argv[i + 1], ROOT, 'VISION-CARD-NUMBERS')
const { chromium } = await import('playwright')
const b = await chromium.launch()
const p = await b.newPage({ viewport: { width: 900, height: 1000 } })

View file

@ -271,6 +271,47 @@ function ownsAMapDeeply(profileDir) {
return false
}
/**
* Does THIS PROFILE reach a map through its own import graph?
*
* ★ PER PROFILE, NOT PER BUNDLE (BUG-FALSE-ISMAP-BAKES-403KB-MAPBOX-INTO-MAPLESS-LEAVES, fixed
* 2026-09-25). ownsAMapDeeply() answers "is there a map ANYWHERE under this bundle's src/", and the
* single-leaf branch used it for every profile — so a map-less sibling inherited map-ness from the
* bundle: regions-dashboard/regions-list shows a list and no map, but src/RegionCountryMap.vue made
* it isMap=true. Its leaf then baked the ~400 KB Mapbox engine it never uses, and since 4a6886d2 its
* embed snippet also preloaded it. This follows the RELATIVE imports reachable from the profile's
* own .vue (bounded to the bundle folder, .vue/.js/.mjs), so research-dashboard — whose map sits
* several imports deep in src/components/dashboards/DashboardMapPanel.vue — still resolves true.
* An explicit boolean `isMap` in <profile>.meta.json wins over the scan.
*/
const RELATIVE_IMPORT_RE = /(?:import[^'"`;]*?from\s*|import\s*\(\s*|import\s+)['"`](\.{1,2}\/[^'"`]+)['"`]/g
function profileReachesAMap(dir, file) {
const seen = new Set()
const stack = [join(dir, file)]
while (stack.length) {
const f = stack.pop()
if (seen.has(f)) continue
seen.add(f)
let src
try { src = readFileSync(f, 'utf8') } catch { continue }
if (MAP_ENGINE_RE.test(src)) return true
for (const m of src.matchAll(RELATIVE_IMPORT_RE)) {
const base = resolve(dirname(f), m[1])
if (!base.startsWith(dir)) continue // stay inside this bundle
for (const cand of [base, base + '.vue', base + '.js', base + '.mjs', join(base, 'index.js')]) {
if (NESTED_SCAN_EXT.test(cand) && existsSync(cand) && statSync(cand).isFile()) { stack.push(cand); break }
}
}
}
return false
}
function metaIsMap(dir, profileName) {
try {
const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8'))
return typeof meta?.isMap === 'boolean' ? meta.isMap : null
} catch { return null }
}
/**
* Does a cap-4 composite (`nested-elements-mfe`) compose a child that paints a map?
*
@ -1206,7 +1247,7 @@ function scanBundles() {
? false
: profileType === 'nested-elements-mfe'
? composesAMapLeaf(dir, profileName, file)
: (isMapLeaf(join(dir, file)) || ownsAMapDeeply(dir))
: (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file))
return {
profile: profileName,

View file

@ -377,8 +377,9 @@ await shot('prod-gospel-resources', '/doxa-maps/gospel-resources/resources-map/i
// ---- THE PRODUCTION MAPS ON A PHONE (390x844) — for the README's phone row ----
// Big Picture is desktop-only by ruling (on phones the Research Gallery disables it and Simple Research is
// the dashboard), so it has no phone shot. UPG-100's phone shot is deploy2-mobile-upg100. Simple Research
// (and the Research Gallery, which opens on it on phones) are left out until its phone layout is fixed.
// the dashboard), so it has no phone shot. UPG-100's phone shot is deploy2-mobile-upg100.
await shot('prod-simple-research-phone', '/doxa-maps/research-map-plus-grid/research-map-plus-grid/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
await shot('prod-research-gallery-phone', '/doxa-maps/doxa-maps-spa/doxa-maps-spa/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
await shot('prod-regions-phone', '/doxa-maps/regions-dashboard/regions-mfe/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
await shot('prod-prayer-phone', '/doxa-maps/doxa-simple-map/doxa-simple-map/prayer/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })
await shot('prod-cities-internet-phone', '/doxa-maps/cities-internet/connectivity-map/index.html', { vp: { width: 390, height: 844 }, dsf: 2, wait: 10000, minElements: 60 })

View file

@ -23,6 +23,9 @@
* (defaults to http://localhost:8899; serve ./public)
*/
import { chromium } from 'playwright'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { requireThisTree } from './_serves-this-tree.mjs'
// Default matches every other browser gate in this repo: `bun run serve` on loopback 4173.
// It was `localhost:8899` — one of the ad-hoc ports a previous session happened to have running,
@ -37,6 +40,9 @@ const BASE = process.env.BASE_URL || process.argv[2] || 'http://127.0.0.1:4173'
// internal/serve-strict.mjs, which 404s a directory exactly as production does, and it caught these
// two the first time it ran. The manifest already ships 28/28 iframeUrl values in this form.
const SHELL = '/doxa-maps/plugin-studio/plugin-studio/index.html'
// Measure THIS build, or measure nothing: a default port on this machine can belong to another
// worktree, and a stale studio there would be graded as ours. Exit 75 = NOT MEASURED (see a9550858).
await requireThisTree(BASE, resolve(dirname(fileURLToPath(import.meta.url)), '..'), 'SHELL-SCROLL')
const LEAF = '/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html'
// Shadow DOM DOES NOT PIERCE. A single-level querySelector lies about every composite, so every
@ -110,76 +116,62 @@ const run = async () => {
if (!before) { await browser.close(); fail('.pgg-root is NOT MOUNTED in the shell — different bug, not a scroll bug') }
if (before.h < 400) { await browser.close(); fail('.pgg-root mounted but collapsed (h=' + before.h + ') — not a scroll bug') }
// 2. A REAL WHEEL GESTURE over the MAP AREA must move the grid up the screen. This is the
// gesture the Driver makes, over the surface he makes it on. Mapbox scrollZoom eating the
// wheel would show up here as no movement.
await wheel(page, 850, 420, WHEEL_DY)
const afterMap = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
const movedByMap = before.top - afterMap.top
// 3. ...and a gesture BELOW the map must too (in case the map swallows its own area).
await wheel(page, 850, 800, WHEEL_DY)
// 2. THE DRIVER'S RULING, asserted as stated (2026-09-23, recorded in
// plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue:~1188):
// "I can't zoom in and out on the map. The scrolling is causing that to change." →
// a wheel OVER the map ZOOMS and moves the page 0 px; a wheel BELOW the map SCROLLS the page.
// ★ THIS STEP USED TO ASSERT THE OPPOSITE — that a wheel over the map must scroll the page —
// written before the ruling, when the map row filled the whole pane. Once the ruling made the
// map a bounded region with zoom always on, that assertion failed on the correct behaviour and
// kept this gate red on a product that was right (re-measured 2026-09-25: over-map 0 px, below
// the map 2502 px, grid on screen). dx-shepherd ruled the gate stale; this is the ruling.
// Negative control: SHELL_SCROLL_NEGATIVE=1 disables scrollZoom on the map before the gestures
// (the pre-ruling behaviour), and the gate must FAIL.
const MAP = `(() => { ${WALK}
const c = __walk('canvas.mapboxgl-canvas, canvas.maplibregl-canvas').find(x => x.getBoundingClientRect().height > 50)
if (!c) return null
const host = __walk('*').find(e => e.__doxaMap && e.__doxaMap.getCanvas && e.__doxaMap.getCanvas() === c)
const r = c.getBoundingClientRect()
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), bottom: Math.round(r.bottom), top: Math.round(r.top),
zoom: host ? host.__doxaMap.getZoom() : null }
})()`
const mapBox = await page.evaluate(MAP)
if (!mapBox || mapBox.zoom == null) { await browser.close(); fail('no map (canvas + its map instance) on the dashboard tab — cannot assert the zoom-vs-scroll ruling') }
if (process.env.SHELL_SCROLL_NEGATIVE === '1') {
await page.evaluate(`(() => { ${WALK} for (const e of __walk('*')) if (e.__doxaMap && e.__doxaMap.scrollZoom) e.__doxaMap.scrollZoom.disable() })()`)
console.log(' NEGATIVE CONTROL: scrollZoom disabled on the map — this run must FAIL')
}
// (a) a scroll-DOWN wheel over the map: the page must not move.
await wheel(page, mapBox.x, mapBox.y, WHEEL_DY, 3)
const afterMapDown = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
const movedByMap = before.top - afterMapDown.top
// (b) a zoom-IN wheel over the map: the zoom must go up (in, not out, so a map already at its
// minimum zoom cannot fake a failure).
const zBefore = (await page.evaluate(MAP)).zoom
await wheel(page, mapBox.x, mapBox.y, -WHEEL_DY, 3)
const zAfter = (await page.evaluate(MAP)).zoom
// (c) the same scroll-down wheel BELOW the map's box: this is how the page reaches the grid.
// Below the map's box, and no higher than the y=800 the original gate measured this gesture at: a
// point just under the canvas (y=636 on a 900px viewport) lands on the dashboard's own lower
// controls, which consume the wheel themselves, and read as "the page will not scroll".
const belowY = Math.min(Math.max(mapBox.bottom + 60, VIEWPORT_FOLD - 100), VIEWPORT_FOLD - 20)
const pageBefore = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
await wheel(page, mapBox.x, belowY, WHEEL_DY)
const afterLow = await page.evaluate(`(() => { ${WALK} return __gridTop() })()`)
const movedTotal = before.top - afterLow.top
// 4. THE REAL QUESTION: is the grid actually ON SCREEN now? A grid that moved 50px but still
// sits below the fold is still unreachable. Require its top inside the viewport.
const movedBelow = pageBefore.top - afterLow.top
const onScreen = afterLow.top < VIEWPORT_FOLD
console.log('SHELL-SCROLL measurements (cap-5 shell, tab="' + tab + '"):')
console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top)
console.log(' wheel over MAP area : grid moved ' + movedByMap + 'px (top ' + before.top + ' -> ' + afterMap.top + ')')
console.log(' wheel BELOW map : grid moved ' + movedTotal + 'px total (top -> ' + afterLow.top + ')')
console.log(' grid on screen : ' + onScreen)
console.log(' grid mounted : h=' + before.h + 'px, top=' + before.top)
console.log(' wheel OVER map (down) : page moved ' + movedByMap + 'px (ruling: 0)')
console.log(' wheel OVER map (zoom in) : zoom ' + zBefore.toFixed(2) + ' -> ' + zAfter.toFixed(2) + ' (ruling: zooms)')
console.log(' wheel BELOW map (y=' + belowY + ') : page moved ' + movedBelow + 'px (ruling: scrolls)')
console.log(' grid on screen : ' + onScreen)
if (movedTotal < 200) fail('grid did not move — a real wheel gesture in the SHELL scrolled ' + movedTotal + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.')
if (!onScreen) fail('grid moved ' + movedTotal + 'px but its top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.')
// ★ ASSERT ON THE NUMBER WE ALREADY MEASURED. This gate computed `movedByMap` at step 2 — the
// gesture the Driver actually makes — and then graded only `movedTotal`, which sums in the
// step-3 gesture BELOW the map. So it reported PASS (2611px total) while wheeling OVER the map
// moved just 300px and the Driver still could not reach the grid: Mapbox scrollZoom was eating
// the wheel because syncScrollZoomToHost sampled the layout on three fixed timeouts and froze a
// stale answer. Passing on the total rewarded an escape hatch the user will never find — his
// cursor is over the map, which fills most of the band.
//
// The failure mode to remember: a gate that MEASURES the right thing and then ASSERTS on a
// different variable. Distinct from a gate that cannot fail; it fails honestly, just never on
// the condition it was built for. Audit every gate for values it computes but never checks.
// ★ THE THRESHOLD IS DERIVED, NOT GUESSED. This was `movedByMap < 200` — a number picked as a
// vague "did it move meaningfully" floor WITHOUT comparing it to the value already measured as
// broken, which was 300. So the assertion passed on the exact measurement that demonstrates the
// bug, and the gate reported PASS across three ticks while the Driver still could not scroll.
//
// The gesture requests WHEEL_TICKS * WHEEL_DY px. A healthy scrollport delivers most of that
// (capped only by the scroll range actually available). Delivering one tick's worth means the
// other nine were swallowed by a nested scrollport under the cursor. Comparing DELIVERED against
// REQUESTED is self-calibrating: change the tick count or delta and the assertion still holds,
// where a hardcoded constant silently rots.
// ★ THE BASELINE IS MEASURED, NOT COMPUTED. A first attempt derived the expected travel as
// `before.top - VIEWPORT_FOLD`, which is 911-900 = 11px — a plausible-looking quantity that is
// simply the wrong one (it asks how far until the grid reaches the fold, not how much scroll the
// PANE has). With expected=11 the assertion passed on the broken 300px, repeating the exact
// mistake it was written to fix.
//
// The honest baseline is the SAME gesture performed somewhere it is known to work: step 3 wheels
// identically lower down the page and delivers `movedTotal - movedByMap`. Two identical gestures
// on one surface should move the page comparably; if the one over the dashboard moves far less,
// something under that cursor is eating it. No constant to rot, and it self-adjusts to whatever
// scroll range the layout actually has.
const requested = WHEEL_TICKS * WHEEL_DY
const movedByLower = movedTotal - movedByMap
if (movedByLower > 100 && movedByMap < movedByLower * 0.5) {
fail('a nested scrollport ATE THE WHEEL — the SAME ' + requested + 'px gesture moved the grid ' +
movedByLower + 'px lower down the page but only ' + movedByMap + 'px over the dashboard' +
'. The grid is technically reachable but NOT by ' +
'the gesture the Driver makes.\n' +
' ★ Do NOT assume the map. MEASURED 2026-09-22: the culprit was .rd-grid — a virtualized table ' +
'with scrollHeight 54834 in a ~207px window — consuming the wheel by normal scroll-chaining, ' +
'while the Mapbox canvas sat ABOVE the cursor and was never involved. Fix was ' +
'overscroll-behavior:contain on .rd-grid. Identify the element UNDER THE CURSOR before blaming ' +
'the one nearby: compare each scrollport\'s scrollTop before and after the gesture.')
}
if (Math.abs(movedByMap) > 4) fail('a wheel OVER the map scrolled the page ' + movedByMap + 'px. The Driver ruled (2026-09-23) that a wheel over the map ZOOMS and must not move the page — is scrollZoom disabled on this map?')
if (!(zAfter > zBefore + 0.05)) fail('a zoom-in wheel OVER the map left the zoom at ' + zAfter.toFixed(2) + ' (was ' + zBefore.toFixed(2) + '). The Driver ruled that a wheel over the map zooms it.')
if (movedBelow < 200) fail('a wheel BELOW the map scrolled the page only ' + movedBelow + 'px. The grid is mounted at h=' + before.h + 'px and is UNREACHABLE.')
if (!onScreen) fail('the page scrolled ' + movedBelow + 'px but the grid top is still at ' + afterLow.top + ' (below the 900px fold) — still not reachable.')
console.log('SHELL-SCROLL: PASS — the grid is reachable by wheel in the cap-5 shell.')

View file

@ -25,7 +25,7 @@
* onMounted(() => initializeMap())
*/
import { ref, shallowRef, inject } from 'vue'
import { ref, shallowRef, inject, watch } from 'vue'
import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
// ─── Engine registry (provider → CDN script + css + global) ──────────────────
@ -77,6 +77,7 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord'
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
import { resolveMapProvider } from './useMapProvider.js'
import { installRtlTextPlugin } from '../maps/rtl-text.js'
import { localizeBasemapLabels } from '../maps/localize-labels.js'
/**
* The live engine global for a provider, or undefined if not yet loaded.
@ -360,6 +361,22 @@ export function useMapInstance({
provider = provider || resolveMapProvider(_pcForProvider)
const isMaplibre = provider === 'maplibre'
// THE READER'S LANGUAGE, for the basemap's place names (maps/localize-labels.js). ProfileLoader
// provides it as a ref ('lang'); an element without ProfileLoader falls back to its profile-config
// locale, then the page (?lang= / <html lang>). Resolved here because inject() is setup-only.
let _langRef = null
try { _langRef = inject('lang', null) } catch { /* called outside setup */ }
const readerLang = () => {
const v = _langRef && typeof _langRef === 'object' && 'value' in _langRef ? _langRef.value : _langRef
if (typeof v === 'string' && v) return v
const pc = _pcForProvider && typeof _pcForProvider === 'object' && 'value' in _pcForProvider ? _pcForProvider.value : _pcForProvider
if (pc && (pc.locale || pc.lang)) return String(pc.locale || pc.lang)
try {
const q = new URLSearchParams(location.search)
return q.get('lang') || q.get('locale') || document.documentElement.lang || 'en'
} catch { return 'en' }
}
async function initializeMap() {
// Resolve a NAMED style ('ink', 'satellite', …) for this engine; raw URLs
// and undefined keep their existing behavior (see library/styles/registry.js).
@ -668,6 +685,9 @@ export function useMapInstance({
}
map.value.on('style.load', () => {
// Basemap place names in the reader's language — re-applied on EVERY style load, so a later
// setStyle() (a theme switch) cannot drop it. Plugin label layers are never touched.
if (map.value) localizeBasemapLabels(map.value, readerLang())
// ── NAMED-STYLE PAINT PATCH (e.g. 'galaxy': blue water on a black ground) ──────────────
// style.load is the ONLY correct moment: setPaintProperty before the style document has
// parsed throws, and re-applying here also survives a later setStyle() — the event fires
@ -714,5 +734,11 @@ export function useMapInstance({
}
}
// A live language switch (the gallery re-sets profile-config.locale; ProfileLoader's 'lang' ref
// follows) re-labels a map that is already on screen — no reload, no style swap.
if (_langRef && typeof _langRef === 'object' && 'value' in _langRef) {
try { watch(_langRef, () => { if (map.value && isMapReady.value) localizeBasemapLabels(map.value, readerLang()) }) } catch { /* outside setup */ }
}
return { map, isMapReady, initializeMap, destroy }
}

View file

@ -59,6 +59,18 @@ for (const [path, subtree] of Object.entries(rawModules)) {
messages[locale][area] = subtree
}
/**
* The English catalogue, exported from the ONE module that already owns it.
*
* Four gospel profiles used to `import enGospel from '@map/i18n/locales/en/gospel.json'` to
* get a real English fallback for a host that never installed i18n. That file is ALSO pulled
* in by the eager glob above, so the bundle ended up with two instances of the same module
* and the IIFE threw "Identifier 'Ge' has already been declared" on evaluation — a hard
* error that killed every gospel leaf while the SRI hash still matched and the page still
* returned 200. Importing it from here instead means there is exactly one.
*/
export const EN_MESSAGES = messages.en || {}
export const SUPPORTED_LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
export const RTL_LOCALES = new Set(['ar'])

View file

@ -0,0 +1,73 @@
/**
* localize-labels.js — show the BASEMAP's place names in the reader's language, on both engines.
*
* WHY. Every map translated its own chrome but left the basemap's country, city and street names in
* the style's default (English for Mapbox, Latin + native script for OpenFreeMap), in all eleven
* locales. A reader in Arabic saw an Arabic legend over an English map.
*
* TWO ENGINES, TWO MECHANISMS — each the engine's own supported one:
* Mapbox map.setLanguage(lang). The engine localizes its own styles, including the Standard
* style's IMPORTED basemap, which a layer-by-layer rewrite cannot reach.
* MapLibre OpenFreeMap styles label from OSM name tags, so each basemap symbol layer's text-field
* is pointed at ['coalesce', name:<lang>, name:latin, name]: the translated name where
* OSM has one, else the style's Latin name, else the local name. English restores each
* layer's ORIGINAL expression (remembered per map), so switching back loses nothing.
*
* ★ ONLY THE BASEMAP'S LABELS. Plugins add their own symbol layers (zone titles, people-group names,
* counts) that read their OWN properties. A layer is rewritten only when its text-field reads an OSM
* name field (name, name:latin, name:nonlatin, name_en, name_int, name:en). Anything else is not a
* basemap label and is never touched.
*
* Fail-soft everywhere: a label that stays in the default language is a nicety lost, never a broken
* map.
*/
const OSM_NAME_FIELD = /\[\s*"get"\s*,\s*"name(?::latin|:nonlatin|:en|_en|_int)?"\s*\]|\{name(?::latin|:nonlatin|:en|_en|_int)?\}/
const originals = new WeakMap() // map -> Map(layerId -> original text-field)
function isMaplibreMap(map) {
return typeof window !== 'undefined' && !!window.maplibregl && map instanceof window.maplibregl.Map
}
/**
* Point the basemap's place-name labels at `lang` ('en' = the style's own labels).
* Call after style.load, and again whenever the reader's language changes.
* @returns {number} how many label layers were localized (0 on Mapbox, which localizes internally)
*/
export function localizeBasemapLabels(map, lang) {
if (!map || !lang) return 0
const code = String(lang).split('-')[0].toLowerCase()
try {
if (!isMaplibreMap(map)) {
if (typeof map.setLanguage === 'function') map.setLanguage(code)
return 0
}
const style = map.getStyle && map.getStyle()
if (!style || !Array.isArray(style.layers)) return 0
let saved = originals.get(map)
if (!saved) { saved = new Map(); originals.set(map, saved) }
let n = 0
for (const layer of style.layers) {
if (layer.type !== 'symbol' || !layer.layout || layer.layout['text-field'] == null) continue
const current = layer.layout['text-field']
const json = JSON.stringify(current)
// Our own expression is recognisable by shape. Anything ELSE is the style's current original —
// re-saved every time, so a setStyle() that reuses layer ids with new expressions (a theme
// switch) never restores the PREVIOUS style's labels.
if (!json.startsWith('["coalesce",["get","name:')) {
if (!OSM_NAME_FIELD.test(json)) continue // a plugin's own label: leave it
saved.set(layer.id, current)
} else if (!saved.has(layer.id)) continue
const next = code === 'en'
? saved.get(layer.id)
: ['coalesce', ['get', 'name:' + code], ['get', 'name:latin'], ['get', 'name']]
map.setLayoutProperty(layer.id, 'text-field', next)
n++
}
return n
} catch (_) {
return 0
}
}
export default localizeBasemapLabels

View file

@ -196,7 +196,12 @@
"check:embed-base": "node internal/check-embed-base-resolution.mjs",
"check:composed-filters": "node internal/check-gospel-composed-filters.mjs",
"app:start-page": "node internal/set-app-start-page.mjs",
"check:app-start-page": "node internal/set-app-start-page.mjs --check"
"check:app-start-page": "node internal/set-app-start-page.mjs --check",
"check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs",
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:gospel-all": "node internal/check-gospel-all.mjs",
"check:no-conflict-markers": "node internal/check-no-conflict-markers.mjs",
"check:no-conflict-markers:poison": "node internal/check-no-conflict-markers.mjs --poison"
},
"//scripts": {
"_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.",
@ -296,7 +301,10 @@
"check:composed-filters": "CELL C — the gospel map composed above its own tables. Asserts the composite keeps the reader INSIDE it: \"Every resource in <language>\" must be a <button> that filters in place, not an <a> that navigates away and throws out the map the reader just positioned. Checks the tag, that the URL did not change, that the table NARROWED (row count down but not to zero), that the pin count survived, and that nothing 4xx'd. Picks a tier-2 pin from the live source — the first version opened pin 0, a group with nothing found, and failed a working composite on an empty table. Browser gate: run under both rendezvous locks, product lock first.",
"check:vision-card-numbers": "the DOXA Vision Card must show the numbers the DATA gives: independently recomputes all four funnel steps (and DOXA-in-frontier) from people_groups.csv + the saved DOXA list, compares with vision-funnel.json, and with --url also with the numbers painted in the card. A typed-in number, a stale funnel file or a rendering bug fails it.",
"check:map-embed-cells": "PRODUCTION-MATRIX cells B (iframe) and C (script embed) for the seven Research Gallery maps on a genuinely foreign origin (tree on 127.0.0.1, partner page on localhost). C passes the HOST a distinct token and fails if any Mapbox request uses another. --only bundle/profile,… --cell B|C.",
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle."
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on nuxtbase): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
"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:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
"check:no-conflict-markers": "Nothing tracked may carry an unresolved merge. Exists because a merge from this worktree committed a BUILT bundle with 21 conflict markers - both sides concatenated, 1.13MB - and every gospel leaf rendered dead while the SRI hash matched and every request returned 200. The cause was a landing script that resolved one expected file then ran \"git add -A public\", staging every other file including the ones still holding markers. For a built file: take either side, REBUILD, commit the rebuild, then run this before pushing. --poison for the negative test."
},
"devDependencies": {
"@capacitor/android": "^7.6.9",

View file

@ -46,7 +46,7 @@
* inlining it into the IIFE would block first paint for every profile in the bundle).
*/
import { useI18n } from 'vue-i18n'
import enGospel from '@map/i18n/locales/en/gospel.json'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { computed, inject, ref, watch } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -54,7 +54,7 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js'
/* Guarded like every other useI18n() in this bundle — the host may never have installed it,
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]), enGospel)
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()

View file

@ -21,7 +21,7 @@
*/
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import enGospel from '@map/i18n/locales/en/gospel.json'
import { EN_MESSAGES } from '@map/i18n/index.js'
import DataTable from '@map/components/tables/DataTable.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
@ -36,7 +36,7 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
the labels would silently render as whatever the tab object stringifies to. Guarded like
every other useI18n() call in this bundle: the host may never have installed i18n. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enGospel)
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()

View file

@ -1,6 +1,6 @@
<script setup>
import { useI18n } from 'vue-i18n'
import enGospel from '@map/i18n/locales/en/gospel.json'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { resourceRows } from './gospel-resource-rows.js'
/**
* resources-list.vue — GOSPEL RESOURCES, by language.
@ -45,7 +45,7 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
/* Guarded like every other useI18n() in this bundle — the host may never have installed it,
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]), enGospel)
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()

View file

@ -72,7 +72,7 @@
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import enGospel from '@map/i18n/locales/en/gospel.json'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
@ -203,6 +203,11 @@ const doxaUnplaced = ref(0) // on the roster, no coordinates in this build
const doxaByCountry = shallowRef({})
const pinCodes = ref({})
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
custom-element boundary, so the map simply never mounted and the page reported no error
at all. Zero errors is not the same as working. */
const pinCandidates = shallowRef({})
const pinCount = ref(0)
// The legend quotes these, so they can never drift from what the layer is drawing.
@ -652,7 +657,7 @@ const pinsByIso = computed(() => {
key. Returning the last dot-segment would have put "gap" and "deathsDay" on screen — a
broken-looking label is not graceful degradation, it just moves the bug. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enGospel)
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let t = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
@ -713,6 +718,7 @@ function groupAt(i) {
scripture_earth: c.se || null,
gospel_audio: c.ga || null,
bridge: pinBridges.value[`${r[4]}|${r[3]}`] || null,
bridge_candidate: pinCandidates.value[`${r[4]}|${r[3]}`] || null,
related: c.rel || null,
zume_url: c.z || '',
zume_approx: c.za || null,
@ -842,6 +848,7 @@ async function load() {
rows.value = data.countries || []
pinCodes.value = pinData.codes || {}
pinBridges.value = pinData.bridges || {}
pinCandidates.value = pinData.bridgeCandidates || {}
/* 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. */
@ -1734,6 +1741,26 @@ useShadowStyles(
<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.
</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.
No strength is shown because none can honestly be computed: ranking needs "is
this a lingua franca", and by population share a 10% floor refuses Urdu in
Pakistan while by group count Oman picks Swahili for an Arabic-speaking people.
The evidence is shown instead and the reader judges. Never for a sign language:
a spoken bridge is no bridge for a Deaf community. -->
<div v-else-if="selectedGroup.bridge_candidate && !selectedGroup.is_sign
&& !selectedGroup.scripture_url && !selectedGroup.film_url
&& !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><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.
</div>
<p v-if="selectedGroup.scripture_title || selectedGroup.scripture_scope" class="gm-scope">
<b>{{ selectedGroup.scripture_title || 'Scripture' }}</b>

View file

@ -40,52 +40,12 @@ const REPO = resolve(HERE, '../../..')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const doxa = T('doxa-people-groups.json')
const { codes, bridges, pins } = T('people-group-pins.json')
const { codes, bridges, bridgeCandidates } = T('people-group-pins.json')
/**
* ═══ A SECOND-BEST BRIDGE, OFFERED AS EVIDENCE AND NOT AS A RECOMMENDATION ══════════════
*
* The curated bridge above covers 168 of Doxa's 369 groups with nothing, because it comes
* from a curated subset table rather than from the data. For the other 201 a candidate IS
* computable from the pin index — the languages spoken in their country that have both
* Scripture and the film — and 196 of them have one.
*
* WHAT I COULD NOT COMPUTE, and therefore do not claim. Ranking those candidates needs a
* notion of "is this a lingua franca", and neither proxy available here survives contact:
* - by GROUP COUNT, Oman picks Swahili (2 groups) for an Arabic-speaking people.
* - by SHARE OF PINNED POPULATION, a 10% floor REFUSES Urdu in Pakistan at 7.3% — the
* national language — because few Pakistani groups are pinned as Urdu speakers. That is
* the same bad-denominator mistake this map already fixed on the choropleth: dividing by
* something that does not mean what the question needs it to mean.
* Both produce confident wrong answers, so neither ships as a strength. What ships is the
* CANDIDATE and the EVIDENCE — which language, how many groups there speak it, and what
* share of that country's pinned population they are — under column names that say
* "candidate". A reader who knows Pakistan will see Urdu at 7.3% and know what to do with
* it; a score would have thrown it away on their behalf.
*/
const countryPool = {}
const countryPop = {}
for (const p of pins) {
const iso3 = p[3], code = p[4], pop = p[5] || 0
if (!iso3 || !code) continue
const c = codes[code]
if (!c) continue
countryPop[iso3] = (countryPop[iso3] || 0) + pop
const e = (countryPool[iso3] ||= {})
const slot = (e[code] ||= { n: 0, pop: 0, l: c.l || '', rows: resourceRows(c) })
slot.n += 1
slot.pop += pop
}
const candidateFor = (iso3, ownCode) => {
const pool = countryPool[iso3] || {}
const best = Object.entries(pool)
.filter(([code, s]) => code !== ownCode &&
s.rows.some((x) => x.type === 'Scripture') && s.rows.some((x) => x.type === 'Film'))
.sort((a, b) => b[1].pop - a[1].pop)[0]
if (!best) return null
const tot = countryPop[iso3] || 0
return { code: best[0], l: best[1].l, n: best[1].n, share: tot ? Math.round((1000 * best[1].pop) / tot) / 10 : 0 }
}
/* The candidate is computed ONCE, in build-people-group-pins.mjs beside the curated bridge,
and read here. It used to be computed in this file, which meant the table could offer a
group a next step the map did not show — the same disagreement the tier rule exists to
prevent. One source, two surfaces. */
const TYPE_ORDER = ['Scripture', 'Film', 'Audio Scripture', 'Bible video', 'Bible Stories', 'Gospel presentation', 'Gospel audio', 'Discipleship training']
const first = (rows, type) => rows.find((r) => r.type === type) || null
@ -144,7 +104,8 @@ const rows = doxa.rows.map((g) => {
const isSign = c?.sg === 1 || /sign language/i.test(g.language || '') || /^Deaf /i.test(g.name || '')
const br = !isSign && g.iso639_3 ? bridges[`${g.iso639_3}|${g.iso3}`] : null
/* Only where the curated bridge has nothing to say, and only for a group with nothing. */
const cand = !isSign && !br && access === 'nothing found' ? candidateFor(g.iso3, g.iso639_3) : null
const cand = !isSign && !br && access === 'nothing found'
? (bridgeCandidates[`${g.iso639_3}|${g.iso3}`] || null) : null
return {
people_group: g.name,
@ -172,9 +133,9 @@ const rows = doxa.rows.map((g) => {
bridge_has_scripture: br ? !!br.s : false,
bridge_has_film: br ? !!br.f : false,
bridge_candidate: cand ? cand.l : '',
bridge_candidate_iso: cand ? cand.code : '',
bridge_candidate_iso: cand ? cand.c : '',
bridge_candidate_groups: cand ? cand.n : 0,
bridge_candidate_pct_of_country: cand ? cand.share : 0,
bridge_candidate_pct_of_country: cand ? cand.pct : 0,
pray_url: g.pray_url,
people_praying: g.people_praying,
placed: g.mapped,

View file

@ -739,8 +739,65 @@ for (const p of slim) {
])
}
/**
* ═══ THE WEAKER COUSIN OF A BRIDGE, COMPUTED HERE SO BOTH SURFACES READ ONE SOURCE ══════
*
* `bridges` above comes from the curated gaps-and-opportunities table and covers 168 of
* Doxa's 369 groups with nothing. For the rest a CANDIDATE is computable straight from this
* index: the largest language in their country that carries both Scripture and the film.
*
* It lives here rather than in the access-table builder because the map sheet and the table
* must not disagree about what a group's next step is — the same rule that put the tier in
* one place. The access table used to compute this for itself, which meant the table offered
* 107 groups a candidate while the map showed them nothing at all.
*
* NO STRENGTH IS ATTACHED, on purpose. Ranking needs "is this a lingua franca" and neither
* proxy survives: by group count Oman picks Swahili for an Arabic-speaking people, and by
* population share a 10% floor refuses Urdu in Pakistan. So the candidate ships with its
* group count and its share of the country's pinned population, and the reader judges.
*
* NEVER FOR A SIGN LANGUAGE. A spoken bridge is not a bridge for a Deaf community — the same
* reason GRN is skipped for them above.
*/
const bridgeCandidates = {}
{
const pool = {}
const cpop = {}
for (const p of pins) {
const iso3 = p[3], code = p[4], pop = p[5] || 0
if (!iso3 || !code) continue
const c = codes[code]
if (!c) continue
cpop[iso3] = (cpop[iso3] || 0) + pop
const e = (pool[iso3] ||= {})
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.f = slot.f || !!c.f
}
for (const p of pins) {
const iso3 = p[3], code = p[4]
if (!iso3 || !code || p[6] !== 0) continue
if (codes[code] && codes[code].sg === 1) continue
const key = `${code}|${iso3}`
if (bridges[key] || bridgeCandidates[key]) continue
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]
if (!best) continue
const tot = cpop[iso3] || 0
bridgeCandidates[key] = {
l: best[1].l, c: best[0], n: best[1].n,
pct: tot ? Math.round((1000 * best[1].pop) / tot) / 10 : 0,
}
}
}
const out = {
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.',
generated: new Date().toISOString().slice(0, 10),
counts: { pins: pins.length, joined, withUrl, liftedByD43, codes: Object.keys(codes).length, droppedNoCoord: noCoord },
@ -769,6 +826,7 @@ console.log(`arclight codes ${Object.keys(arc).length} (${arcOnly} new, ${upgr
console.log(`share urls ${Object.keys(share).length}`)
console.log(`fcbh codes ${audioCodes} (${audioNew} new codes)`)
console.log(`gospel tracts ${gospelCodes} (Cru/GodTools presentations; depth only, tier UNCHANGED)`)
console.log(`bridge candidates ${Object.keys(bridgeCandidates).length} (computed here so the map sheet and the access table cannot disagree)`)
console.log(`in-country bridge ${bridgeRows} (groups with NOTHING of their own; a widely-spoken language around them has something — never counted as theirs)`)
console.log(`gospel audio ${grnCodes} (${grnNew} new codes; GRN via 5fish — heard, not read; ${grnSignSkipped} sign language(s) skipped)`)
console.log(`scripture earth ${seCodes} (${seNew} new codes; SIL partners — Scripture in their own language)`)

View file

@ -1275,6 +1275,103 @@ function allLists() {
...(clusters.uncovered_lists || [])];
}
// ── BATCH SELECTION MAKES A PACKET OR A POSTER ──────────────────────────────
// Driver: the batch tool is "super buggy", and recolouring regions is not what is wanted —
// "the batch selection tool should let you create your own poster or prayer packet based on
// your own selection." So the selection is an INPUT to the two artefacts this app already
// builds, and neither of these grows a third pipeline: both end in printPrayerPacket /
// packetPrints.printPoster, the same calls the legend and the poster wall make.
const batchBusy = ref(false);
// The zones behind the current selection. selectedTerritories holds 'zone:<id>' keys, and the
// list objects carry the selectors the packet needs; resolve back to those rather than
// re-deriving membership from the map, which would be a second source of truth.
function selectedLists() {
const want = new Set(selectedTerritories.value
.filter((t) => t.kind === 'zone')
.map((t) => String(t.key).replace(/^zone:/, '')));
return allLists().filter((l) => want.has(l.id));
}
// A SYNTHETIC LIST standing for the whole selection, so every existing action works unchanged.
// ⚠ ITS SELECTOR IS A pg_slugs UNION, NOT A LIST OF SELECTORS. 12 of the 27 lists already use
// pg_slugs and the evaluator THROWS on that type, so the selection is resolved to concrete slugs
// HERE — once, against one fetch of the people-groups list — and handed on as the type every
// consumer already handles. Resolving per zone would refetch the whole catalogue per zone and
// still leave the caller to merge.
async function selectionAsList() {
const lists = selectedLists();
if (!lists.length) throw new Error('nothing selected');
if (!API_BASE) throw new Error('no API configured');
const fields = 'slug,name,country_code,population,religion,image_url';
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`);
const records = normalizeRecords(extractRecords(await resp.json()));
const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set();
for (const l of lists) {
if (l.selector && l.selector.type === 'pg_slugs') {
for (const s of (l.selector.values || [])) slugs.add(s);
} else {
for (const s of evaluate(l.selector, records)) slugs.add(s);
}
}
// The label is what the reader sees on the cover and in the print dialog's title. Name the
// territories they actually chose rather than inventing a title for their selection.
const names = lists.map((l) => l.label || l.id);
const label = names.length <= 3
? names.join(' + ')
: `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
return { id: 'selection', label, count: slugs.size,
selector: { type: 'pg_slugs', values: [...slugs] } };
}
async function makeSelectionPacket() {
if (batchBusy.value) return;
batchBusy.value = true;
try {
const sel = await selectionAsList();
// The packet's map page is per-zone artwork; a multi-zone selection has no single poster to
// lift it from, so it prints a cover WITHOUT the map rather than one zone's map standing in
// for all of them. packetMapSvg already treats a miss as fine.
const [{ createPacketRenderer }, strings] = await Promise.all([
import('@map/packet/packet-render.js'),
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
const lists = selectedLists();
const mapSvg = lists.length === 1 ? await packetMapSvg(lists[0].id, R) : '';
await packetPrints.printPacket(sel, { mapSvg });
} catch (e) {
status.value = `Selection packet failed: ${String(e?.message || e).slice(0, 90)}`;
setTimeout(() => { if (status.value.startsWith('Selection packet failed')) status.value = ''; }, 4000);
} finally { batchBusy.value = false; }
}
async function makeSelectionPoster() {
if (batchBusy.value) return;
batchBusy.value = true;
try {
const lists = selectedLists();
if (!lists.length) throw new Error('nothing selected');
// ⚠ A POSTER IS PRE-RENDERED PER ZONE — there is no artwork for an arbitrary union, and
// generating one would be the new pipeline the brief rules out. With one zone selected this
// is exactly the wall's poster; with several, say so plainly instead of silently printing
// the first zone's poster as though it covered the selection.
if (lists.length > 1) {
status.value = `Poster prints one territory at a time — ${lists.length} selected. `
+ 'Pick one, or use the prayer packet for the whole selection.';
setTimeout(() => { if (status.value.startsWith('Poster prints one')) status.value = ''; }, 5000);
return;
}
await packetPrints.printPoster(lists[0]);
} catch (e) {
status.value = `Selection poster failed: ${String(e?.message || e).slice(0, 90)}`;
setTimeout(() => { if (status.value.startsWith('Selection poster failed')) status.value = ''; }, 4000);
} finally { batchBusy.value = false; }
}
// The packet follows the app's locale so a reader gets it in their own language;
// packet-strings.json falls back PER KEY, so a half-finished translation renders
// its finished keys rather than blanking.
@ -2135,25 +2232,18 @@ onBeforeUnmount(() => {
</span>
</div>
<div v-if="selCount" class="upg-hud-names">{{ selectedTerritories.map(t => t.name).join(', ') }}</div>
<!-- R1.1 (g): color picker — 8 colorblind-aware swatches + custom hex. Picking
preview-recolors the selected territories (upg-sel-fill repaints); the
clipboard payload then ends with the chosen "· #hex" (h). The gate clicks
.upg-swatch[data-color] and reads __UPG_SELECT__.color. -->
<div v-if="selCount" class="upg-hud-swatches">
<button
v-for="c in SWATCHES" :key="c"
class="upg-swatch" :class="{ active: selectedColor === c }"
:data-color="c" :style="{ background: c }" :title="c"
@click="setSelectionColor(c)"
></button>
<input
v-model="hexDraft" class="upg-hex" placeholder="#hex"
spellcheck="false" @change="onHexInput" @keyup.enter="onHexInput"
/>
</div>
<!-- ★ THE SELECTION MAKES SOMETHING. Driver: the batch tool is "super buggy", and letting
you change the colour of regions is NOT what is wanted — "the batch selection tool
should let you create your own poster or prayer packet based on your own selection."
So the colour swatches and the hex input are GONE, and the actions that remain BUILD
the two artefacts this app already knows how to make, from exactly what was picked.
These reuse printPrayerPacket / printPoster rather than growing a third pipeline. -->
<div v-if="selCount" class="upg-hud-actions">
<button class="upg-hud-btn copy" @click="copySelection">Copy selection</button>
<button class="upg-hud-btn clear" @click="clearSelection">Clear</button>
<button class="upg-hud-btn make" :disabled="batchBusy"
@click="makeSelectionPacket">{{ t('buttons.prayerList') }}</button>
<button class="upg-hud-btn make" :disabled="batchBusy"
@click="makeSelectionPoster">{{ t('buttons.posterMap') }}</button>
<button class="upg-hud-btn clear" @click="clearSelection">{{ t('buttons.clearSelection') }}</button>
</div>
<div v-if="selCount" class="upg-hud-flash">{{ copyFlash }}</div>
</div>

View file

@ -18,8 +18,9 @@
*
* The `/` keyboard shortcut from the original PplrGeocoder is preserved.
*/
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
import { ensureGeocoder } from '../utils/pplrData.js'
import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue'
import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
@ -44,6 +45,10 @@ const wrapEl = ref(null)
const slotEl = ref(null)
let geocoder = null
// Same engine the map uses. Under maplibre (the keyless gallery) search runs on MaplibreGeocoder
// over this map's own local index only, so it makes no Mapbox request at all.
const provider = resolveMapProvider(inject('profileConfig', null))
const isMaplibre = provider === 'maplibre'
// Build the localGeocoder callback. Mapbox-gl-geocoder calls this on every
// keystroke; we return Carmen-GeoJSON-shaped features for matching local
@ -116,7 +121,7 @@ function renderSuggestion(item) {
// Shadow DOM is sealed from regular-document styles, so we must inject the same
// stylesheet into each shadow root that hosts a geocoder. Idempotent — checks
// if already present.
const GEOCODER_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'
const GEOCODER_CSS_URL = (GEOCODER[provider] || GEOCODER.mapbox).css
function ensureGeocoderCss(root) {
if (!root || root === document) return
if (root.querySelector('link[data-pplr-geocoder-css]')) return
@ -134,6 +139,22 @@ function setup() {
// from mounting. Per user 2026-05-02: map went completely blank when
// the geocoder threw on shadow-DOM addTo().
try {
if (isMaplibre) {
if (!window.MaplibreGeocoder || !window.maplibregl) {
console.warn('[MapboxGeocoder] maplibre-gl-geocoder or maplibre-gl not on window — search disabled')
return
}
// Local-only: the PG/family index this map already holds. No place search, no key.
geocoder = new window.MaplibreGeocoder({ forwardGeocode: async () => ({ type: 'FeatureCollection', features: [] }) }, {
maplibregl: window.maplibregl,
placeholder: props.placeholder,
localGeocoder: buildLocalGeocoder(),
localGeocoderOnly: true,
minLength: props.minChars,
showResultsWhileTyping: true,
render: renderSuggestion,
})
} else {
if (!window.MapboxGeocoder || !window.mapboxgl) {
console.warn('[MapboxGeocoder] mapbox-gl-geocoder or mapbox-gl not on window — check CDN <script> tags')
return
@ -152,6 +173,7 @@ function setup() {
limit: 5,
render: renderSuggestion,
})
}
geocoder.on('result', (ev) => {
const f = ev.result
@ -222,7 +244,7 @@ function onGlobalKey(e) {
const isCmdK = (e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')
if (isSlash || isCmdK) {
e.preventDefault()
const input = slotEl.value?.querySelector('input.mapboxgl-ctrl-geocoder--input')
const input = slotEl.value?.querySelector('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')
input?.focus()
input?.select?.()
}
@ -230,7 +252,7 @@ function onGlobalKey(e) {
onMounted(() => {
// setup() swallows its own errors; a failed script load leaves the map working without search.
ensureGeocoder().then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
ensureGeocoder(provider).then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
document.addEventListener('keydown', onGlobalKey)
})
onBeforeUnmount(() => {

View file

@ -21,6 +21,22 @@
* (one search per map cluster, per QA "unified geocoder" decision).
*/
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
// Accessible names from the shared catalog (legend.a11y.*, aria.*), the keys the library legend
// uses, so they are translated in all eleven locales. i18n is OPTIONAL: on a host without it, the
// English fallback below is used, never a raw key.
const A11Y_EN = {
'legend.a11y.expandRow': 'Expand {label}', 'legend.a11y.collapseRow': 'Collapse {label}',
'legend.a11y.clearSelection': 'Clear selection',
'aria.expandLegend': 'Expand legend', 'aria.collapseLegend': 'Collapse legend',
}
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
try {
const i18n = useI18n()
ta = (key, p) => { const v = i18n.t(key, p || {}); return v === key ? fillA11y(A11Y_EN[key] ?? key, p) : v }
} catch (_) { /* no i18n on this host */ }
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
@ -373,6 +389,8 @@ function rowClasses(row) {
class="stl-collapse-btn"
:class="{ closed: !panelOpen }"
:title="panelOpen ? 'Collapse legend' : 'Expand legend'"
:aria-label="panelOpen ? ta('aria.collapseLegend') : ta('aria.expandLegend')"
:aria-expanded="String(panelOpen)"
@click="panelOpen = !panelOpen">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
@ -442,6 +460,8 @@ function rowClasses(row) {
class="stl-trunk" :class="`stl-trunk-${seg}`"></span>
<button v-if="row.node.children?.length"
class="stl-caret" :class="{ open: expandedIds.has(row.node.id) }"
:aria-expanded="expandedIds.has(row.node.id) ? 'true' : 'false'"
:aria-label="ta(expandedIds.has(row.node.id) ? 'legend.a11y.collapseRow' : 'legend.a11y.expandRow', { label: row.node.label })"
@click.stop="toggleExpand(row.node.id, $event)">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.6"
@ -456,7 +476,7 @@ function rowClasses(row) {
<span v-if="hasPop" class="stl-num">{{ fmtPop(row.node.pop) }}</span>
<span class="stl-x-slot">
<button v-if="selectedId === row.node.id"
class="stl-x-chip" title="Clear selection"
class="stl-x-chip" :title="ta('legend.a11y.clearSelection')" :aria-label="ta('legend.a11y.clearSelection')"
@click.stop="deselect($event)">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@ -485,7 +505,9 @@ function rowClasses(row) {
<button v-if="!panelOpen"
class="stl-reopen"
:data-theme="instance?.theme.value || 'dark'"
title="Expand legend"
:title="ta('aria.expandLegend')"
:aria-label="ta('aria.expandLegend')"
aria-expanded="false"
@click="panelOpen = true">
<svg class="stl-reopen-icon" width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 4h8M2 6h8M2 8h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>

View file

@ -33,17 +33,58 @@ export function loadNames() {
return namesPromise
}
// mapbox-gl-geocoder is a page global too (the original page carried a <script> tag). Load it
// once, on first need, so the search box works wherever the bundle is embedded.
const GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
let geocoderPromise = null
export function ensureGeocoder() {
if (typeof window === 'undefined' || window.MapboxGeocoder) return Promise.resolve()
if (!geocoderPromise) geocoderPromise = new Promise((res, rej) => {
// The geocoder is a page global too (the original page carried a <script> tag). Load it once, on
// first need, and pick it BY ENGINE, as library/components/map-controls/GeocoderComponent.vue does:
// the Mapbox plugin under provider=mapbox, MapLibre's under provider=maplibre. A hard-coded Mapbox
// script made this the only request to api.mapbox.com in the keyless (maplibre) gallery.
export const GEOCODER = {
mapbox: { js: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js',
css: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
global: 'MapboxGeocoder' },
maplibre: { js: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js',
css: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
global: 'MaplibreGeocoder' },
}
const geocoderPromises = {}
export function ensureGeocoder(provider = 'mapbox') {
const g = GEOCODER[provider] || GEOCODER.mapbox
if (typeof window === 'undefined' || window[g.global]) return Promise.resolve()
if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => {
const s = document.createElement('script')
s.src = GEOCODER_JS
s.onload = () => res(); s.onerror = () => { geocoderPromise = null; rej(new Error('geocoder failed to load')) }
s.src = g.js
s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) }
document.head.appendChild(s)
})
return geocoderPromise
return geocoderPromises[provider]
}
// The engine namespace that built THIS map (Popup, Marker, NavigationControl…). The views were
// written against the Mapbox global; under provider=maplibre that global does not exist and a
// `new mapboxgl.Popup` throws on the first pin click.
export function glFor(map) {
if (typeof window === 'undefined') return undefined
const ml = window.maplibregl
if (ml && map && ml.Map && map instanceof ml.Map) return ml
return window.mapboxgl || ml
}
// 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 }
export function exposeMap(map) {
if (typeof window === 'undefined' || !map) return
let loaded = false
map.once('load', () => { loaded = true })
window.__pplrMap = () => {
let layers = []
try { layers = map.getStyle()?.layers || [] } catch (_) {}
return {
engine: window.maplibregl && window.maplibregl.Map && map instanceof window.maplibregl.Map ? 'maplibre' : 'mapbox',
// 'load' may already have fired before this listener was attached; a loaded style says so too
loaded: loaded || !!(map.isStyleLoaded && map.isStyleLoaded()),
styleLayers: layers.length,
// sources this bundle added (not the basemap's): proof the view's own data reached the map
dataSources: (() => { try { return Object.keys(map.getStyle()?.sources || {}).filter((k) => !/^(composite|mapbox|openmaptiles|natural_earth|ne2_shaded|maplibre)/i.test(k)).length } catch (_) { return 0 } })(),
}
}
}

View file

@ -29,7 +29,7 @@ export const LOCALE_NAMES = {
}
// ── PPLR FeatureServer — country polygons (live, not bundled) ────────────────
import { dataUrl } from './pplrData.js'
import { dataUrl, glFor } from './pplrData.js'
export const COUNTRY_FS_URL = 'https://services2.arcgis.com/S4ydGgujXcif36k3/arcgis/rest/services/aCountry/FeatureServer/0/query'
export const COUNTRY_FS_FIELDS = ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp']
@ -282,7 +282,7 @@ export function makePinIndicator() {
if (!map || !lngLat) return
ensureEl(color)
if (!marker) {
marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
marker = new (glFor(map)).Marker({ element: el, anchor: 'bottom' })
}
marker.setLngLat(lngLat).addTo(map)
},

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
*
@ -31,7 +31,7 @@ function reload() { window.location.reload() }
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6
})
@ -50,6 +50,7 @@ async function streamCoverage() {
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
map.value.on('load', async () => {
try {
status.value = 'Loading per-country coverage…'
@ -105,7 +106,7 @@ onMounted(async () => {
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>
</div>`
new mapboxgl.Popup({ maxWidth: '300px' })
new (glFor(map.value)).Popup({ maxWidth: '300px' })
.setLngLat(e.lngLat).setHTML(html).addTo(map.value)
})
@ -113,7 +114,7 @@ onMounted(async () => {
const { pinLayerId } = await addPGPoints(map.value, { circleColor: '#fef3c7' })
map.value.on('click', pinLayerId, e => {
const f = e.features[0]
new mapboxgl.Popup({ maxWidth: '320px' })
new (glFor(map.value)).Popup({ maxWidth: '320px' })
.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)`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
*
@ -12,6 +12,8 @@ import { dataUrl } from '../utils/pplrData.js'
* Each depth level = one tab. Filter expressions are pre-built into each tree node.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
@ -23,6 +25,8 @@ import { createPplrInstance, provideInstance } from '../composables/usePplrInsta
const pplrInstance = createPplrInstance('language-families')
provideInstance(pplrInstance)
// Registry names resolve per engine, so the Dark/Satellite/Light switch works under MapLibre too.
const mapProvider = resolveMapProvider(inject('profileConfig', null))
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens .profile so chrome stays visible
@ -32,7 +36,7 @@ const PIN_LAYER = 'lf-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — no globe→mercator reproject flash
@ -100,9 +104,9 @@ const projection = ref('mercator') // default mercator per user spec
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ key: 'dark', label: 'Dark', url: 'ink' },
{ key: 'satellite', label: 'Satellite', url: 'hybrid' },
{ key: 'light', label: 'Light', url: 'paper' },
]
const pgData = ref(null)
@ -370,7 +374,7 @@ function setBasemap(key) {
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false suppresses Mapbox's "Unimplemented: setSprite" warning between
// dark-v11 and light-v11. We re-add layers in setupLayers anyway.
map.value.setStyle(style, { diff: false })
map.value.setStyle(resolveNamedStyle(style, mapProvider).style, { diff: false })
}
function toggleTheme() {
@ -422,8 +426,9 @@ function popupHtml(p) {
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {

View file

@ -5,6 +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 { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -26,13 +27,14 @@ const DATA_SOURCES = [
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6
})
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
map.value.on('load', async () => {
try {
status.value = 'Adding country polygons…'
@ -42,7 +44,7 @@ onMounted(async () => {
map.value.on('click', pinLayerId, e => {
const f = e.features[0]
new mapboxgl.Popup({ maxWidth: '340px' })
new (glFor(map.value)).Popup({ maxWidth: '340px' })
.setLngLat(f.geometry.coordinates)
.setHTML(popupForPG(f.properties))
.addTo(map.value)

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-religion.vue — PG pins colored by primary religion.
*
@ -8,6 +8,8 @@ import { dataUrl } from '../utils/pplrData.js'
* Uses the `rg` field pre-computed in pg-points.geojson.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
@ -19,6 +21,8 @@ import { createPplrInstance, provideInstance } from '../composables/usePplrInsta
const pplrInstance = createPplrInstance('religion')
provideInstance(pplrInstance)
// Registry names resolve per engine, so the Dark/Satellite/Light switch works under MapLibre too.
const mapProvider = resolveMapProvider(inject('profileConfig', null))
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens .profile so chrome stays visible
@ -30,9 +34,9 @@ const projection = ref('mercator')
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ key: 'dark', label: 'Dark', url: 'ink' },
{ key: 'satellite', label: 'Satellite', url: 'hybrid' },
{ key: 'light', label: 'Light', url: 'paper' },
]
const pgData = ref(null)
const dataReady = ref(false)
@ -57,7 +61,7 @@ function setBasemap(key) {
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false suppresses Mapbox's "Unimplemented: setSprite" warning between
// dark-v11 and light-v11. We re-add layers in setupLayers anyway.
map.value.setStyle(style, { diff: false })
map.value.setStyle(resolveNamedStyle(style, mapProvider).style, { diff: false })
}
function setupLayers() {
@ -108,7 +112,7 @@ const DATA_SOURCES = [
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — no globe→mercator reproject flash
@ -240,8 +244,9 @@ function popupHtml(p) {
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-rop-tree.vue — ROP hierarchy: Affinity Bloc → Cluster → People Group → PGIC.
*
@ -8,6 +8,8 @@ import { dataUrl } from '../utils/pplrData.js'
* Pins are colored by Affinity Bloc. Each level sorts by pin count desc.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, shallowRef, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
@ -20,6 +22,8 @@ import { createPplrInstance, provideInstance } from '../composables/usePplrInsta
const pplrInstance = createPplrInstance('rop-tree')
provideInstance(pplrInstance)
// Registry names resolve per engine, so the Dark/Satellite/Light switch works under MapLibre too.
const mapProvider = resolveMapProvider(inject('profileConfig', null))
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens the .profile so chrome stays visible
@ -29,7 +33,7 @@ const PIN_LAYER = 'rop-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — see useMapInstance projection arg
@ -78,9 +82,9 @@ const projection = ref('mercator') // default mercator per user spec
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ key: 'dark', label: 'Dark', url: 'ink' },
{ key: 'satellite', label: 'Satellite', url: 'hybrid' },
{ key: 'light', label: 'Light', url: 'paper' },
]
// ── ROP tree (4 generations: Affinity Bloc → People Cluster → ROP25 → ROP3byCountry)
@ -309,7 +313,7 @@ function setBasemap(key) {
// "Unimplemented: setSprite" warning whenever sprite sheets differ between
// dark-v11 and light-v11. We re-add all our layers in setupLayers anyway,
// so a full rebuild is correct and avoids the noisy warning.
map.value.setStyle(style, { diff: false })
map.value.setStyle(resolveNamedStyle(style, mapProvider).style, { diff: false })
}
// Quick light/dark toggle — flips between dark-v11 and light-v11 directly
@ -389,12 +393,13 @@ function popupHtml(p) {
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
// Navigation: zoom +/- (Mapbox built-in) + custom fullscreen toggle.
// See FullscreenToggleControl in pplrMap.js for why we don't use
// mapboxgl.FullscreenControl with a non-map container.
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}

View file

@ -13,6 +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 { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -34,13 +35,14 @@ function reload() { window.location.reload() }
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [50, 22],
zoom: 1.8
})
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
map.value.on('load', async () => {
try {
status.value = 'Loading PG data…'
@ -126,7 +128,7 @@ onMounted(async () => {
${missingIds.join(', ')} ID${missingIds.length > 1 ? 's' : ''} not yet cross-mapped in PPLR — empty rows are a data gap, not an error.
</td></tr>`
: ''
new mapboxgl.Popup({ maxWidth: '360px' })
new (glFor(map.value)).Popup({ maxWidth: '360px' })
.setLngLat(f.geometry.coordinates)
.setHTML(popupForPG(p, rosettaRow + missingNote))
.addTo(map.value)

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-translations.vue — PG name in all 17 locales.
*
@ -26,7 +26,7 @@ const PIN_LAYER = 'trans-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [70, 22],
zoom: 2,
projection: 'mercator',
@ -56,8 +56,9 @@ const DATA_SOURCES = [
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {

View file

@ -53,6 +53,7 @@ import { openPrintFrame } from '@map/composables/printInPlace.js'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
import { translate, LOCALES } from './src/strings.js'
// ─── The host's attributes stay on the host ───────────────────────────────────
@ -371,21 +372,9 @@ function staticSnapshotUrl(coords) {
// is what fills the page under the title band and above the charge/corners/credit lines.
const PRINT_W = 1200, PRINT_H = 1344, PRINT_PIXEL_RATIO = 2
// Street and place labels in the reader's language where OSM has it, else the local name.
// OpenFreeMap's styles label with name:latin (+ name:nonlatin); `name:<lang>` is the OSM tag for
// a translated name. English keeps the style's own expression untouched.
function localizeBasemapLabels(m, lang) {
if (!m || !lang || lang === 'en') return
try {
for (const layer of m.getStyle().layers || []) {
if (layer.type !== 'symbol' || !layer.layout || !layer.layout['text-field']) continue
m.setLayoutProperty(layer.id, 'text-field', ['coalesce', ['get', 'name:' + lang], ['get', 'name:latin'], ['get', 'name']])
}
} catch (_) { /* labels are a nicety; the map is still correct in the style's own language */ }
}
// Street and place labels in the reader's language: the shared library/maps/localize-labels.js
// (the same behaviour every map now has; the print map is built raw, so it calls it directly).
// Render ONE zone as a print-resolution PNG data URL in a temporary, off-screen MapLibre map.
// Returns null when there is no engine (no WebGL) — the sheet then prints without a map.
// Arabic (and every RTL script) needs the RTL text plugin, and the print map must WAIT for it:
// it captures one frame, and a lazily-loaded plugin can still be in flight. The shared helper
// (library/maps/rtl-text.js) owns the URL and the engine logic; useMapInstance installs it lazily

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,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-rsq5CBTh6pLStfLv/4PHCQ4uBEQvgFIUIn459pPy1dlA8jIBrcQtFK5sKY1t2gHg" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-tLAMFSmOXgKsuumAYlSOGxWarmOzELfe4FfQqpvfBimiWBm23Ajinbv0qsxRZupf" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -1431,12 +1431,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/alk",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -1728,12 +1728,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_strength": "strong",
"bridge_groups_speaking": 25,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -3708,12 +3708,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_strength": "strong",
"bridge_groups_speaking": 65,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -12288,12 +12288,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_strength": "strong",
"bridge_groups_speaking": 25,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -19581,12 +19581,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_strength": "strong",
"bridge_groups_speaking": 65,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -33771,12 +33771,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/ngt",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -38292,12 +38292,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nos",
"training_url": "",
"bridge_language": "Chinese, Mandarin",
"bridge_iso639_3": "cmn",
"bridge_strength": "strong",
"bridge_groups_speaking": 25,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -38556,12 +38556,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/lbo",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -47697,12 +47697,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/jle",
"training_url": "",
"bridge_language": "Arabic, Sudanese",
"bridge_iso639_3": "apd",
"bridge_strength": "strong",
"bridge_groups_speaking": 65,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,
@ -48555,12 +48555,12 @@
"stories_url": "",
"gospel_audio_url": "https://fivefish.org/nev",
"training_url": "",
"bridge_language": "Akha",
"bridge_iso639_3": "ahk",
"bridge_strength": "strong",
"bridge_groups_speaking": 12,
"bridge_has_scripture": true,
"bridge_has_film": true,
"bridge_language": "",
"bridge_iso639_3": "",
"bridge_strength": "",
"bridge_groups_speaking": 0,
"bridge_has_scripture": false,
"bridge_has_film": false,
"bridge_candidate": "",
"bridge_candidate_iso": "",
"bridge_candidate_groups": 0,

View file

@ -4991,7 +4991,7 @@
"religion": "Islam - Sunni",
"population": 5500,
"people_praying": 0,
"people_committed": 0,
"people_committed": 1,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -16751,7 +16751,7 @@
"religion": "Islam - Sunni",
"population": 57500,
"people_praying": 2,
"people_committed": 0,
"people_committed": 1,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -34055,7 +34055,7 @@
"religion": "Hinduism",
"population": 700,
"people_praying": 1,
"people_committed": 2,
"people_committed": 3,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -34319,7 +34319,7 @@
"religion": "Islam - Folk",
"population": 12000,
"people_praying": 1,
"people_committed": 4,
"people_committed": 3,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",
@ -43463,7 +43463,7 @@
"religion": "Islam - Sunni",
"population": 23000,
"people_praying": 0,
"people_committed": 1,
"people_committed": 2,
"adopted_by_churches": 0,
"mapped": true,
"matched_by": "doxa detail",

View file

@ -1,7 +1,7 @@
{
"meta": {
"title": "Gospel resource link verification",
"generated": "2026-09-25T03:43:08.467Z",
"generated": "2026-09-25T09:56:59.860Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": {
@ -96,12 +96,12 @@
]
},
"summary": {
"records": 8516,
"checkedThisPass": 3,
"records": 8524,
"checkedThisPass": 8,
"carriedForward": 74,
"pruned": 0,
"broken": 74,
"verified": 8437,
"verified": 8445,
"unknown": 5
},
"byHost": {
@ -142,7 +142,7 @@
"verified": 921
},
"fivefish.org": {
"verified": 1022
"verified": 1030
}
}
},
@ -42726,6 +42726,46 @@
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/the-savior.html/arabic-najdi.html)",
"at": "2026-09-25T03:43:07.944Z"
},
"https://fivefish.org/lma": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/12954?language=Limba%2C%20East (the site answers 200 for any path)",
"at": "2026-09-25T09:55:49.572Z"
},
"https://fivefish.org/nev": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/23092?language=Nyaheun (the site answers 200 for any path)",
"at": "2026-09-25T09:55:59.632Z"
},
"https://fivefish.org/ngt": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/23045?language=Kriang (the site answers 200 for any path)",
"at": "2026-09-25T09:56:09.574Z"
},
"https://fivefish.org/alk": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/23050?language=Alak (the site answers 200 for any path)",
"at": "2026-09-25T09:56:19.687Z"
},
"https://fivefish.org/nos": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/4976?language=Nisu%2C%20Eastern (the site answers 200 for any path)",
"at": "2026-09-25T09:56:29.456Z"
},
"https://fivefish.org/lbo": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/12797?language=Laven (the site answers 200 for any path)",
"at": "2026-09-25T09:56:39.531Z"
},
"https://fivefish.org/jle": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/14912?language=Ngile (the site answers 200 for any path)",
"at": "2026-09-25T09:56:49.486Z"
},
"https://fivefish.org/psi": {
"state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)",
"at": "2026-09-25T09:56:59.503Z"
}
}
}

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-tWFKP5lpuEIney1vkcEFXPPhlr/fgu3siY7p7J25oMxBOedxS/MJjwKD+FMKnsA4" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-mbN7vJ4SwlPjwow485s6WcZtjgHfRL9c/cEScZGYM4iZVwTX3imUfgz/lNX7UKJe" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-tWFKP5lpuEIney1vkcEFXPPhlr/fgu3siY7p7J25oMxBOedxS/MJjwKD+FMKnsA4" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-mbN7vJ4SwlPjwow485s6WcZtjgHfRL9c/cEScZGYM4iZVwTX3imUfgz/lNX7UKJe" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-tWFKP5lpuEIney1vkcEFXPPhlr/fgu3siY7p7J25oMxBOedxS/MJjwKD+FMKnsA4" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-mbN7vJ4SwlPjwow485s6WcZtjgHfRL9c/cEScZGYM4iZVwTX3imUfgz/lNX7UKJe" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-P1ECIwoWVV0RxJB1K0ASPPCojtDw7ygyE1Cz3Qy1GeVP0kAHnzR8gzguOLxMxhQP" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Q11zxYDmXp/3KoxkMIRaPGn1KWAer2kQrJTpE/YiwUeasLLy4UNocmXO4qByMX+D" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-P1ECIwoWVV0RxJB1K0ASPPCojtDw7ygyE1Cz3Qy1GeVP0kAHnzR8gzguOLxMxhQP" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Q11zxYDmXp/3KoxkMIRaPGn1KWAer2kQrJTpE/YiwUeasLLy4UNocmXO4qByMX+D" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,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-P1ECIwoWVV0RxJB1K0ASPPCojtDw7ygyE1Cz3Qy1GeVP0kAHnzR8gzguOLxMxhQP" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-Q11zxYDmXp/3KoxkMIRaPGn1KWAer2kQrJTpE/YiwUeasLLy4UNocmXO4qByMX+D" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,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-h5/KFpBTK/gix8IyOdm4xLGRYGFGeuqXiuK1u1t+HzjpVUMda91x21fC/NmrWDs3" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-OiUABqAB3sINj95nEswpAJ2nJBd9bwrMR2bezKyN6WpxHw9/NvUmYMe8o0i1muWV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-h5/KFpBTK/gix8IyOdm4xLGRYGFGeuqXiuK1u1t+HzjpVUMda91x21fC/NmrWDs3" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-OiUABqAB3sINj95nEswpAJ2nJBd9bwrMR2bezKyN6WpxHw9/NvUmYMe8o0i1muWV" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,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-bHZzOpmlz2zEoz+T3Sb3u/qMZgxnOZGsqQUSycHCK+oZql/afP15VSV0QbEwNuOS" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-QDnthNH0t4oJYQcYunMxIb9DUGBQRAUm+TPEg2RWvsrkJ4CYrbAlzbyCWoTqOXB7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-bHZzOpmlz2zEoz+T3Sb3u/qMZgxnOZGsqQUSycHCK+oZql/afP15VSV0QbEwNuOS" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-QDnthNH0t4oJYQcYunMxIb9DUGBQRAUm+TPEg2RWvsrkJ4CYrbAlzbyCWoTqOXB7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-bHZzOpmlz2zEoz+T3Sb3u/qMZgxnOZGsqQUSycHCK+oZql/afP15VSV0QbEwNuOS" crossorigin="anonymous"></script>
<script src="../doxa-simple-map.js" integrity="sha384-QDnthNH0t4oJYQcYunMxIb9DUGBQRAUm+TPEg2RWvsrkJ4CYrbAlzbyCWoTqOXB7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-bHZzOpmlz2zEoz+T3Sb3u/qMZgxnOZGsqQUSycHCK+oZql/afP15VSV0QbEwNuOS" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-QDnthNH0t4oJYQcYunMxIb9DUGBQRAUm+TPEg2RWvsrkJ4CYrbAlzbyCWoTqOXB7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-SQD84HjvCJ8hyWy/4iHK/Ddo9KGh1JuilIxbn/hvTJuOA0rKVo4ZvJjcTEyur2rg" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-PEvIo9qJtlQUr7v+NdWX8BDcKxd5n6HrtzM7961RqfzHThKAs3GQ7xxUI5zGtY3L" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-SQD84HjvCJ8hyWy/4iHK/Ddo9KGh1JuilIxbn/hvTJuOA0rKVo4ZvJjcTEyur2rg" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-PEvIo9qJtlQUr7v+NdWX8BDcKxd5n6HrtzM7961RqfzHThKAs3GQ7xxUI5zGtY3L" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,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-SQD84HjvCJ8hyWy/4iHK/Ddo9KGh1JuilIxbn/hvTJuOA0rKVo4ZvJjcTEyur2rg" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-PEvIo9qJtlQUr7v+NdWX8BDcKxd5n6HrtzM7961RqfzHThKAs3GQ7xxUI5zGtY3L" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-SQD84HjvCJ8hyWy/4iHK/Ddo9KGh1JuilIxbn/hvTJuOA0rKVo4ZvJjcTEyur2rg" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-PEvIo9qJtlQUr7v+NdWX8BDcKxd5n6HrtzM7961RqfzHThKAs3GQ7xxUI5zGtY3L" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-SQD84HjvCJ8hyWy/4iHK/Ddo9KGh1JuilIxbn/hvTJuOA0rKVo4ZvJjcTEyur2rg" crossorigin="anonymous"></script>
<script src="../gospel-resources.js" integrity="sha384-PEvIo9qJtlQUr7v+NdWX8BDcKxd5n6HrtzM7961RqfzHThKAs3GQ7xxUI5zGtY3L" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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-wV3C24ByQTO9YmoAwC+/XTH++0E+XCv7L3L9zp8NW7aRqp/u7gKmXOJ94bsFWRrD" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-vqUN7kfMcv1vUvjQIzNxapDlwPSdtR8atXoIETM0hxoqOCKG+7FAPPltRsA4jukY" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -29,7 +29,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-wV3C24ByQTO9YmoAwC+/XTH++0E+XCv7L3L9zp8NW7aRqp/u7gKmXOJ94bsFWRrD" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-vqUN7kfMcv1vUvjQIzNxapDlwPSdtR8atXoIETM0hxoqOCKG+7FAPPltRsA4jukY" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -40,7 +40,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-wV3C24ByQTO9YmoAwC+/XTH++0E+XCv7L3L9zp8NW7aRqp/u7gKmXOJ94bsFWRrD" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-vqUN7kfMcv1vUvjQIzNxapDlwPSdtR8atXoIETM0hxoqOCKG+7FAPPltRsA4jukY" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
<script src="../pplr-data-maps.js" integrity="sha384-+ccGSOeasNwvFR0DkR7nAGvjxFFqVwj4TA73CTaNcjFQKIy6OF7cFhrao0nhGEHr" crossorigin="anonymous"></script>
<script src="../pplr-data-maps.js" integrity="sha384-+EOcMT3P42D39HGIjZBbJ6waYI2yJDhNDwNf+8wFruaHqEhdYYa36M+iLIT5fRiH" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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