Integrate main into bundler-2/work

# Conflicts:
#	public/dx-maps/big-picture/big-picture.js
#	public/dx-maps/big-picture/big-picture/big-picture/index.html
#	public/dx-maps/big-picture/big-picture/frontier/index.html
#	public/dx-maps/big-picture/big-picture/index.html
#	public/dx-maps/dashboard-plus-grid/dashboard-plus-grid.js
#	public/dx-maps/dashboard-plus-grid/dashboard-plus-grid/default/index.html
#	public/dx-maps/dashboard-plus-grid/dashboard-plus-grid/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/doxa-research-map/doxa-research-map.js
#	public/dx-maps/doxa-research-map/research-map/index.html
#	public/dx-maps/doxa-research-map/research-map/maplibre/index.html
#	public/dx-maps/manifest.json
#	public/dx-maps/research-dashboard/research-dashboard.js
#	public/dx-maps/research-dashboard/research-dashboard/index.html
This commit is contained in:
Doxa Maps 2026-09-26 06:43:22 +00:00
commit 3157cceed2
176 changed files with 3917 additions and 1075 deletions

View file

@ -0,0 +1 @@
{"id":"frontier-500k","label":"Project 700: frontier people groups of 500,000 or more","definition":"IMB Lostness Priority 0 and Population of at least 500,000","filter":{"lostness":[0],"minPopulation":500000},"sources":{"imb":"data/people-groups/people_groups.csv","doxa":"https://pray.doxa.life/api/people-groups/list (fetched 2026-09-25)"},"counts":{"imb":737,"doxa":71},"slugs":["agasa","aiyarakulu","akhdam","alawites-turkiye","arab-bedouin","avar-kunsakh","badhai-chhattisgarhi","bairagi-gujarati","baloch-eastern","baloch-western","bania-agarwal-telegu","bederia","bedouin-arabs-saudi-arabia","dar-hamid","deaf-burmese","deaf-congolese-democratic-republic-of-the-congo","deaf-iranians","deaf-pakistanis","digil-rahanweyn","gaaliin","gawamaa","gazan-arabs","guhayna","ha-li","hadrami-arabs","hasania","jains-hindi","jat-muslim","jula-burkina-faso","kawahia","kotokoli-togo","kummara-kumbara","kurd-northern","kuria","laki","lebanese-shia-arabs","lebanese-sunni-arabs","libyan-arabs","luwu","makhuwa-shirima","makua","meadow-mari","mishaba-laluo","moor","mwera","namasudra-assamese","northern-dong","northern-yemeni-arabs","nuer","pila","pingdi-yao","rizeiqat","rufaa","sanusi-bedouins","shiites","shina-pakistan","shirazi","sudra-das","sutradhar","tigre","tihama-yemeni-arabs","tondman","turkmen","west-bank-arabs","western-baloch-pakistan","white-moor","xiaoliangshan-nosu","yao","yazeed","yinuo-nosu","zigua"]}

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

@ -321,6 +321,22 @@ as before, and an embed that passes a token keeps getting Mapbox under `auto`.
---
## Embed under a strict Content-Security-Policy
No bundle needs `'unsafe-eval'` (translations compile without `eval`; `bun run check:no-eval`), and
every third-party script is pinned with an integrity hash (`library/constants/cdn.js`,
`bun run check:cdn-pins`). A page embedding the maps needs, in addition to its own sources:
| Directive | Add | Why |
|---|---|---|
| `script-src` | `https://cdn.jsdelivr.net` (and `https://api.mapbox.com` for Mapbox) | the map engine and the search geocoder |
| `style-src` | `'unsafe-inline'`, the same CDN hosts | each plugin styles its own shadow root with a `<style>` element |
| `worker-src` | `blob:` | the map engines run their tile workers from a blob URL |
| `connect-src` | the tile/style hosts you use (`https://tiles.openfreemap.org` for MapLibre, `https://*.mapbox.com` for Mapbox), your data API | tiles, styles, glyphs, data |
| `img-src` | `data:` `blob:` and the same tile hosts | sprites and raster tiles |
---
## Set the API + Mapbox token
Both the data backend and the Mapbox token are **runtime inputs**, never baked into a bundle.

View file

@ -6,7 +6,8 @@ This page lists each one: where it can come from (its **channels**), which chann
(**precedence**), and what happens when nothing sets it (**default**).
The set here is the one the header sets today: **engine, locale (with direction), Mapbox token and
token-service URL**. Whether anything else counts as universal is an open decision
token-service URL**, plus the **people-group list** (section 5), which every map must honour so the
same maps serve other projects' lists, not only DOXA's. Whether anything else counts as universal is an open decision
(DECIDE-UNIVERSAL-PROPS-SCOPE), not part of this contract.
`profile-config` is the element's JSON attribute (`<doxa-map profile-config='{"tk":"pk.…"}'>`).
@ -122,6 +123,34 @@ A URL that returns a public token as JSON (`{token|tk|accessToken}`) or as a bar
- **Plugins:** none reads `profile-config.tokenUrl` today. The studio and the Research Gallery
forward it, but no element consumes it (see the open points below).
## 5. People-group list: `'doxa'` | `'unreached'` | `'frontier'` | `'frontier-500k'` | `'unengaged'`
Which people groups a map shows. The lists are defined in `lists/lists.json`, each as a filter over
the IMB / peoplegroups.org dataset (never a hand-kept roster). Aliases name them by project:
`2000` = `doxa`, `7000` = `unreached`, `4700` = `frontier`, `700` = `frontier-500k`.
| Precedence | Channel | Where it is read |
|---|---|---|
| 1 | `profile-config.list` | `lists/peopleGroupList.js` `resolvePeopleGroupList` |
| 2 | `?list=` in the page URL | the same |
| 3 | `window.MAP_LIST` | the same |
| 4 | default `'doxa'` | DOXA's live list, exactly as before: nothing fetched, nothing filtered |
- **How a map honours it:** every people-group fetch passes its rows through
`applyPeopleGroupList(rows, profileConfig)`, which keeps the rows whose slug is in the list's
membership file (`data/people-groups/lists/<id>.json`, served as `assets/people-groups/lists/`).
`DataSourceManager` does it for maps that load through it (pass it `profileConfig`).
- **What a map shows (Tier A):** the DOXA groups in the list, because DOXA's API is where the rows
and the prayer fields come from. The list's groups that DOXA does not carry are Tier B, per map.
- **Composites** thread `list` to their children like `provider` (`INHERITED_PAGE_KEYS`, and the
stack composites' child `profile-config`).
- **Membership files** are generated: `bun run lists` (`scripts/people-groups/build-lists.mjs`,
from the IMB CSV and the DOXA snapshot that `build-vision-funnel.mjs` saves).
- **An unknown list** falls back to `'doxa'` with a console warning. **A list whose membership
cannot load** shows no groups (fails closed), never DOXA's whole list under another list's name.
- **Guarded by** `check:universal-props` (LIST-FETCH, LIST-DSM, LIST-KEYS) and
`check:list-prop-unit`.
---
## Open points found while writing this down

View file

@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* check-cdn-pins — every runtime third-party script/stylesheet is an EXACT version with an integrity hash.
*
* CRITIC-5 (2026-09-26): maplibre-gl@5 and maplibre-gl-geocoder@1 were floating ranges, and no runtime
* loader set `integrity`, so a CDN could serve any bytes to every partner's visitors unnoticed.
* library/constants/cdn.js is now the one registry; loaders set integrity + crossorigin from it.
*
* OFFLINE (default, in gates:post): in shipped code (library/, plugin-designs/ except scripts/,
* internal/generate-tree.mjs) no jsDelivr/unpkg/esm.sh/api.mapbox.com JS or CSS URL is a floating
* range (@7, @1.2, latest), and every such URL is either in the registry or on the reviewed list below.
* --online: also re-fetches every registry URL and requires its sha384 to equal the pinned hash.
* EXIT 0 pass · 1 fail · 75 (online) network unavailable
*/
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { CDN } from '../library/constants/cdn.js'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const ONLINE = process.argv.includes('--online')
// Loaded through something other than a <script>/<link> tag, so integrity cannot apply; pinned exactly.
const REVIEWED = new Map([
['https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.3.0/mapbox-gl-rtl-text.js', 'fetched by the engine (setRTLTextPlugin)'],
['https://cdn.jsdelivr.net/npm/@mapbox/mapbox-gl-rtl-text@0.3.0/dist/mapbox-gl-rtl-text.js', 'fetched by the engine (setRTLTextPlugin)'],
])
const known = new Set(Object.values(CDN).map((e) => e.url))
const URL_RE = /https:\/\/(?:cdn\.jsdelivr\.net|unpkg\.com|esm\.sh|api\.mapbox\.com\/mapbox-gl-js)\/[^\s'"`)<>]+?\.(?:m?js|css)\b/g
const FLOATING = /@(?:\d+|\d+\.\d+|latest|next)\/|@\^|@~/
const files = execFileSync('git', ['ls-files', '--', 'library', 'plugin-designs', 'internal/generate-tree.mjs'], { cwd: ROOT, encoding: 'utf8' })
.split('\n').filter((f) => /\.(m?js|vue|html)$/.test(f) && !/\/scripts\//.test(f) && !/\/index\.html$/.test(f) && !/^plugin-designs\/[^/]+\/data\//.test(f))
let bad = 0
const seen = new Map()
for (const f of files) {
const src = execFileSync('git', ['show', `HEAD:${f}`], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 << 20 })
for (const m of src.matchAll(URL_RE)) {
const u = m[0]
if (!seen.has(u)) seen.set(u, f)
if (FLOATING.test(u)) { bad++; console.log(` FAIL ${f}: floating version ${u}`) }
else if (!known.has(u) && !REVIEWED.has(u)) { bad++; console.log(` FAIL ${f}: not in library/constants/cdn.js (no integrity): ${u}`) }
}
}
console.log(`CDN-PINS: ${seen.size} distinct runtime CDN url(s) in shipped code; registry holds ${known.size}.`)
if (ONLINE) {
for (const [k, e] of Object.entries(CDN)) {
let got = ''
try { const r = await fetch(e.url); if (!r.ok) throw new Error('HTTP ' + r.status); got = 'sha384-' + createHash('sha384').update(Buffer.from(await r.arrayBuffer())).digest('base64') }
catch (err) { console.log(`CDN-PINS: NOT MEASURED — ${e.url}: ${err.message}`); process.exit(75) }
const ok = got === e.integrity
if (!ok) bad++
console.log(` ${ok ? 'ok ' : 'FAIL'} ${k}: ${ok ? 'bytes match the pinned hash' : `served ${got}, pinned ${e.integrity}`}`)
}
}
if (bad) { console.log(`CDN-PINS: FAIL — ${bad} problem(s).`); process.exit(1) }
console.log(`CDN-PINS: PASS — every runtime CDN script/stylesheet is an exact version${ONLINE ? ', and every pinned hash matches what the CDN serves' : ' with an integrity hash'}.`)

View file

@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* check-composite-interop.mjs — the links between a composite's children must exist on BOTH ends.
*
* WHY THIS EXISTS. A capability-4 composite is only a composite because its children talk: the grid
* narrows the map, "Show on map" moves the map, a region click retitles the dashboard. Every one of
* those is a NAME (an event, an attribute, a config key) written in two files that nothing ties
* together. Rename it on one side and the build stays green, both children still render, and the
* link is silently gone. The interop matrix (2026-09-26) also found the other silent way a link dies:
* research-map.vue found its host with getCurrentInstance() INSIDE a watcher callback, where Vue
* returns null, so `map-filtered` was never dispatched, and the only "proof" had fired a synthetic
* event at the composite. Neither shape needs a browser to see.
*
* WHAT IT CHECKS, headless:
* A. SOURCE: each link's send pattern is in the sender file and its listen pattern is in the
* receiver file (a rename on either side fails, naming both files).
* B. BUILT: each link's literal is in every bundle that ships an end of it (what the reader
* actually gets; a stale bundle fails here even when the source is right).
* C. COVERAGE: every composite (*.meta.json type nested-elements-mfe) has links in LINKS or an
* entry in NO_LINKS with its reason. A new composite with neither fails as "unmapped".
* D. LATE getCurrentInstance(): in plugin-designs/ and library/, a getCurrentInstance() call
* inside a plain function (not setup, not a lifecycle hook, not a use* composable body) is a
* host lookup that returns null whenever that function runs from a watcher or an event. Capture
* the instance in setup instead. Sites already routed to an owner are listed in LATE_KNOWN and
* printed, not failed; delete the entry in the owner's fix commit so the gate enforces it again.
*
* NOT A SUBSTITUTE FOR A BROWSER. Both ends existing is necessary, not sufficient: UPG-100's
* select-zone listener exists and still loses the first click, because the lazy Map pane attaches it
* too late (rega, 2026-09-26). The runtime half is check:gallery-embed's `linked` phase.
*
* NEGATIVE-TESTED (2026-09-26, main 521f8b18, PASS with 17 links and the 3 LATE_KNOWN sites), each FAILS:
* a receiver renamed in source ('map-filtered' -> 'map-filter' in research-map-plus-grid.vue);
* a built bundle out of step ("country-selected" renamed in regions-dashboard.js only);
* stack-demo's NO_LINKS entry removed (unmapped composite);
* a new getCurrentInstance() in a one-line function, a one-line arrow and a multi-line function;
* the emitFilterState site fixed with its LATE_KNOWN entry left behind (stale entry).
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve, dirname, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const PD = 'plugin-designs'
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Common send/listen shapes. `n` is the event name.
const EMIT = (n) => new RegExp(`emit\\(\\s*['"]${esc(n)}['"]`)
const VON = (n) => new RegExp(`@${esc(n)}=`)
const DOM_SEND = (n) => new RegExp(`new CustomEvent\\(\\s*['"]${esc(n)}['"]|['"]${esc(n)}['"][\\s\\S]{0,400}dispatchEvent|dispatch\\(\\s*['"]${esc(n)}['"]`)
const DOM_LISTEN = (n) => new RegExp(`addEventListener\\??\\.?\\(\\s*['"]${esc(n)}['"]`)
const LIT = (s) => new RegExp(esc(s))
const camelOn = (n) => 'on' + n.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase())
// ── the links (PRODUCTION-MATRIX.md "Interop matrix") ─────────────────────────────────────────
// send/recv: [file, RegExp]. built: [bundle, literal] — every bundle that ships an end of the link.
const LINKS = [
// big-picture: children compiled in (Vue emits + props)
{ composite: 'big-picture', id: 'header facets -> grid (research-filter)',
send: [`${PD}/big-picture/src/components/BpResearchHeader.vue`, EMIT('research-filter')],
recv: [`${PD}/big-picture/big-picture.vue`, VON('research-filter')],
built: [['big-picture', '"research-filter"'], ['big-picture', camelOn('research-filter')]] },
{ composite: 'big-picture', id: 'grid total -> header count (groups-filtered)',
send: [`${PD}/people-groups-grid/people-groups-grid.vue`, EMIT('groups-filtered')],
recv: [`${PD}/big-picture/big-picture.vue`, VON('groups-filtered')],
built: [['big-picture', '"groups-filtered"'], ['big-picture', camelOn('groups-filtered')]] },
// dashboard-plus-grid: children compiled in
{ composite: 'dashboard-plus-grid', id: 'dashboard facets -> grid (dashboard-filter)',
send: [`${PD}/research-dashboard/research-dashboard.vue`, EMIT('dashboard-filter')],
recv: [`${PD}/dashboard-plus-grid/dashboard-plus-grid.vue`, VON('dashboard-filter')],
built: [['dashboard-plus-grid', '"dashboard-filter"'], ['dashboard-plus-grid', camelOn('dashboard-filter')]] },
// research-map-plus-grid: children composed by src (DOM events + attributes)
{ composite: 'research-map-plus-grid', id: 'grid -> composite (groups-filtered)',
send: [`${PD}/people-groups-grid/people-groups-grid.vue`, DOM_SEND('groups-filtered')],
recv: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, DOM_LISTEN('groups-filtered')],
built: [['people-groups-grid', '"groups-filtered"'], ['research-map-plus-grid', '"groups-filtered"']] },
{ composite: 'research-map-plus-grid', id: 'map -> composite (map-filtered)',
send: [`${PD}/doxa-research-map/research-map.vue`, DOM_SEND('map-filtered')],
recv: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, DOM_LISTEN('map-filtered')],
built: [['doxa-research-map', '"map-filtered"'], ['research-map-plus-grid', '"map-filtered"']] },
{ composite: 'research-map-plus-grid', id: 'composite -> map (externalFilters)',
send: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, LIT('externalFilters')],
recv: [`${PD}/doxa-research-map/research-map.vue`, LIT('externalFilters')],
built: [['research-map-plus-grid', 'externalFilters'], ['doxa-research-map', 'externalFilters']] },
{ composite: 'research-map-plus-grid', id: 'composite -> map (fitSlugs)',
send: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, LIT('fitSlugs')],
recv: [`${PD}/doxa-research-map/research-map.vue`, /'fitSlugs' in raw/],
built: [['research-map-plus-grid', 'fitSlugs'], ['doxa-research-map', 'fitSlugs']] },
...[['wagf-region', 'wagfRegion'], ['religion-family', 'religionFamily'], ['primary-language', 'primaryLanguage']].map(([attr, prop]) => ({
composite: 'research-map-plus-grid', id: `composite -> grid (attribute ${attr})`,
send: [`${PD}/research-map-plus-grid/research-map-plus-grid.vue`, new RegExp(`setAttribute\\(\\s*'${esc(attr)}'`)],
recv: [`${PD}/people-groups-grid/people-groups-grid.vue`, new RegExp(`^\\s*${prop}\\s*:\\s*\\{\\s*type`, 'm')],
built: [['research-map-plus-grid', `"${attr}"`], ['people-groups-grid', prop]],
})),
// country-posters: tabs (window event scoped by instanceId)
{ composite: 'country-posters', id: 'wall "Show on map" -> composite',
send: [`${PD}/country-posters/country-poster-wall.vue`, /event-name="doxa-country-posters:show-on-map"/],
recv: [`${PD}/country-posters/country-posters-mfe.vue`, /['"]doxa-country-posters:show-on-map['"]/],
built: [['country-posters', 'doxa-country-posters:show-on-map']] },
// my-upg-100-list: tabs
{ composite: 'my-upg-100-list', id: 'wall "Show on map" -> composite',
send: [`${PD}/my-upg-100-list/poster-wall.vue`, /event-name="doxa-upg-100-list:show-on-map"/],
recv: [`${PD}/my-upg-100-list/upg-100-mfe.vue`, DOM_LISTEN('doxa-upg-100-list:show-on-map')],
built: [['my-upg-100-list', 'doxa-upg-100-list:show-on-map']] },
{ composite: 'my-upg-100-list', id: 'composite -> map (doxa:select-zone)',
send: [`${PD}/my-upg-100-list/upg-100-mfe.vue`, DOM_SEND('doxa:select-zone')],
recv: [`${PD}/my-upg-100-list/upg-100-list.vue`, DOM_LISTEN('doxa:select-zone')],
built: [['my-upg-100-list', '"doxa:select-zone"']] },
// regions-dashboard: tabs
...['region-selected', 'country-selected'].map((n) => ({
composite: 'regions-dashboard', id: `list -> composite (${n})`,
send: [`${PD}/regions-dashboard/regions-list.vue`, DOM_SEND(n)],
recv: [`${PD}/regions-dashboard/regions-mfe.vue`, DOM_LISTEN(n)],
built: [['regions-dashboard', `"${n}"`]],
})),
{ composite: 'regions-dashboard', id: 'composite -> dashboards (doxa-regions-dashboard:select)',
send: [`${PD}/regions-dashboard/regions-mfe.vue`, /SELECT_EVENT = 'doxa-regions-dashboard:select'[\s\S]*dispatchEvent\(new CustomEvent\(SELECT_EVENT/],
recv: [`${PD}/regions-dashboard/region-dashboards.vue`, /SELECT_EVENT = 'doxa-regions-dashboard:select'[\s\S]*addEventListener\(SELECT_EVENT/],
built: [['regions-dashboard', 'doxa-regions-dashboard:select']] },
// gospel-resources (gospel-dashboard): children compiled in, provide/inject
{ composite: 'gospel-resources', id: 'map -> tables (composedFilter)',
send: [`${PD}/gospel-resources/resources-map.vue`, /composedFilter\.value\s*=/],
recv: [`${PD}/gospel-resources/data-tables.vue`, /watch\(\s*composedFilter/],
built: [['gospel-resources', '"composedFilter"']] },
]
// Composites whose children deliberately exchange nothing. The reason is printed on every run.
const NO_LINKS = {
'stack-demo': 'a demonstration stack: page knobs only; dashboard-plus-grid is the linked version',
}
// ── D: late getCurrentInstance() sites already routed to an owner ─────────────────────────────
const LATE_KNOWN = {
// emitFilterState runs from a watcher, so `map-filtered` is never dispatched (map -> grid dead).
[`${PD}/doxa-research-map/research-map.vue#emitFilterState`]: 'doxa-research-map owner, routed via dx-shepherd 2026-09-26',
// reflectDir runs from watch(textDir): a host switching lang to ar AFTER mount gets no dir=rtl.
[`${PD}/dashboard-plus-grid/dashboard-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
[`${PD}/research-map-plus-grid/research-map-plus-grid.vue#reflectDir`]: 'composite owner, routed via dx-shepherd 2026-09-26',
}
const fails = []
const known = []
const read = (rel) => (existsSync(join(ROOT, rel)) ? readFileSync(join(ROOT, rel), 'utf8') : null)
const bundleCache = {}
const bundle = (b) => (bundleCache[b] ??= read(`public/${TREE_NAME}/${b}/${b}.js`))
// A + B
for (const l of LINKS) {
const tag = `${l.composite}: ${l.id}`
for (const [side, [file, re]] of [['sender', l.send], ['receiver', l.recv]]) {
const src = read(file)
if (src == null) fails.push(`${tag} — ${side} file is gone: ${file}`)
else if (!re.test(src)) fails.push(`${tag} — the ${side} no longer matches ${re} in ${file} (renamed on one side?)`)
}
for (const [b, lit] of l.built) {
const js = bundle(b)
if (js == null) fails.push(`${tag} — no built bundle public/${TREE_NAME}/${b}/${b}.js`)
else if (!js.includes(lit)) fails.push(`${tag} — built ${b}.js does not contain ${lit} (stale bundle, or renamed)`)
}
}
// C
const composites = []
for (const b of readdirSync(join(ROOT, PD))) {
let files = []
try { files = readdirSync(join(ROOT, PD, b)) } catch { continue }
for (const f of files.filter((f) => f.endsWith('.meta.json'))) {
try { if (JSON.parse(readFileSync(join(ROOT, PD, b, f), 'utf8')).type === 'nested-elements-mfe') composites.push(b) } catch { /* not ours to judge */ }
}
}
if (!composites.length) fails.push('found no capability-4 composites (vacuous run)')
for (const c of composites) {
const n = LINKS.filter((l) => l.composite === c).length
if (!n && !NO_LINKS[c]) fails.push(`${c}: unmapped composite — add its links to LINKS, or an entry to NO_LINKS saying why it has none`)
}
// D
function* sources(dir) {
for (const e of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
const rel = `${dir}/${e.name}`
if (e.isDirectory()) { if (!/node_modules|^\./.test(e.name) && !/\/(scripts|dist)$/.test(rel)) yield* sources(rel) }
else if (/\.(vue|js)$/.test(e.name)) yield rel
}
}
const HOOK = /\b(on(Before)?(Mounted|Unmounted|Update|Updated)|onActivated|onDeactivated)\s*\(/
for (const rel of [...sources(PD), ...sources('library')]) {
const lines = readFileSync(join(ROOT, rel), 'utf8').split('\n')
lines.forEach((line, i) => {
if (!/getCurrentInstance\(\)/.test(line) || /^\s*(\/\/|\*|import\b)/.test(line)) return
// Walk the enclosing blocks outward by indentation.
let ind = line.search(/\S/)
let verdict = 'top'
let fnName = ''
// A one-liner opens its function on the SAME line as the call: read that prefix first.
const pre = line.slice(0, line.indexOf('getCurrentInstance()'))
const same = pre.match(/function\s+([A-Za-z0-9_$]+)\s*\(/)
if (HOOK.test(pre) || /\bsetup\s*\(/.test(pre)) verdict = 'ok'
else if (same) { verdict = /^use[A-Z]/.test(same[1]) ? 'ok' : 'late'; fnName = same[1] }
else if (/=>/.test(pre) || /\bfunction\s*\(/.test(pre)) { verdict = 'late'; fnName = (pre.match(/(?:const|let)\s+([A-Za-z0-9_$]+)\s*=/) || [])[1] || '(anonymous)' }
for (let j = i - 1; verdict === 'top' && j >= 0 && ind > 0; j--) {
const l = lines[j]
if (!l.trim() || /^\s*(\/\/|\*)/.test(l)) continue
const li = l.search(/\S/)
if (li >= ind) continue
ind = li
if (HOOK.test(l) || /\bsetup\s*\(/.test(l)) { verdict = 'ok'; break }
const m = l.match(/function\s+([A-Za-z0-9_$]+)\s*\(/)
if (m) { if (/^use[A-Z]/.test(m[1])) { verdict = 'ok'; break } verdict = 'late'; fnName = m[1]; break }
if (/=>\s*\{?\s*$/.test(l) || /\bfunction\s*\(/.test(l)) { verdict = 'late'; fnName = '(anonymous)'; break }
}
if (verdict !== 'late') return
const key = `${rel}#${fnName}`
const where = `${rel}:${i + 1} in ${fnName}()`
if (LATE_KNOWN[key]) known.push(`KNOWN [late getCurrentInstance] ${where} — owner: ${LATE_KNOWN[key]}`)
else fails.push(`${where}: getCurrentInstance() inside a plain function returns null when it runs from a watcher or an event. Capture \`const inst = getCurrentInstance()\` in setup and use inst here.`)
})
}
for (const k of Object.keys(LATE_KNOWN)) {
if (!known.some((m) => m.includes(k.split('#')[0]) && m.includes(k.split('#')[1] + '()'))) {
fails.push(`LATE_KNOWN entry no longer matches anything: ${k} — it was fixed; delete the entry so the gate enforces it`)
}
}
console.log(`COMPOSITE-INTEROP: ${composites.length} composite(s), ${LINKS.length} link(s) checked in source and in the built tree (${TREE_NAME})`)
for (const [c, why] of Object.entries(NO_LINKS)) console.log(` · ${c}: no links, by design — ${why}`)
for (const k of known) console.log(` ${k}`)
if (fails.length) {
console.log(`\nCOMPOSITE-INTEROP: FAIL (${fails.length})`)
for (const f of fails) console.log(` - ${f}`)
process.exit(1)
}
console.log('COMPOSITE-INTEROP: PASS — every link is present on both ends, in source and in the built bundles')

View file

@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* check-composites-mount.mjs: every composite's setup() must run without throwing, in en and ar.
*
* WHY THIS EXISTS. 08f89f55 gave dashboard-plus-grid and research-map-plus-grid a text-direction
* computed that called getRuntimeLocale(), detectLocale() and RTL_LOCALES without importing them.
* Nothing checks free names at build time, so the build was green and the bundles on main carried
* three undefined globals. watch(textDir, ...) evaluates the computed DURING setup, so setup threw
* a ReferenceError in every locale and Vue rendered NOTHING for the whole composite (Big Picture
* and Simple Research, both in the Research Gallery). Every structural gate passed. This gate runs
* the thing that failed: the composite's own setup, and its mounted hooks.
*
* HOW. No browser (fleet policy: only rega launches one). Vite loads each composite .vue through
* ssrLoadModule with the repo's alias and the same vue({ customElement: true }) plugin, and
* @vue/runtime-core mounts it on a no-op renderer. The SSR compile gives the component an
* ssrRender rather than a render, so the renderer draws nothing and no CHILD is set up: this
* measures the composite's OWN setup and lifecycle, which is exactly the part a composite author
* writes. Each composite is mounted twice, with profile-config {"lang":"en"} and {"lang":"ar"}
* (ar is the RTL path, which is where the direction code lives).
*
* WHAT FAILS. Any error Vue reports while mounting: setup, a watcher's first run, a mounted hook.
* An import that throws also fails, naming the file. A run that finds no composites FAILS
* (vacuous guard). Composites are those whose *.meta.json type is nested-elements-mfe (capability
* 4) or nested-mfe-spa (capability 5); the .vue is the meta's basename.
*
* NOT A SUBSTITUTE FOR A BROWSER. It proves setup runs; it cannot prove the children render.
*
* NEGATIVE-TESTED (2026-09-26): on main 75c4b54e (before the fix 57009b7e) it FAILS exactly
* dashboard-plus-grid and research-map-plus-grid, in en and ar, with "ReferenceError: RTL_LOCALES is
* not defined", and mounts the other 8 clean; on 521f8b18 all 20 mount clean. A throw poisoned into
* big-picture's setup (a directly-defined element) and into regions-mfe's (a ProfileLoader profile)
* each FAIL that composite in both locales. About 20 s.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const LOCALES = ['en', 'ar']
const TYPES = new Set(['nested-elements-mfe', 'nested-mfe-spa'])
// ── the page a composite expects to be dropped into, at its thinnest ──────────────────────────
// Only what setup-time code touches. Anything a composite reaches for beyond this shows up as a
// failure naming the missing global, which is the signal to add it here, not to skip the file.
function installPage(lang) {
const noop = () => {}
const el = () => ({ style: {}, dataset: {}, setAttribute: noop, getAttribute: () => null, removeAttribute: noop,
appendChild: (c) => c, removeChild: noop, addEventListener: noop, removeEventListener: noop,
querySelector: () => null, querySelectorAll: () => [], getRootNode() { return this }, classList: { add: noop, remove: noop, toggle: noop, contains: () => false } })
const html = el(); html.lang = lang
const store = new Map()
Object.assign(globalThis, {
window: globalThis, self: globalThis,
document: Object.assign(el(), { documentElement: html, head: el(), body: el(), createElement: el, createTextNode: el,
currentScript: null, readyState: 'complete', cookie: '', getElementById: () => null }),
location: new URL(`https://gate.invalid/${TREE_NAME}/x/x/index.html?lang=${lang}`),
localStorage: { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
sessionStorage: { getItem: () => null, setItem: noop, removeItem: noop },
customElements: { define: noop, get: () => undefined, whenDefined: () => new Promise(noop) },
matchMedia: () => ({ matches: false, addEventListener: noop, removeEventListener: noop, addListener: noop, removeListener: noop }),
addEventListener: noop, removeEventListener: noop, dispatchEvent: () => true,
requestAnimationFrame: (f) => setTimeout(f, 0), cancelAnimationFrame: clearTimeout,
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
IntersectionObserver: class { observe() {} unobserve() {} disconnect() {} },
MutationObserver: class { observe() {} disconnect() {} },
HTMLElement: class {}, ShadowRoot: globalThis.ShadowRoot || class ShadowRoot {}, CustomEvent: globalThis.CustomEvent || class extends Event { constructor(t, o) { super(t, o); this.detail = o?.detail } },
})
if (!globalThis.navigator?.language) globalThis.navigator = { language: lang, languages: [lang], userAgent: 'gate' }
}
// --all-profiles (check:profiles-mount): EVERY profile .vue of every bundle, and every preset instance
// with its props, not only composites. 0a2a7b09 was a setup-time TDZ throw in a NON-composite lens
// (upg-neighbor lang-cluster, reached through an instance's `view` prop), which the composite scope
// could not see and check:no-free-identifiers cannot see either (the name IS bound, only late).
const ALL = process.argv.includes('--all-profiles')
function allProfiles() {
const out = []
const base = join(ROOT, 'plugin-designs')
for (const bundle of readdirSync(base)) {
if (bundle.startsWith('_') || bundle.startsWith('.')) continue
const dir = join(base, bundle)
let files = []
try { files = readdirSync(dir) } catch { continue }
if (!files.includes('index.js')) continue
for (const f of files.filter((f) => f.endsWith('.vue'))) {
const profile = f.replace(/\.vue$/, '')
out.push({ bundle, profile, vue: join(dir, f), missing: false })
let inst = null
for (const cand of [`${profile}.instances.json`, ...files.filter((x) => x.endsWith('.instances.json'))]) {
try { const j = JSON.parse(readFileSync(join(dir, cand), 'utf8')); if (!j.profile || j.profile === profile) { inst = j; break } } catch { /* none */ }
}
for (const i of (inst && inst.instances) || []) out.push({ bundle, profile, vue: join(dir, f), missing: false, instance: i.id, props: i.props || {} })
}
}
return out
}
function composites() {
const out = []
const base = join(ROOT, 'plugin-designs')
for (const bundle of readdirSync(base)) {
const dir = join(base, bundle)
let files = []
try { files = readdirSync(dir) } catch { continue }
for (const f of files.filter((f) => f.endsWith('.meta.json'))) {
let meta
try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch { continue }
if (!TYPES.has(meta.type)) continue
const vue = join(dir, f.replace(/\.meta\.json$/, '.vue'))
out.push({ bundle, profile: f.replace(/\.meta\.json$/, ''), vue, missing: !existsSync(vue) })
}
}
return out
}
const list = ALL ? allProfiles() : composites()
if (!list.length) { console.error('COMPOSITES-MOUNT: FAIL, found no composites (vacuous run)'); process.exit(1) }
// ── mount each composite the way its bundle entry does ─────────────────────────────────────────
// A composite's setup depends on what its entry's configureApp installs (pinia, the i18n instance,
// provides) and, for a ProfileLoader bundle, on what ProfileLoader provides. Mounting the bare .vue
// would fail a correct composite for a missing app.use and pass nothing real, so the gate RUNS the
// entry: every defineCustomElement(...) in plugin-designs/<bundle>/index.js is swapped for a
// recorder, and the recorded component + configureApp are what gets mounted.
const ENTRY_RE = /plugin-designs\/[^/]+\/index\.js$/
const entryCapture = {
name: 'gate-entry-capture',
enforce: 'post',
resolveId: (id) => (id === 'virtual:gate-define' ? '\0gate-define' : null),
load: (id) => (id === '\0gate-define'
? 'export function __gateDefine(comp, opts) { (globalThis.__gateDefs ||= []).push({ comp, opts: opts || {} }); return class {} }'
: null),
transform(code, id) {
if (!ENTRY_RE.test(id)) return null
return 'import { __gateDefine } from "virtual:gate-define";\n' + code.replace(/\bdefineCustomElement\s*\(/g, '__gateDefine(')
},
}
const { createServer } = await import('vite')
const { default: vuePlugin } = await import('@vitejs/plugin-vue')
const server = await createServer({
root: ROOT, configFile: false, logLevel: 'error', appType: 'custom',
server: { middlewareMode: true, hmr: false, watch: null },
plugins: [vuePlugin({ customElement: true }), entryCapture],
resolve: { alias: { '@map': join(ROOT, 'library') } },
define: { __DOXA_TREE_NAME__: JSON.stringify(TREE_NAME) },
})
// A node the no-op renderer hands out. $el.getRootNode().host is how composites find their own
// custom-element host (for dir, and for DOM events), so the root node answers with a shadow root.
function makeNode() {
const noop = () => {}
const node = { style: {}, dataset: {}, setAttribute: noop, getAttribute: () => null, removeAttribute: noop,
addEventListener: noop, removeEventListener: noop, dispatchEvent: () => true,
querySelector: () => null, querySelectorAll: () => [], appendChild: (c) => c }
const host = { ...node }
const shadow = Object.assign(Object.create(globalThis.ShadowRoot.prototype), node, { host })
node.getRootNode = () => shadow
return node
}
const failures = []
let mounted = 0
try {
const rc = await import('@vue/runtime-core')
const noop = () => {}
const { createApp } = rc.createRenderer({
insert: noop, remove: noop, createElement: makeNode, createText: makeNode, createComment: makeNode,
setText: noop, setElementText: noop, parentNode: () => null, nextSibling: () => null, patchProp: noop,
})
for (const lang of LOCALES) {
installPage(lang)
// Re-evaluate every module per locale: module-level state (the runtime locale, the stores) must
// start from the page this locale describes, and a cached entry would not run its defines again.
server.moduleGraph.invalidateAll()
const stores = await Promise.all(['uiStore', 'mapStore', 'dataStore'].map((n) => server.ssrLoadModule(`/library/stores/${n}.js`)))
const [useUIStore, useMapStore, useDataStore] = [stores[0].useUIStore, stores[1].useMapStore, stores[2].useDataStore]
const entryDefs = {}
for (const c of list) {
const tag = `${c.bundle}/${c.profile}${c.instance ? '/' + c.instance : ''} [${lang}]`
if (c.missing) { failures.push(`${tag}: no ${c.profile}.vue beside its meta.json`); continue }
let comp, defs
try {
comp = (await server.ssrLoadModule(c.vue)).default
// The entry runs ONCE per locale (the module is cached after), so its recorded defines are kept
// per bundle: a second profile of the same bundle reads the same record.
entryDefs[c.bundle] ||= await (async () => {
globalThis.__gateDefs = []
await server.ssrLoadModule(`/plugin-designs/${c.bundle}/index.js`)
return globalThis.__gateDefs
})()
defs = entryDefs[c.bundle]
} catch (e) { failures.push(`${tag}: import threw: ${e?.message || e}`); continue }
const own = defs.find((d) => d.comp === comp)
const viaLoader = defs.find((d) => String(d.comp?.__file || '').endsWith('/ProfileLoader.vue'))
const def = own || viaLoader
if (!def) { failures.push(`${tag}: its bundle entry defines neither this component nor ProfileLoader`); continue }
const cfg = { ...(c.props || {}), profile: c.profile, lang, locale: lang }
let root = comp
if (!own) {
// Under ProfileLoader the profile is a child: give it what ProfileLoader provides.
root = { setup() {
rc.provide('mapboxToken', rc.ref(''))
rc.provide('dataSource', rc.ref(''))
rc.provide('colorSet', rc.ref(null))
rc.provide('profileConfig', rc.computed(() => cfg))
rc.provide('instanceId', 'gate')
rc.provide('lang', rc.ref(lang))
rc.provide('uiStore', useUIStore())
rc.provide('mapStore', useMapStore())
rc.provide('dataStore', useDataStore())
return () => rc.h(comp)
} }
}
const errors = []
const app = createApp(root, own ? { profileConfig: JSON.stringify(cfg) } : null)
app.config.errorHandler = (e, _i, info) => errors.push(`${info}: ${e?.name || 'Error'}: ${e?.message || e}`)
app.config.warnHandler = noop
// plugin-vue's SSR transform records each module in useSSRContext().modules during setup
app.provide(rc.ssrContextKey, { modules: new Set() })
try { def.opts.configureApp?.(app) } catch (e) { errors.push(`configureApp: ${e?.message || e}`) }
try { app.mount(makeNode()) } catch (e) { errors.push(`mount: ${e?.message || e}`) }
await new Promise((r) => setTimeout(r, 0))
try { app.unmount() } catch (_) { /* unmount is not what is measured */ }
if (errors.length) failures.push(`${tag}: ${errors.join(' | ')}`)
else mounted++
}
}
} finally {
await server.close()
}
console.log(`${ALL ? 'PROFILES-MOUNT' : 'COMPOSITES-MOUNT'}: ${list.length} ${ALL ? 'profile/instance(s)' : 'composite(s)'} x ${LOCALES.length} locale(s): ${mounted} mounted clean`)
for (const f of failures) console.log(` FAIL ${f}`)
const NAME = ALL ? 'PROFILES-MOUNT' : 'COMPOSITES-MOUNT'
console.log(failures.length ? `${NAME}: FAIL (${failures.length})` : `${NAME}: PASS`)
process.exit(failures.length ? 1 : 0)

View file

@ -52,9 +52,11 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const leaves = []
for (const b of manifest.bundles || []) for (const p of b.profiles || []) {
const base = `${b.bundle}/${p.profile}`
const add = (id) => { if ((!ONLY || ONLY.has(id)) && existsSync(join(DIR, TREE_NAME, id, 'index.html'))) leaves.push({ id, isMap: !!p.isMap }) }
// opensOnMap:false (a preset opening on a non-GL lens, e.g. upg-neighbor/flower-mesh): not judged as
// a drawing map, but its Mapbox-request count under ?provider=maplibre is still measured.
const add = (id, opensOnMap = true) => { if ((!ONLY || ONLY.has(id)) && existsSync(join(DIR, TREE_NAME, id, 'index.html'))) leaves.push({ id, isMap: !!p.isMap && opensOnMap }) }
add(base)
for (const inst of p.instances || []) add(`${base}/${inst.id}`)
for (const inst of p.instances || []) add(`${base}/${inst.id}`, inst.opensOnMap !== false)
}
if (!leaves.length) { console.log('LEAF-ENGINE: NOT MEASURED — no leaves matched'); process.exit(75) }

View file

@ -0,0 +1,70 @@
#!/usr/bin/env node
/**
* check-list-prop-unit — the people-group LIST prop (library/lists/), measured WITHOUT a browser.
*
* Runs the real library module in Node against a stand-in window/location/fetch:
* - the ladder: profile-config `list` > ?list= > window.MAP_LIST > 'doxa'; ids and aliases ('700');
* an unknown value falls back to the default;
* - the default list returns the SAME array, untouched, with no fetch at all;
* - a list keeps exactly the rows whose slug is in its membership file, fetched from the asset pool;
* - a membership that cannot load FAILS CLOSED: no rows, never the whole DOXA list;
* - every registry list with an imb filter has a membership file, and its counts match the slugs.
* USAGE node internal/check-list-prop-unit.mjs EXIT 0 pass · 1 fail
*/
import { register } from 'node:module'
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
// Vite imports JSON without an attribute; Node 20 does not. Teach this process the same thing.
register('data:text/javascript,' + encodeURIComponent(
"export async function load(url, ctx, next) { if (url.endsWith('.json')) return { format: 'json', shortCircuit: true, source: (await import('node:fs')).readFileSync(new URL(url)) }; return next(url, ctx) }"))
globalThis.window = globalThis
let search = ''
Object.defineProperty(globalThis, 'location', { configurable: true, get: () => ({ search, href: 'https://host.example/page' + search }) })
const fetched = []
let serve = () => ({ ok: false, status: 404 })
globalThis.fetch = async (url) => { fetched.push(String(url)); const r = serve(String(url)); return { ok: r.ok, status: r.status, json: async () => r.body } }
const L = await import('../library/lists/peopleGroupList.js')
const results = []
const check = (name, got, want) => {
const ok = JSON.stringify(got) === JSON.stringify(want)
results.push(ok)
console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(66)} ${JSON.stringify(got)}${ok ? '' : ' want ' + JSON.stringify(want)}`)
}
const reset = () => { search = ''; delete globalThis.MAP_LIST; fetched.length = 0 }
console.log('ladder')
reset(); check('nothing set -> the default', L.resolvePeopleGroupList(null), 'doxa')
reset(); check('profile-config list wins', (search = '?list=unreached', globalThis.MAP_LIST = 'frontier', L.resolvePeopleGroupList({ list: 'frontier-500k' })), 'frontier-500k')
reset(); check('?list= beats window.MAP_LIST', (search = '?list=frontier', globalThis.MAP_LIST = 'unreached', L.resolvePeopleGroupList({})), 'frontier')
reset(); check('window.MAP_LIST when nothing else', (globalThis.MAP_LIST = 'unreached', L.resolvePeopleGroupList(null)), 'unreached')
reset(); check('an alias: 700', L.resolvePeopleGroupList({ list: '700' }), 'frontier-500k')
reset(); check('an alias, any case: Project-7000', L.resolvePeopleGroupList({ list: 'Project-7000' }), 'unreached')
reset(); check('a JSON-string profile-config', L.resolvePeopleGroupList('{"list":"4700"}'), 'frontier')
reset(); check('a ref-shaped profile-config', L.resolvePeopleGroupList({ value: { list: '2000' } }), 'doxa')
reset(); check('unknown -> the default', L.resolvePeopleGroupList({ list: 'project-9999' }), 'doxa')
console.log('apply')
const rows = [{ slug: 'a' }, { slug: 'b' }, { slug: 'c' }]
reset(); { const out = await L.applyPeopleGroupList(rows, null); check('default: the same array, no fetch', [out === rows, fetched.length], [true, 0]) }
reset(); serve = (u) => (u.endsWith('/people-groups/lists/frontier.json') ? { ok: true, status: 200, body: { slugs: ['a', 'c'] } } : { ok: false, status: 404 })
{ const out = await L.applyPeopleGroupList(rows, { list: 'frontier', assetsBase: 'https://cdn.example/tree/assets' }); check('a list keeps exactly its members', out.map((r) => r.slug), ['a', 'c']) }
check('…fetched from the asset pool', fetched, ['https://cdn.example/tree/assets/people-groups/lists/frontier.json'])
reset(); serve = () => ({ ok: false, status: 404 })
{ const err = console.error; console.error = () => {}; const out = await L.applyPeopleGroupList(rows, { list: 'unreached', assetsBase: 'https://cdn.example/x/assets' }); console.error = err; check('an unloadable list FAILS CLOSED: no rows', out, []) }
console.log('membership files')
for (const l of L.peopleGroupLists().filter((x) => x.source === 'imb')) {
const f = join(ROOT, 'data/people-groups/lists', `${l.id}.json`)
if (!existsSync(f)) { check(`${l.id}: membership file exists`, false, true); continue }
const d = JSON.parse(readFileSync(f, 'utf8'))
check(`${l.id}: file matches its registry filter, counts match its slugs`, [JSON.stringify(d.filter) === JSON.stringify(l.filter), d.counts.doxa === d.slugs.length, d.counts.imb >= d.counts.doxa], [true, true, true])
}
const failed = results.filter((x) => !x).length
console.log(failed ? `LIST-PROP: FAIL — ${failed} case(s)` : `LIST-PROP: PASS — ${results.length} cases: the ladder, aliases, the untouched default, membership filtering, fail-closed, and the membership files`)
process.exit(failed ? 1 : 0)

View file

@ -0,0 +1,560 @@
#!/usr/bin/env node
/**
* check-map-data-painted — every map leaf must have OUR DATA painted on it, not just a basemap.
*
* THE HOLE THIS CLOSES, MEASURED RATHER THAN ASSUMED. verify-map-paint.mjs:293 asserts
* ok = shadow && glContext && attribution && !asksForToken && cssOk && no-real-errors
* Every one of those is TRUE of a flawless basemap carrying none of our zones, polygons or pins.
* "The map engine booted and tiles are loading" and "my data is on it" are two different
* properties; only the first was ever measured, so every future data-layer regression lands green.
*
* ★ THE CENSUS THAT SIZED IT (2026-09-26, starve-probe: abort OUR data requests, leave the basemap
* untouched, then read what verify-map-paint would have read). The hole is NOT uniform, and that
* distinction is the whole design:
*
* DECOUPLED — build the map first, add layers later. Starved, these report a live canvas, live
* GL, attribution present and ZERO page errors. verify-map-paint passes them with no data at all:
* my-upg-100-list/upg-100-list 2821 colours healthy -> 305 starved (9x)
* my-upg-100-list/upg-100-mfe canvas 1280x720, gl, attrib, 0 errors
* doxa-research-map/research-map canvas 1280x676, gl, attrib, 0 errors
* all-people-groups canvas 1280x720, gl, attrib, 0 errors
* country-posters/countries-map canvas 1280x720, gl, attrib, 0 errors
* dashboard-plus-grid canvas 686x340, gl, attrib, 0 errors
* research-dashboard canvas 686x357, gl, attrib, 0 errors
* demo-map, doxa-simple-map canvas 1280x*, gl, attrib, 0 errors
*
* COUPLED — data is load-bearing for map construction. Starved, there is NO CANVAS AT ALL, so
* verify-map-paint ALREADY fails them on glContext:
* gospel-resources/resources-map canvas null, gl false, attrib false
* cities-internet/connectivity-map canvas null, gl false, attrib false
* regions-dashboard/region-dashboards, regions-dashboard/regions-mfe likewise
*
* 9 of the 13 leaves verify-map-paint covers are decoupled, i.e. the hole is the majority of the
* suite, not an edge case. A STATIC read of construction order (does `new Map` precede the first
* `await`?) disagreed with the runtime probe on three of them and returned "indeterminate" for four
* more, including upg-100-list, whose map is built through engineGlobal() and matches no
* constructor pattern. The starve-probe is the instrument; source order is not.
*
* ⚠ THE COUPLED LEAVES ARE DELIBERATELY NOT LISTED HERE. Asserting them would add coverage that
* cannot catch a defect — the existing gate detects the identical failure one assertion earlier.
* A gate's leaf list is a claim about WHERE A FAILURE WOULD OTHERWISE GO UNSEEN, not a trophy
* cabinet of everything it can be pointed at. Padding it would inflate apparent coverage, which is
* the same decoration problem as printing a number nobody asserts on.
*
* ★ WHY A CANVAS SCREENSHOT, when both obvious canvas approaches are known-broken. Measured three
* times this cycle: getImageData/drawImage STRAIGHT FROM THE GL CANVAS returns an empty buffer —
* Mapbox allocates its context with preserveDrawingBuffer:false, so the drawing buffer is already
* swapped by the time script runs. This probe read `{"colours":1,"nonzero":0}` on three maps that
* were visibly full of colour. readPixels fails identically.
* The fix is the COMPOSITOR, not the context: Playwright's elementHandle.screenshot() forces a
* real frame and hands back PNG bytes. Same three leaves, same instant: 2821 / 459 / 889 colours.
* The instrument was wrong, not the map.
*
* ★ WHY A DIFFERENTIAL AND NOT A FLOOR. Healthy leaves span 459..2821 colours — a 6x spread —
* because a choropleth of 195 polygons and a two-tone connectivity map are both correct. ANY global
* floor is therefore either vacuous or flaky, and the repo has one to prove it: check:map-paints
* requires 40 distinct colours, and a starved upg-100-list scores 306. It would pass a data-free map
* without hesitation.
* So each leaf is compared AGAINST ITSELF: same URL, same viewport, same wait, run twice — once
* normally, once with our data aborted. The threshold is then not a guess about how colourful a map
* ought to be, it is "withholding our data must visibly change THIS map".
*
* ★ TWO MEASURES, PASS ON EITHER — see MAX_STARVED_RATIO / MIN_CHANGED_PCT below for the numbers.
* The short version: palette change and area change catch different kinds of data layer, and each
* one alone produces a false red on a real leaf. A choropleth in a small dashboard pane repaints a
* quarter of the canvas while barely touching the palette; a sparse demo map transforms the palette
* while repainting 5% of the frame. A map with NO data on it moves neither, which is what makes the
* disjunction sound rather than merely lenient.
*
* ⚠ AND BECAUSE A DISJUNCTION IS A WEAKER CLAIM, --poison IS NOT OPTIONAL. It starves BOTH runs so
* neither measure can move, and requires every leaf to go red. A widened assertion that still
* passes everything reads as coverage while proving nothing; the poison arm is what distinguishes
* "measured and fine" from "no longer able to fail".
*
* ⚠ AND IT REFUSES TO JUDGE RATHER THAN GUESS. If either run yields no canvas, or the starved run
* does not actually abort anything, the leaf reports NOT MEASURED and the process exits 75
* (the repo's EX_TEMPFAIL, reported as the wave runner's (/) ) -- never 0. A gate that measured
* nothing must not read as green; that exact confusion once let three confident runs be reported
* about code nobody had changed.
*
* Usage: node internal/check-map-data-painted.mjs [url ...]
* node internal/check-map-data-painted.mjs --poison # must go RED; proves it can fail
* Defaults to the LOCAL tree's decoupled leaves. Pass URLs explicitly to audit what is published.
*/
import { chromium } from 'playwright'
import { servesThisTree } from './_serves-this-tree.mjs'
import { TREE_NAME } from './_tree-name.mjs'
const BASE = process.env.BASE || `http://127.0.0.1:4173/${TREE_NAME}`
// ONLY the decoupled leaves — see the census above. A leaf moves onto this list when a starve-probe
// shows verify-map-paint still passing it with our data gone, and off it when construction becomes
// data-dependent (at which point the existing gate covers it).
const DECOUPLED_LEAVES = [
'my-upg-100-list/upg-100-list/index.html',
'all-people-groups/all-people-groups/index.html',
'country-posters/countries-map/index.html',
'dashboard-plus-grid/dashboard-plus-grid/index.html',
'demo-map/demo-map/index.html',
'doxa-simple-map/doxa-simple-map/index.html',
]
// ⚠ TWO DECOUPLED LEAVES ARE EXCLUDED BECAUSE THIS INSTRUMENT CANNOT MEASURE THEM — recorded here
// rather than left in the list to emit a permanent (/), which would read as coverage. Each is a
// MEASUREMENT limit, not a verdict that the leaf is fine:
//
// my-upg-100-list/upg-100-mfe — the map canvas sits at y:800 in an 800px viewport with
// document.scrollHeight ALSO exactly 800 and scrollY stuck at 0; at viewport 1600 it moves to
// y:1600,h:1600. It is the composite's second tab pane in a 100vh flex layout, positioned outside
// the scrollable area, so it is always exactly one viewport below the fold and the document never
// grows to contain it. scrollIntoView cannot reach it — there is nothing to scroll. Measuring it
// requires ACTIVATING THE TAB (a product interaction), which is a different gate.
// Its inner map is the same profile as upg-100-list, which IS measured here.
//
// doxa-research-map/research-map — page.screenshot times out at 25s on a 400x250 clip, while
// upg-100-mfe captures the identical size in 0.9s on the same browser and slot. So it is neither
// capture area nor element stability: this leaf does not yield a compositor frame on demand.
// Cause unknown; it needs its own investigation rather than a longer timeout.
const EXCLUDED = new Map([
['my-upg-100-list/upg-100-mfe', 'map canvas is always exactly one viewport below a non-scrolling document (inactive tab pane); needs tab activation'],
['doxa-research-map/research-map', 'page.screenshot times out even on a 400x250 clip that other leaves capture in 0.9s; no compositor frame on demand'],
['research-dashboard/research-dashboard', 'map is not in the shared mapStore, so readiness cannot be measured; on the fixed-wait fallback it PASSED and then timed out on consecutive runs minutes apart'],
])
// ⚠ research-dashboard IS THE REASON THE READINESS LABEL IS PRINTED RATHER THAN JUST USED.
// Measured twice, minutes apart, no code change between: pass 1 "PASS … 19.1% changed, fixed wait
// — map instance unreachable", pass 2 "NOT MEASURED (page.screenshot timeout)". Its map does not
// appear in the provides().mapStore that every other leaf exposes, so the isStyleLoaded() &&
// areTilesLoaded() predicate cannot see it and the run falls back to a fixed wait — which is
// exactly the flakiness this gate was rewritten to remove. Excluding it is not a verdict that the
// leaf is fine; it is a refusal to publish a verdict that depends on machine load.
// A leaf leaves this map the moment its map instance is reachable.
// OUR data, as opposed to the basemap vendor's tiles. Measured from a request census across the
// leaves: local boundary/table/point JSON under /assets/, plus the people-groups API.
const OUR_DATA = /\/assets\/(boundaries|tables|people-groups)\/|pray\.doxa\.life\/api\/|\/api\/people-groups\//i
// ★ TWO MEASURES, AND THE LEAF PASSES IF EITHER ONE MOVES DECISIVELY. Neither alone can carry a
// single threshold — measured 2026-09-26 on four healthy leaves, and they disagree in OPPOSITE
// directions, which is the finding that shaped this gate:
//
// leaf colourRatio changedPixels
// all-people-groups 0.110 70.1% both decisive
// upg-100-list 0.114 30.2% both decisive
// dashboard-plus-grid 0.788 26.4% palette barely moves, AREA does
// demo-map 0.196 5.0% area barely moves, PALETTE does
//
// dashboard-plus-grid's map is a 686x340 PANE inside a composite: the basemap already contributes
// ~846 distinct colours through coastlines, terrain and label antialiasing, and a choropleth
// deletes AREA without introducing many new HUES. demo-map is the mirror image — one sparse data
// request over a muted basemap, so it repaints little of the frame while transforming the palette.
// A 15% changed-pixel bar would falsely fail demo-map; a 0.55 colour bar falsely failed
// dashboard-plus-grid (it did, and I nearly reported it as a product defect).
// A map with NO data on it moves NEITHER: starve it and the palette is unchanged AND the pixels are
// unchanged. So the assertion is a disjunction, and each bar sits far from every healthy reading.
const MAX_STARVED_RATIO = 0.55 // palette: worst healthy non-pane reading is 0.196
const MIN_CHANGED_PCT = 3.0 // area: worst healthy reading is 5.0% (demo-map)
// Below this many colours even the healthy run is suspect: something other than data is wrong.
const MIN_HEALTHY_COLOURS = 60
// Both images are downsampled to this before comparison — cheap, and coarse enough that tile
// dithering does not read as data.
const SAMPLE_W = 400
const SAMPLE_H = 250
// Widest capture taken from a canvas, in CSS px. Bounded because a full-width 1280px capture timed
// out at 30s on research-map even with a perfectly valid clip rect.
const CLIP_CAP = 800
const GL_ARGS = ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']
// ★ --poison IS THE MUTATION ARM, AND WIDENING THE ASSERTION TO A DISJUNCTION IS EXACTLY WHY IT IS
// MANDATORY. "Either the palette moved OR the pixels moved" is a weaker claim than either half, and
// a weakened assertion that still passes everything reads as coverage while proving nothing.
// --poison starves BOTH runs, so the comparison is data-free-vs-data-free: neither measure can
// move, and the gate must go RED. If --poison ever comes back green the bars have drifted into
// vacuity, regardless of what the normal run says.
// ⚠ Flags are filtered out of the positional list below — a bare `--poison` was otherwise consumed
// as a leaf URL, which has bitten this repo's gates twice (--self-test read as a leaf path,
// --emit read as a baseUrl).
const POISON = process.argv.includes('--poison')
const argv = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const urls = argv.length
? argv
: DECOUPLED_LEAVES.map((p) => BASE.replace(/\/$/, '') + '/' + p)
// ★ "ANSWERS 200" AND "IS SERVING THIS BUILD" ARE DIFFERENT QUESTIONS. :4173 on this box has been
// held by another worktree before now, and a gate happily reported someone else's map as proof that
// this checkout worked. Refuse rather than measure the wrong tree.
const TREE_BASE = String(urls[0]).split(`/${TREE_NAME}/`)[0]
if (!argv.length) {
const serves = await servesThisTree(TREE_BASE).catch(() => false)
if (!serves) {
console.log(`MAP-DATA-PAINTED: NOT MEASURED — nothing at ${TREE_BASE} is serving this tree.`)
console.log(' Start it with `bun run serve` in this worktree, then re-run.')
process.exit(75)
}
}
const deepSrc = `(sel) => {
const out = []
const walk = (r) => {
for (const e of r.querySelectorAll('*')) { if (e.matches(sel)) out.push(e); if (e.shadowRoot) walk(e.shadowRoot) }
}
walk(document)
return out
}`
const CANVAS_SEL = 'canvas.mapboxgl-canvas, canvas.maplibregl-canvas, canvas'
const browser = await chromium.launch({ headless: true, args: GL_ARGS })
/**
* Render one leaf and count the distinct colours the COMPOSITOR actually painted.
* starve=true aborts only OUR data requests; the basemap is left completely alone, so a difference
* cannot be explained by a blank map.
*/
async function measure(url, starve, forceClip) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
const page = await ctx.newPage()
let blocked = 0
const pageErrors = []
page.on('pageerror', (e) => pageErrors.push(String(e.message).slice(0, 120)))
if (starve) {
await page.route('**/*', (route) => {
if (OUR_DATA.test(route.request().url())) { blocked++; return route.abort() }
return route.continue()
})
}
try {
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
} catch (e) {
await ctx.close()
return { err: `navigation failed: ${String(e.message).slice(0, 70)}`, blocked }
}
// ★ A FIXED WAIT MADE THIS GATE FLAKY, AND THAT IS A MEASUREMENT BUG, NOT BAD LUCK. With
// `waitForTimeout(10000)` alone, two runs minutes apart on identical code disagreed:
// countries-map read 1868 distinct colours then 54; research-dashboard passed at 17.2% changed
// then timed out. Under the two-slot browser policy on a loaded box, 10s is sometimes enough for
// tiles to paint and sometimes not, so the verdict tracked machine load rather than the product.
//
// So wait on THE MAP'S OWN VERDICT, the way measure-map-load.mjs:81 does:
// isStyleLoaded() && areTilesLoaded(). Not map.loaded() — measure-map-load records that it
// resolves earlier than the tiles in view actually being drawn.
//
// ★ THE MAP INSTANCE IS REACHABLE WITHOUT A PER-PROFILE HANDLE. Only upg-100-list exposes
// window.__UPG_MAP__, but every leaf mounts a Vue custom element, and mobile-sheet-probe.mjs:102
// already reads host()._instance.provides to get at the shared mapStore. Walking that gives the
// real instance on any leaf, so this gate reads the map's own readiness rather than a proxy for
// it — which matters, because my first attempt sampled canvas dimensions and DOM node counts and
// those stop changing LONG before tiles finish painting. A settled proxy is not a settled map.
//
// ⚠ BOTH RUNS USE THE SAME PREDICATE, so this does NOT give them different exposures — which was
// my original reason for a fixed wait, and it was wrong: equal WAITS do not mean equal READINESS,
// and it is readiness the comparison depends on.
await page.waitForTimeout(2500)
const readiness = await page.evaluate(async ({ maxMs }) => {
const hosts = [...document.querySelectorAll('*')].filter((e) => e.tagName.includes('-'))
const maps = []
for (const h of hosts) {
const p = h._instance && h._instance.provides
const store = p && p.mapStore
const vals = store && store.maps ? Object.values(store.maps) : []
for (const v of vals) if (v && v.instance) maps.push(v.instance)
}
if (!maps.length) return { via: 'none' }
const ready = () => maps.every((m) => {
try { return m.isStyleLoaded && m.isStyleLoaded() && m.areTilesLoaded && m.areTilesLoaded() } catch { return false }
})
const t0 = Date.now()
while (Date.now() - t0 < maxMs) {
if (ready()) return { via: 'mapStore', maps: maps.length, waitedMs: Date.now() - t0 }
await new Promise((r) => setTimeout(r, 200))
}
return { via: 'mapStore', maps: maps.length, timedOut: true, waitedMs: Date.now() - t0 }
}, { maxMs: 25000 }).catch(() => ({ via: 'error' }))
// ⚠ NO FIXED-WAIT FALLBACK. If the store was not reachable this returns via:'none' and the caller
// reports NOT MEASURED — it does NOT wait longer and then judge anyway. Silently degrading to the
// clock is precisely how the instability above got shipped: the leaf still produced a confident
// PASS, just an unrepeatable one.
// A final settle so a just-finished tile fade is fully composited before capture.
await page.waitForTimeout(2500)
// Locate the canvas and take its viewport rectangle — NOT an element handle. See below.
// ⚠ evaluateHandle/evaluate take exactly ONE argument; pass several and Playwright throws
// "Too many arguments", which surfaced as exit 1, i.e. indistinguishable from "this map has no
// data on it". Wrap them in an object.
// ★ THE STARVED RUN REUSES THE HEALTHY RUN'S CLIP. Computing it independently in each run made
// the two crops disagree by 3px on dashboard-plus-grid (686x340@297,118 vs @297,121) — the grid
// chrome beside the map reflows slightly when its rows have no data — and a 3px offset compares
// two different rectangles, which is a silently wrong percentage. Rejecting the pair was my first
// answer and it threw away a measurable leaf; aligning the crops keeps the comparison honest AND
// measurable. The canvas is still located and scrolled first, so a clip is only reused when there
// is in fact a canvas to sample.
const clip = forceClip ? await page.evaluate(({ src, sel, given }) => {
const deep = eval(src)
const c = deep(sel)[0]
if (!c) return null
let r = c.getBoundingClientRect()
if (r.top >= window.innerHeight || r.bottom <= 0) { c.scrollIntoView({ block: 'center', inline: 'center' }); r = c.getBoundingClientRect() }
return (r.width > 1 && r.height > 1) ? given : null
}, { src: deepSrc, sel: CANVAS_SEL, given: forceClip }) : await page.evaluate(({ src, sel, cap }) => {
const deep = eval(src)
const c = deep(sel)[0]
if (!c) return null
// ⚠ SCROLL IT INTO VIEW FIRST. upg-100-mfe's map canvas sits at y:800 in an 800px viewport —
// it is the composite's map PANE, below the fold in the tab layout — so clamping to
// innerHeight produced height 0 and the leaf reported "no canvas with a visible box" on a
// canvas that was painting perfectly. Being off-screen is a fact about the layout, not about
// whether our data is on the map.
let r = c.getBoundingClientRect()
if (r.top >= window.innerHeight || r.bottom <= 0) {
c.scrollIntoView({ block: 'center', inline: 'center' })
r = c.getBoundingClientRect()
}
if (r.width < 2 || r.height < 2) return null
// ★ SAMPLE A BOUNDED, CENTRED WINDOW rather than the whole canvas. Two reasons, both measured:
// - research-map's canvas is 1280x756, fully in the viewport with a valid rect, and
// page.screenshot STILL timed out at 30s on it. A large full-width capture is the cost;
// a bounded one is not.
// - the canvas edges carry attribution, scale bar and control chrome, which never change
// between the two runs and so only DILUTE the changed-pixel fraction. Centre-sampling
// measures map, not furniture.
const cw = Math.min(r.width, cap), ch = Math.min(r.height, cap * 0.625)
const cx = r.left + (r.width - cw) / 2, cy = r.top + (r.height - ch) / 2
const x = Math.max(0, Math.floor(cx)), y = Math.max(0, Math.floor(cy))
const width = Math.floor(Math.min(cw, window.innerWidth - x))
const height = Math.floor(Math.min(ch, window.innerHeight - y))
return (width > 1 && height > 1) ? { x, y, width, height } : null
}, { src: deepSrc, sel: CANVAS_SEL, cap: CLIP_CAP })
if (!clip) { await ctx.close(); return { err: 'no canvas with a visible box in any shadow root', blocked } }
// ★ page.screenshot({clip}), NOT elementHandle.screenshot(). This is the whole reason three
// dashboard leaves reported NOT MEASURED with "Timeout 20000ms exceeded":
// elementHandle.screenshot() waits for the element to be STABLE — two consecutive animation
// frames at the same position — before it captures. A Mapbox canvas inside a scrolling dashboard
// pane is continuously animating (tile fade-in, label collision), so it may never satisfy that
// precondition and the call times out on a map that is painting perfectly. Raising the timeout
// does not fix a liveness precondition that is never met.
// A clip on page.screenshot has no stability requirement: it captures the compositor's output for
// a rectangle. check-gospel-map-paints:116 and check-embed-basemap-paints:143 both do exactly
// this, on these same leaves, and neither has ever timed out.
let png
try {
png = await page.screenshot({ type: 'png', clip, timeout: 30000 })
} catch (e) {
await ctx.close()
return { err: `canvas screenshot failed: ${String(e.message).slice(0, 60)}`, blocked }
}
// Sanity-read THIS image before it is ever compared: an all-black readback is the known
// preserveDrawingBuffer failure described above, not a finding about the map.
const counted = await page.evaluate(async ({ b64, w, h }) => {
const img = new Image()
await new Promise((res, rej) => { img.onload = res; img.onerror = () => rej(new Error('decode')); img.src = 'data:image/png;base64,' + b64 })
const o = document.createElement('canvas'); o.width = w; o.height = h
const ctx2 = o.getContext('2d')
ctx2.drawImage(img, 0, 0, w, h)
const d = ctx2.getImageData(0, 0, w, h).data
const set = new Set()
let nonzero = 0
for (let i = 0; i < d.length; i += 4) {
if (d[i] || d[i + 1] || d[i + 2]) nonzero++
// Bucket to 32 levels per channel so gradient dithering is not counted as a distinct colour.
set.add(((d[i] >> 3) << 10) | ((d[i + 1] >> 3) << 5) | (d[i + 2] >> 3))
}
return { nonzero, colours: set.size }
}, { b64: png.toString('base64'), w: SAMPLE_W, h: SAMPLE_H }).catch((e) => ({ err: String(e.message).slice(0, 60) }))
await ctx.close()
if (counted.err) return { err: `image read failed: ${counted.err}`, blocked }
if (counted.nonzero === 0) return { err: 'canvas read back entirely black (buffer already swapped)', blocked }
return { colours: counted.colours, b64: png.toString('base64'), clip, blocked, pageErrors, readiness }
}
/**
* What FRACTION of this map's own pixels did our data paint? See the MIN_CHANGED_PCT note above for
* why this exists beside the palette measure rather than replacing it.
* Runs in a throwaway blank page — it only needs a 2D canvas, not the leaf.
*/
async function changedPct(page, healthyB64, starvedB64) {
const r = await page.evaluate(async ({ a, c, w, h }) => {
const load = (s) => new Promise((res, rej) => {
const i = new Image(); i.onload = () => res(i); i.onerror = () => rej(new Error('decode'))
i.src = 'data:image/png;base64,' + s
})
const [ia, ic] = await Promise.all([load(a), load(c)])
const grab = (img) => {
const o = document.createElement('canvas'); o.width = w; o.height = h
const q = o.getContext('2d'); q.drawImage(img, 0, 0, w, h)
return q.getImageData(0, 0, w, h).data
}
const da = grab(ia), dc = grab(ic)
let changed = 0
// A per-channel-sum threshold of 24 (out of a possible 765) ignores antialiasing and tile
// compression noise while catching any real fill, stroke or pin.
for (let i = 0; i < da.length; i += 4) {
if (Math.abs(da[i] - dc[i]) + Math.abs(da[i + 1] - dc[i + 1]) + Math.abs(da[i + 2] - dc[i + 2]) > 24) changed++
}
return 100 * changed / (da.length / 4)
}, { a: healthyB64, c: starvedB64, w: SAMPLE_W, h: SAMPLE_H }).catch(() => null)
return r
}
console.log('MAP-DATA-PAINTED')
console.log(` ${urls.length} decoupled leaf(s); each measured twice (healthy vs our-data-starved)`)
console.log(` bar: withholding our data must move EITHER the palette (ratio <= ${MAX_STARVED_RATIO})`)
console.log(` OR the pixels (>= ${MIN_CHANGED_PCT}% of the canvas changed). A data-free map moves neither.`)
// ★ SAY WHAT IS NOT COVERED. A silent exclusion reads as "everything is checked"; the loop's rule
// that an unasserted number is decoration applies just as much to an unmentioned gap.
if (!argv.length) {
for (const [leaf, why] of EXCLUDED) console.log(` ⊝ NOT COVERED — ${leaf}: ${why}`)
}
// ⚠ AN UNEXPECTED THROW MUST NOT LOOK LIKE A PRODUCT FINDING. The first run of this gate died on a
// Playwright arity error and exited 1 — which a wave runner reads as "these maps have no data on
// them". A harness bug and a data regression are different claims; only the second may exit 1.
process.on('uncaughtException', (e) => {
console.error('\nMAP-DATA-PAINTED: NOT MEASURED — the gate itself threw, which is a harness bug,')
console.error(' not a verdict about the maps:')
console.error(` ${String(e && e.stack || e).split('\n').slice(0, 4).join('\n ')}`)
process.exit(75)
})
// A blank page used only as a 2D-canvas host for comparing the two screenshots. Doing the diff in
// the leaf's own page would make the comparison depend on whichever leaf happened to be open.
const diffCtx = await browser.newContext()
const diffPage = await diffCtx.newPage()
await diffPage.goto('about:blank')
const rows = []
for (const url of urls) {
const name = url.replace(/^https?:\/\/[^/]+\//, '').replace(/\/index\.html$/, '')
// Under --poison the "healthy" run is ALSO starved, so both images are data-free and neither
// measure can move. See the POISON note at the top.
const healthy = await measure(url, POISON)
if (healthy.err) { rows.push({ name, unmeasured: `healthy run: ${healthy.err}` }); console.log(` ⊘ ${name} — NOT MEASURED (healthy run: ${healthy.err})`); continue }
// ⚠ REFUSE TO PUBLISH A VERDICT THAT RESTS ON A FIXED WAIT — checked BEFORE the second page load,
// so a leaf this gate will not judge does not cost a run. "Waited 12s and hoped" and "the map said
// its tiles were loaded" are different confidences, and the fixed wait is measurably unstable
// here: research-dashboard PASSED at 19.1% changed and then timed out on the next run minutes
// later, with no code change between them. A PASS produced that way measures machine load, not the
// product. Such a leaf belongs in EXCLUDED with its reason until its map instance is reachable.
if ((healthy.readiness || {}).via !== 'mapStore') {
rows.push({ name, unmeasured: 'map instance unreachable, so readiness could not be measured — a fixed-wait result is load-dependent' })
console.log(` ⊘ ${name} — NOT MEASURED (map instance unreachable; a fixed-wait verdict would track machine load, not the product)`)
continue
}
const starved = await measure(url, true, healthy.clip)
if (starved.err) { rows.push({ name, unmeasured: `starved run: ${starved.err}` }); console.log(` ⊘ ${name} — NOT MEASURED (starved run: ${starved.err})`); continue }
// ⚠ IF THE STARVE BLOCKED NOTHING, THE COMPARISON IS BETWEEN TWO IDENTICAL RUNS and would pass
// forever. That is the vacuous-assertion trap: a leaf whose data URLs stop matching OUR_DATA
// must report NOT MEASURED, not PASS.
if (starved.blocked === 0) {
rows.push({ name, unmeasured: 'starve aborted 0 requests — OUR_DATA matches none of this leaf\'s fetches' })
console.log(` ⊘ ${name} — NOT MEASURED (starve aborted 0 requests; OUR_DATA pattern no longer matches this leaf)`)
continue
}
if (healthy.colours < MIN_HEALTHY_COLOURS) {
rows.push({ name, unmeasured: `healthy run painted only ${healthy.colours} colours (<${MIN_HEALTHY_COLOURS})` })
console.log(` ⊘ ${name} — NOT MEASURED (healthy run painted only ${healthy.colours} colours)`)
continue
}
// ⚠ THE TWO RUNS MUST SAMPLE THE SAME REGION or the diff compares two different crops and the
// percentage is meaningless. The starved run is now HANDED the healthy run's clip, so this is an
// invariant check on that plumbing rather than a tolerance on layout drift — if it ever fires, the
// clip did not survive being passed through, and the right response is to fix that, not to widen a
// threshold.
const sameClip = healthy.clip && starved.clip
&& healthy.clip.x === starved.clip.x && healthy.clip.y === starved.clip.y
&& healthy.clip.width === starved.clip.width && healthy.clip.height === starved.clip.height
if (!sameClip) {
const fmt = (c) => c ? `${c.width}x${c.height}@${c.x},${c.y}` : 'none'
rows.push({ name, unmeasured: `the layout moved when data was withheld — sampled ${fmt(healthy.clip)} vs ${fmt(starved.clip)}` })
console.log(` ⊘ ${name} — NOT MEASURED (layout shifted: sampled ${fmt(healthy.clip)} healthy vs ${fmt(starved.clip)} starved)`)
continue
}
const ratio = starved.colours / healthy.colours
const pct = await changedPct(diffPage, healthy.b64, starved.b64)
if (pct === null) {
rows.push({ name, unmeasured: 'the two screenshots could not be compared (PNG decode failed)' })
console.log(` ⊘ ${name} — NOT MEASURED (the two screenshots could not be compared)`)
continue
}
const paletteMoved = ratio <= MAX_STARVED_RATIO
const pixelsMoved = pct >= MIN_CHANGED_PCT
const ok = paletteMoved || pixelsMoved
rows.push({ name, healthy: healthy.colours, starved: starved.colours, ratio, pct, blocked: starved.blocked, ok, paletteMoved, pixelsMoved })
const rd = healthy.readiness
const how = rd.timedOut
? `tiles NOT settled within 25s (${rd.maps} map(s))`
: `tiles settled in ${(rd.waitedMs / 1000).toFixed(1)}s`
const line = `${name} — palette ${healthy.colours}→${starved.colours} (ratio ${ratio.toFixed(3)}${paletteMoved ? ' ✓' : ''}), `
+ `pixels changed ${pct.toFixed(1)}%${pixelsMoved ? ' ✓' : ''}, ${starved.blocked} aborted, ${how}`
if (ok) console.log(` PASS ${line}`)
else console.error(` FAIL ${line}`)
}
const unmeasured = rows.filter((r) => r.unmeasured)
const failed = rows.filter((r) => r.ok === false)
const measured = rows.filter((r) => !r.unmeasured)
// ★ THE POISON ARM INVERTS THE VERDICT. A green poison run means the bars no longer discriminate.
if (POISON) {
const leaked = measured.filter((r) => r.ok !== false)
console.log('')
if (!measured.length) {
console.error('MAP-DATA-PAINTED --poison: NOT MEASURED — no leaf produced a comparison to poison.')
await browser.close()
process.exit(75)
}
if (leaked.length) {
console.error(`MAP-DATA-PAINTED --poison: FAIL — ${leaked.length} leaf(s) PASSED with our data withheld from BOTH runs.`)
for (const l of leaked) {
console.error(` ✗ ${l.name}: ratio ${l.ratio.toFixed(3)}, changed ${l.pct.toFixed(1)}% — `
+ 'two data-free images should move neither measure')
}
console.error('\n The bars have drifted into vacuity: this gate would now pass a map with none of')
console.error(` our data on it. Tighten MAX_STARVED_RATIO (${MAX_STARVED_RATIO}) or MIN_CHANGED_PCT (${MIN_CHANGED_PCT}%).`)
await browser.close()
process.exit(1)
}
console.log(`MAP-DATA-PAINTED --poison: caught it — all ${measured.length} leaf(s) went RED with the data withheld`)
console.log(' from both runs, so a green normal run is a real measurement and not a vacuous one.')
await browser.close()
process.exit(0)
}
if (failed.length) {
console.error(`\nMAP-DATA-PAINTED: FAIL — ${failed.length} of ${rows.length} leaf(s) look the same with our data removed.`)
for (const f of failed) {
console.error(` ✗ ${f.name}: palette ratio ${f.ratio.toFixed(3)} (bar <=${MAX_STARVED_RATIO}) `
+ `AND only ${f.pct.toFixed(1)}% of pixels changed (bar >=${MIN_CHANGED_PCT}%)`)
}
console.error('\n A leaf whose canvas barely changes when every one of our data requests is')
console.error(' aborted is painting a basemap, not a map of our data. verify-map-paint cannot see')
console.error(' this: a live GL context, attribution and zero console errors are all present on')
console.error(' exactly that map. Check that the data layers are added to the style, that they')
console.error(' are above the basemap layers, and that their filters are not evaluating empty.')
process.exit(1)
}
if (unmeasured.length === rows.length) {
console.error('\nMAP-DATA-PAINTED: NOT MEASURED — no leaf produced a usable comparison.')
for (const u of unmeasured) console.error(` ⊘ ${u.name}: ${u.unmeasured}`)
await browser.close()
process.exit(75)
}
if (unmeasured.length) {
console.log(`\n ⚠ ${unmeasured.length} leaf(s) NOT MEASURED — these are not passes:`)
for (const u of unmeasured) console.log(` ${u.name}: ${u.unmeasured}`)
}
console.log(`\nMAP-DATA-PAINTED: PASS — ${rows.length - unmeasured.length} leaf(s) demonstrably lose their `
+ 'data layers when our data is withheld, so the data is what they were painting.')
await browser.close()

View file

@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* check-map-fly-reduced-motion — useMapFly honours prefers-reduced-motion for EVERY camera move, headless.
*
* A reader who asks the OS for reduced motion must not get flying/easing maps (a photosensitivity issue).
* useMapFly decides it itself (cam()), so a caller cannot forget it; flyToCoords also honours caller
* options (it used to take none, silently dropping a caller's { duration: 0 }).
* Runs the real composable against a recording fake map, with matchMedia reporting reduce / no-preference.
* USAGE node internal/check-map-fly-reduced-motion.mjs EXIT 0 pass · 1 fail
*/
let reduce = false
globalThis.window = { matchMedia: (q) => ({ matches: reduce && /prefers-reduced-motion:\s*reduce/.test(q) }), devicePixelRatio: 1 }
const { useMapFly } = await import('../library/composables/useMapFly.js')
const calls = []
const map = new Proxy({}, { get: (_, k) => (k === 'getZoom' ? () => 3 : (...a) => { calls.push({ fn: String(k), a }) }) })
const fly = useMapFly({ getMap: () => map })
let bad = 0
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — got ' + JSON.stringify(got)}`) }
const run = (fn) => { calls.length = 0; fn(); return calls.slice() }
const B = [[0, 0], [10, 10]]
console.log('MAP-FLY-REDUCED-MOTION: the real useMapFly against a recording map (no browser)')
reduce = false
let c = run(() => fly.flyTo([1, 2], { zoom: 4 }))
check('motion allowed: flyTo animates', c[0]?.fn === 'flyTo', c)
c = run(() => fly.fitBounds(B))
check('motion allowed: fitBounds eases (duration > 0)', c[0]?.fn === 'fitBounds' && c[0].a[1].duration > 0, c)
c = run(() => fly.flyToCoords({ longitude: 1, latitude: 2 }, 5, { duration: 0 }))
check('flyToCoords passes caller options through ({ duration: 0 })', c[0]?.fn === 'flyTo' && c[0].a[0].duration === 0 && c[0].a[0].zoom === 5, c)
reduce = true
c = run(() => fly.flyTo([1, 2], { zoom: 4 }))
check('reduced motion: flyTo becomes jumpTo', c.length === 1 && c[0].fn === 'jumpTo' && c[0].a[0].zoom === 4, c)
c = run(() => fly.flyToCoords({ longitude: 1, latitude: 2 }))
check('reduced motion: flyToCoords becomes jumpTo', c.length === 1 && c[0].fn === 'jumpTo', c)
c = run(() => fly.fitBounds(B))
check('reduced motion: fitBounds is instant (animate:false, duration 0)', c[0]?.fn === 'fitBounds' && c[0].a[1].animate === false && c[0].a[1].duration === 0, c)
c = run(() => fly.resetView())
check('reduced motion: resetView makes no flyTo/easeTo', !c.some((x) => x.fn === 'flyTo' || x.fn === 'easeTo'), c)
if (bad) { console.log(`MAP-FLY-REDUCED-MOTION: FAIL — ${bad} case(s).`); process.exit(1) }
console.log('MAP-FLY-REDUCED-MOTION: PASS — every camera move in useMapFly is instant under reduced motion; caller options reach flyToCoords.')

View file

@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* check-mst-clustering — the "MST" clustering mode draws a true minimum spanning tree, fast enough to
* run on the main thread. Headless.
*
* WHY (2026-09-26): MST built and sorted every pair of pins (~2.2 million edges for the 2,107-pin roster),
* ~12 s on the main thread, twice over with a stats pass. Choosing "MST" froze the page, and the browser
* gate check:clustering-modes read the map before any line existed ("MST drew no cluster lines").
* MSTClusteringUtil now runs Prim in O(n²).
*
* ASSERTS (1) on sizes 2-400, capped and uncapped, the util's tree has the SAME edge count and total
* length as a reference all-pairs Kruskal written here; (2) 2,107 pins in under 3 s.
* EXIT 0 pass · 1 fail
*/
import MST from '../library/utils/MSTClusteringUtil.js'
import { euclideanDistance } from '../library/utils/ClusterHelpers.js'
const kruskal = (pts, cap) => {
const e = []
for (let i = 0; i < pts.length; i++) for (let j = i + 1; j < pts.length; j++) {
const d = euclideanDistance(pts[i].latitude, pts[i].longitude, pts[j].latitude, pts[j].longitude)
if (d <= cap) e.push([d, i, j])
}
e.sort((a, b) => a[0] - b[0])
const p = pts.map((_, i) => i), f = (x) => (p[x] === x ? x : (p[x] = f(p[x])))
let n = 0, len = 0
for (const [d, i, j] of e) { const a = f(i), b = f(j); if (a !== b) { p[a] = b; n++; len += d } }
return { n, len }
}
const pts = (n, k = 1) => Array.from({ length: n }, (_, i) => ({ latitude: Math.sin(i * 1.7 * k) * 60, longitude: Math.cos(i * 1.3 * k) * 170 }))
const quiet = console.warn; console.warn = () => {}
let bad = 0
for (const [n, cap] of [[2, Infinity], [3, Infinity], [50, Infinity], [400, Infinity], [400, 12], [400, 4]]) {
const P = pts(n), ref = kruskal(P, cap)
const L = new MST({ maxConnectionDistance: cap }).generateMSTLines(P)
const len = L.reduce((a, f) => a + f.properties.distance, 0)
const ok = L.length === ref.n && Math.abs(len - ref.len) < 1e-6
if (!ok) bad++
console.log(` ${ok ? 'ok ' : 'FAIL'} ${String(n).padStart(4)} pins, cap ${cap}: ${L.length} edges, length ${len.toFixed(4)} (reference ${ref.n}, ${ref.len.toFixed(4)})`)
}
console.warn = quiet
const t = Date.now()
const L = new MST({}).generateAllMSTLines({ A: pts(1500, 1.1), B: pts(607, 0.9) })
const ms = Date.now() - t
const fast = ms < 3000 && L.length === 2105
if (!fast) bad++
console.log(` ${fast ? 'ok ' : 'FAIL'} 2,107 pins in two groups: ${L.length} lines in ${ms} ms (budget 3000 ms)`)
if (bad) { console.log(`MST-CLUSTERING: FAIL — ${bad} case(s).`); process.exit(1) }
console.log('MST-CLUSTERING: PASS — a true minimum spanning tree/forest, and fast enough for the main thread.')

View file

@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* check-no-eval — no bundle needs CSP 'unsafe-eval', and translations still render without it. Headless.
*
* CRITIC-4 (2026-09-26): vue-i18n's default message compiler builds every message with `new Function`,
* so 20 of 25 bundles needed 'unsafe-eval' and a partner with a strict Content-Security-Policy got no
* translated text. vite.config.js now defines __INTLIFY_JIT_COMPILATION__ (compile to an AST, no eval).
*
* ASSERTS (1) BUILT: no bundle under public/<tree>/ contains `new Function(` or a bare `eval(`.
* (2) RUNTIME: vue-i18n in JIT mode, with the global Function constructor and eval DISABLED
* (they throw, as under a strict CSP), renders real catalogue messages — plain,
* {named} interpolation and plural — in en and ar, from library/i18n/locales.
* EXIT 0 pass · 1 fail
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
let bad = 0
// ── (1) built output ──
const TREE = join(ROOT, 'public', TREE_NAME)
let bundles = 0
for (const d of existsSync(TREE) ? readdirSync(TREE) : []) {
const f = join(TREE, d, `${d}.js`)
if (!existsSync(f)) continue
bundles++
const src = readFileSync(f, 'utf8')
const nf = (src.match(/new Function\s*\(/g) || []).length, ev = (src.match(/(^|[^\w$.])eval\s*\(/g) || []).length
if (nf || ev) { bad++; console.log(` FAIL ${d}.js: ${nf} new Function, ${ev} eval — needs CSP 'unsafe-eval'`) }
}
if (!bundles) { bad++; console.log(' FAIL no built bundles found (vacuous)') }
else console.log(` ${bad ? ' ' : 'ok '} ${bundles} built bundle(s) scanned for new Function / eval`)
// ── (2) runtime, with eval unavailable ──
globalThis.__INTLIFY_JIT_COMPILATION__ = true
globalThis.__INTLIFY_DROP_MESSAGE_COMPILER__ = false
globalThis.__VUE_I18N_FULL_INSTALL__ = true
globalThis.__VUE_I18N_LEGACY_API__ = false
globalThis.__INTLIFY_PROD_DEVTOOLS__ = false
const { createI18n } = await import('vue-i18n')
const RealFunction = globalThis.Function
globalThis.eval = () => { throw new EvalError('CSP: eval blocked') }
globalThis.Function = new Proxy(RealFunction, { construct() { throw new EvalError('CSP: new Function blocked') }, apply() { throw new EvalError('CSP: Function blocked') } })
const cat = (lc, area) => JSON.parse(readFileSync(join(ROOT, 'library/i18n/locales', lc, `${area}.json`), 'utf8'))
const walk = (o, p = '') => Object.entries(o).flatMap(([k, v]) => (v && typeof v === 'object' ? walk(v, p + k + '.') : [[p + k, v]]))
for (const lc of ['en', 'ar']) {
const messages = { [lc]: { studio: cat(lc, 'studio'), search: cat(lc, 'search') } }
const i18n = createI18n({ legacy: false, locale: lc, messages, missingWarn: false, fallbackWarn: false })
const t = i18n.global.t
const keys = walk(messages[lc]).filter(([, v]) => typeof v === 'string')
const named = keys.find(([, v]) => /\{\w+\}/.test(v))
let n = 0
try {
for (const [k, v] of keys) {
const vars = {}; for (const m of v.matchAll(/\{(\w+)\}/g)) vars[m[1]] = 'X'
const out = t(k, vars)
if (typeof out !== 'string' || !out) throw new Error(`${k}: rendered nothing`)
if (/\{\w+\}/.test(out)) throw new Error(`${k}: placeholder left in "${out}"`)
n++
}
const nv = named ? t(named[0], Object.fromEntries([...named[1].matchAll(/\{(\w+)\}/g)].map((m) => [m[1], 'Q']))) : ''
console.log(` ok ${lc}: ${n} studio/search messages rendered with eval blocked${named ? ` (e.g. ${named[0]} → "${nv.slice(0, 50)}")` : ''}`)
} catch (e) { bad++; console.log(` FAIL ${lc}: ${e.message}`) }
}
globalThis.Function = RealFunction
if (bad) { console.log(`NO-EVAL: FAIL — ${bad} problem(s).`); process.exit(1) }
console.log("NO-EVAL: PASS — no bundle needs 'unsafe-eval', and translations render with eval blocked.")

View file

@ -215,6 +215,11 @@ const r = await page.evaluate(() => {
return {
sheets: document.querySelectorAll('.sheet').length,
cards: document.querySelectorAll('.card').length,
// The page-2 funnel is OPTIONAL (buildPacket omits it when no funnel data was supplied), so its
// presence is DETECTED rather than assumed — see the sheet arithmetic below, which must stay
// exact about list pages whether or not this page is there.
funnel: document.querySelectorAll('.funnel-title').length,
funnelSteps: document.querySelectorAll('.funnel-mine').length,
// ★ A CARD MUST NAME ITS PEOPLE GROUP. Counting .card proves the element exists; the spec says
// pages 2..n carry "the people groups, 20 per page, with photos" — a card with a photo and a
// BLANK NAME satisfies every count here: the total holds, the photo/typographic split holds,
@ -562,12 +567,29 @@ if (!boxes.length) {
// page arithmetic only if the pages drop too) and a mis-sized grid, which is the way
// "20 per page WITH IMAGES" actually regresses. Verified against all 11 locale packets
// of laos (95 cards -> 8 sheets) before being turned on.
//
// ⚠ THE PAGE-2 FUNNEL IS COUNTED, NOT TOLERATED. When it was added (2026-09-26) every fixture went
// red here with "43 cards should lay out as 6 sheets, but the packet has 7" — the gate doing its job
// on a structural change it had not been told about. The wrong fix is to accept N or N+1 sheets:
// this assertion exists to catch SILENTLY DROPPED PEOPLE GROUPS, and a ±1 tolerance is exactly the
// slack a lost list page would hide in. So the funnel is DETECTED (.funnel-title) and added as a
// known term, leaving the list-page arithmetic as strict as it was.
const PER_PAGE = 20
const expectedSheets = 1 + Math.ceil(r.cards / PER_PAGE) + 2
const funnelSheets = r.funnel ? 1 : 0
const expectedSheets = 1 + funnelSheets + Math.ceil(r.cards / PER_PAGE) + 2
if (r.cards && r.sheets !== expectedSheets) {
fails.push(`${r.cards} people cards should lay out as ${expectedSheets} sheets `
+ `(1 map + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), but the packet has ${r.sheets} — `
+ `either groups were dropped from a page or the ${PER_PAGE}-per-page grid changed`)
+ `(1 map${funnelSheets ? ' + 1 funnel' : ''} + ${Math.ceil(r.cards / PER_PAGE)} list + points + wheel), `
+ `but the packet has ${r.sheets} — either groups were dropped from a page or the `
+ `${PER_PAGE}-per-page grid changed`)
}
// ★ AND IF THE FUNNEL PAGE IS THERE, IT MUST CARRY ITS NUMBERS. A sheet holding the title and no
// figures would satisfy the arithmetic above perfectly — the same "a number printed but not asserted
// on is decoration" trap the card-name check above exists for, one page earlier. .funnel-mine is the
// reader's own share, the one figure on the page that is about THIS packet.
if (r.funnel && !r.funnelSteps) {
fails.push('the funnel page rendered its title but no share figure (.funnel-mine) — '
+ 'page 2 is asserting a narrowing it never states')
}
// ── ...AND THAT ALONE IS NOT ENOUGH: IT ONLY PROVES SELF-CONSISTENCY ────────

View file

@ -0,0 +1,46 @@
#!/usr/bin/env node
/**
* check-popup-escaping — the library's map popups cannot be made to run script by the DATA. Headless.
*
* CRITIC-3 (2026-09-26): generatePinPopupContent and generateRegionPopupContent (useMapEvents.js)
* interpolated people-group and country fields raw into HTML handed to setHTML(). A name or description
* like `<img src=x onerror=…>` from an API or a partner's list would run in the host page.
* Mounts the real composable in a tiny Vue app and renders both popups from hostile data.
* ASSERTS no raw tag, event-handler attribute or javascript: URL survives; ordinary text still shows.
* EXIT 0 pass · 1 fail
*/
import { createApp, h } from 'vue'
import { createI18n } from 'vue-i18n'
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis.__INTLIFY_JIT_COMPILATION__ = true
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const { useMapEvents } = await import('../library/composables/useMapEvents.js')
const popup = JSON.parse(readFileSync(resolve(ROOT, 'library/i18n/locales/en/popup.json'), 'utf8'))
const EVIL = '<img src=x onerror="alert(1)"><script>alert(2)</script>'
const pin = { name: 'Afar ' + EVIL, country: EVIL, status: EVIL, language: EVIL, languageFamily: EVIL, population: EVIL,
description: EVIL, imageUrl: 'javascript:alert(3)' }
let out = {}
const app = createApp({ setup() {
const ev = useMapEvents({ getNormalizedPeopleGroups: () => [{ countryIso: 'ETH', wagfRegionLabel: EVIL, wagfBlockLabel: EVIL }] })
out.pin = ev.generatePinPopupContent(pin)
out.region = ev.generateRegionPopupContent({ iso_3166_1_alpha_3: 'ETH', name_en: 'Ethiopia ' + EVIL })
return () => h('div')
} })
app.use(createI18n({ legacy: false, locale: 'en', messages: { en: { popup } } }))
app.provide('uiStore', {})
const { renderToString } = await import('vue/server-renderer')
await renderToString(app)
let bad = 0
for (const [k, html] of Object.entries(out)) {
const probs = []
if (/<img src=x|<script>/i.test(html)) probs.push("a raw <img>/<script> tag from the data")
if (/\son\w+\s*=\s*"alert/i.test(html)) probs.push('an event-handler attribute from the data')
if (/javascript:/i.test(html)) probs.push('a javascript: URL')
if (!/Afar|Ethiopia/.test(html)) probs.push('the ordinary text is gone')
if (probs.length) bad++
console.log(` ${probs.length ? 'FAIL' : 'ok '} ${k} popup${probs.length ? ': ' + probs.join('; ') : ': hostile data shown as text, no markup or script'}`)
}
if (bad) { console.log(`POPUP-ESCAPING: FAIL — ${bad} popup(s) render data as HTML.`); process.exit(1) }
console.log('POPUP-ESCAPING: PASS — both library popups escape every data field and refuse non-http URLs.')

View file

@ -2,7 +2,7 @@
/**
* check-universal-props.mjs — every plugin honours the props the Plugin Studio header sets for all
* of them (documentation/reference-universal-props.md): map ENGINE, LOCALE (+dir), Mapbox TOKEN and
* token-service URL. HEADLESS: no browser, no server. It reads what each bundle's source and SHIPPED
* token-service URL, and the people-group LIST (library/lists/). HEADLESS: no browser, no server. It reads what each bundle's source and SHIPPED
* bundle consume, and fails on a bypass: a literal or a private global where the library resolver
* belongs.
*
@ -24,6 +24,12 @@
* TOK-LITERAL token a `pk.ey…` token literal in code (comments excluded)
* LOC-ENUS locale toLocaleString('en-US') / Intl.NumberFormat('en-US') (use the active locale).
* WARN only: whether numerals follow the locale is a Driver decision
* LIST-FETCH list a file that fetches the people-group list (/api/people-groups/list, a
* source's endpoints.bulk, or the doxa-people-groups.json roster) without
* applyPeopleGroupList: that map would show DOXA's list whatever list was asked
* LIST-DSM list `new DataSourceManager(...)` without `profileConfig`: the manager applies the
* list itself, but without the embed's config only ?list= / window.MAP_LIST reach it
* LIST-KEYS list a composite's INHERITED_PAGE_KEYS without 'list': its panes never hear it
* Source = plugin-designs/<bundle>/**.{vue,js} minus index.html dev pages and scripts/. Shipped =
* public/<tree>/<bundle>/<bundle>.js, checked for the TOK-GLOBAL and TOK-LITERAL strings the
* minifier cannot rename.
@ -53,6 +59,7 @@ const BUNDLES = [
const ALLOW = {
'prayer-walk-planner:ENG-PIN': 'keyless by design: always MapLibre, needs no token (index.js header)',
'key-cities:ENG-STYLE': 'gated, Mapbox-only by design (mapbox:// styles, ensureEngineScript(\'mapbox\'))',
'all-people-groups:LIST-DSM': 'draws the IMB dataset (all 12,379 groups), not the DOXA list: a list there is Tier B (SPEC-PEOPLE-GROUP-LIST-PROP.md)',
}
const stripComments = (src) => src
@ -78,6 +85,12 @@ const RULES = [
{ id: 'TOK-GLOBAL', prop: 'token', re: /window\.DOXA_TOKEN\b/g, shipped: 'DOXA_TOKEN' },
{ id: 'TOK-LITERAL', prop: 'token', re: /['"`]pk\.ey[A-Za-z0-9_-]{20,}/g, shipped: /pk\.eyJ[A-Za-z0-9_-]{20,}/ },
{ id: 'LOC-ENUS', prop: 'locale', warn: true, re: /(?:toLocaleString|NumberFormat|DateTimeFormat)\s*\(\s*['"]en(?:-US)?['"]/g },
// A whole-file rule: the fetch and the helper need not be on the same line, only in the same file.
{ id: 'LIST-FETCH', prop: 'list', test(src) {
const m = /\/api\/people-groups\/list|endpoints\?\.bulk|doxa-people-groups\.json/.exec(src)
return m && !/applyPeopleGroupList\s*\(/.test(src) ? [m.index] : [] } },
{ id: 'LIST-DSM', prop: 'list', re: /new\s+DataSourceManager\s*\((?![^)]*\bprofileConfig\b)[^)]*\)/g },
{ id: 'LIST-KEYS', prop: 'list', re: /INHERITED_PAGE_KEYS\s*=\s*\[(?![^\]]*['"]list['"])[^\]]*\]/g },
]
const run = (rule, src) => rule.test ? rule.test(src) : [...src.matchAll(rule.re)].map((m) => m.index)
const lineOf = (src, idx) => src.slice(0, idx).split('\n').length
@ -90,12 +103,18 @@ const PLANTED = `
useMapInstance({ container: el, provider: 'mapbox' })
const tk = window.DOXA_TOKEN
const t = 'pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJjbGFudGVkIn0'
n.toLocaleString('en-US')`
n.toLocaleString('en-US')
const r = await fetch(base + '/api/people-groups/list?fields=slug')
const dsm = new DataSourceManager({ locale: 'en' })
const INHERITED_PAGE_KEYS = ['locale', 'provider']`
const missed = RULES.filter((r) => !run(r, stripComments(PLANTED)).length).map((r) => r.id)
const CLEAN = `map.setStyle(resolveNamedStyle('ink', p).style || 'mapbox://styles/mapbox/dark-v11')
useMapInstance({ container: el, style: cfg.style ?? 'mapbox://styles/mapbox/light-v11' })
// new mapboxgl.Popup() in a comment is not code
n.toLocaleString(locale.value)`
n.toLocaleString(locale.value)
const r2 = await applyPeopleGroupList((await fetch(base + '/api/people-groups/list')).posts, cfg)
const dsm2 = new DataSourceManager({ locale: 'en', profileConfig })
const INHERITED_PAGE_KEYS = ['locale', 'provider', 'list']`
const falsePos = RULES.filter((r) => run(r, stripComments(CLEAN)).length).map((r) => r.id)
if (missed.length || falsePos.length) {
console.error(`UNIVERSAL-PROPS: SELF-TEST FAILED — ${missed.length ? 'rules blind to a planted bypass: ' + missed.join(', ') : ''}${falsePos.length ? ' false positives on clean code: ' + falsePos.join(', ') : ''}`)
@ -119,7 +138,7 @@ const matrix = {}
for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) {
const dir = resolve(ROOT, 'plugin-designs', b)
if (!existsSync(dir)) continue
matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok' }
matrix[b] = { engine: 'ok', token: 'ok', locale: 'ok', list: 'ok' }
for (const f of files(dir)) {
const src = stripComments(readFileSync(f, 'utf8'))
for (const r of RULES) for (const idx of run(r, src)) {
@ -140,8 +159,8 @@ for (const b of BUNDLES.filter((x) => !ONLY || ONLY.includes(x))) {
}
if (REPORT) {
console.log('bundle'.padEnd(24) + 'engine'.padEnd(10) + 'token'.padEnd(10) + 'locale')
for (const [b, m] of Object.entries(matrix)) console.log(b.padEnd(24) + m.engine.padEnd(10) + m.token.padEnd(10) + m.locale)
console.log('bundle'.padEnd(24) + 'engine'.padEnd(10) + 'token'.padEnd(10) + 'locale'.padEnd(10) + 'list')
for (const [b, m] of Object.entries(matrix)) console.log(b.padEnd(24) + m.engine.padEnd(10) + m.token.padEnd(10) + m.locale.padEnd(10) + m.list)
}
for (const f of findings) console.log(`${f.warn ? 'WARN ' : 'BYPASS '} ${f.b.padEnd(22)} ${f.rule.padEnd(11)} ${f.where} ${f.text}`)
const hard = findings.filter((f) => !f.warn)
@ -150,4 +169,4 @@ if (hard.length) {
console.log(`UNIVERSAL-PROPS: FAIL — ${hard.length} bypass(es) in ${new Set(hard.map((f) => f.b)).size} bundle(s). Read the prop through the library (documentation/reference-universal-props.md), or ALLOW it here with the reason it is by design.`)
process.exit(1)
}
console.log(`UNIVERSAL-PROPS: PASS — ${Object.keys(matrix).length} bundles read engine, token and locale through the library (self-test: ${RULES.length} rules each caught a planted bypass).`)
console.log(`UNIVERSAL-PROPS: PASS — ${Object.keys(matrix).length} bundles read engine, token, locale and list through the library (self-test: ${RULES.length} rules each caught a planted bypass).`)

View file

@ -30,6 +30,7 @@
* Run standalone: bun generate-tree.mjs (expects vite build already ran)
* Wired into: bun run build (runs after the per-bundle vite builds)
*/
import { CDN } from '../library/constants/cdn.js'
import {
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
cpSync, rmSync, mkdtempSync,
@ -167,15 +168,7 @@ const SCAFFOLD_BUNDLES = ['template-bundle']
// ★ THE MAPBOX ENGINE URL, read from the ONE place that loads it (useMapInstance.js ENGINES.mapbox.js),
// so the embed snippet's preload can never point at a different version than the one the bundle
// requests — a mismatched preload is not used, and the engine would download twice.
const MAPBOX_GL_JS = (() => {
try {
const src = readFileSync(resolve(__dirname, '..', 'library', 'composables', 'useMapInstance.js'), 'utf8')
const m = src.match(/mapbox:\s*\{[^}]*?js:\s*'([^']+mapbox-gl\.js)'/)
if (m) return m[1]
} catch { /* fall through */ }
console.warn('[generate-tree] could not read the Mapbox engine URL from useMapInstance.js; embed snippets get no preload')
return ''
})()
const MAPBOX_GL_JS = CDN.mapboxGlJs.url // the ONE registry the bundle's loader reads too (library/constants/cdn.js)
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
@ -807,8 +800,8 @@ function mapPageHtml(opts) {
? `<!-- MapLibre peer (token-free engine) loaded ONCE here; the custom element
uses window.maplibregl directly (same versions as @map/composables/useMapInstance). -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js"></script>`
<link href="${CDN.maplibreGlCss.url}" rel="stylesheet" integrity="${CDN.maplibreGlCss.integrity}" crossorigin="anonymous" />
<script src="${CDN.maplibreGlJs.url}" integrity="${CDN.maplibreGlJs.integrity}" crossorigin="anonymous"></script>`
: `<!-- NO STATIC ENGINE TAGS (2026-09-25). This leaf used to hard-link mapbox-gl.css, the
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
@ -1150,6 +1143,10 @@ function readInstances(dir, profileName, bundle, tag) {
element: tag,
iframeUrl,
embed: { iframe: iframeEmbed },
// A preset that OPENS on a lens with no GL map (upg-neighbor's flower-mesh is SVG) is still a
// map element: its view-switcher reaches the map lenses, so it keeps isMap (engine, token
// ladder). This tells the draw gates not to expect a canvas on first paint. Absent = true.
...(i.opensOnMap === false ? { opensOnMap: false } : {}),
}
})
}
@ -1302,15 +1299,14 @@ function scanBundles() {
// pastes this snippet has no leaf page to fetch mapbox-gl.js, so the bundle requested it only
// after it had downloaded, run and mounted: doxa-simple-map's engine started at 1.06 s, right
// after its bundle (0.99 s); regions-mfe's at 2.67 s. A preload lets the ~480 KB engine download
// alongside the bundle. NO crossorigin attribute, on purpose: ensureEngineScript injects a plain
// <script src>, and a preload is only reused when its request mode matches — with crossorigin it
// would download twice. Mapbox only: a non-map profile gets none, and a partner switching to
// alongside the bundle. It carries crossorigin + integrity because ensureEngineScript's <script> now
// does too (library/constants/cdn.js): a preload is reused only when its request mode matches. Mapbox only: a non-map profile gets none, and a partner switching to
// provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests.
// The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too.
embed: {
iframe: iframeEmbed,
script: isMap && MAPBOX_GL_JS
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}">\n` + scriptEmbed
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}" integrity="${CDN.mapboxGlJs.integrity}" crossorigin="anonymous">\n` + scriptEmbed
: scriptEmbed,
},
...(instances.length ? { instances } : {}),

View file

@ -16,6 +16,7 @@
* 2. ONE FOLDER public/ holds exactly the configured tree (vite.doxa-maps-build-config.json "name") and
* README.md. A build made under a different tree name writes a different folder: refused.
* 3. SRI every leaf's baked integrity matches the bundle it loads (check:sri).
* 3b. MOUNTS every capability-4/5 composite mounts headless in en and ar (check:composites-mount).
* 4. FRESH every composite rebuilds to exactly its committed bytes (check:composite-freshness).
*
* USAGE node internal/landing-preconditions.mjs exit 0 = may land · 1 = refused
@ -60,6 +61,13 @@ const sri = run(process.execPath, ['internal/sri-consistency-check.mjs'])
if (sri.status !== 0) refuse('check:sri failed', (sri.stdout + sri.stderr).trim().split('\n').slice(-6).join('\n'))
console.log('LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its integrity names')
// 3b. MOUNTS — every capability-4/5 composite mounts headless in en and ar without a setup/watcher/mount
// error (check:composites-mount, ~20 s). 08f89f55 left Big Picture and Simple Research BLANK while every
// other check passed; a landing that blanks a composite is now refused.
const mounts = run(process.execPath, ['internal/check-composites-mount.mjs'])
if (mounts.status !== 0) refuse('a composite no longer mounts (check:composites-mount)', (mounts.stdout + mounts.stderr).trim().split('\n').filter((l) => /FAIL|✗|Error/.test(l)).slice(0, 8).join('\n'))
console.log('LANDING-PRECONDITIONS: 3b mounts — every composite mounts in en and ar')
// 4. FRESH
if (process.env.DOXA_SKIP_FRESHNESS === '1') {
console.log('LANDING-PRECONDITIONS: 4 fresh — SKIPPED (DOXA_SKIP_FRESHNESS=1; dry check only, never set by main-capture scan)')

View file

@ -64,20 +64,25 @@ const BASE = `http://127.0.0.1:${server.address().port}`
// the build config is local and deterministic — the network path degrades silently, setting TK to
// '' when doxa.life is slow, after which every shot is taken token-limited and still written to
// disk. The fetch is kept only as a fallback for a tree whose build config has no baked token.
// ★ TOKENLESS BY DEFAULT (Driver, 2026-09-26). The engine default is now 'auto': no token means
// MapLibre, which is what every keyless partner embed shows. So the README pictures are taken with
// NO token, showing the look a reader gets by default. SHOTS_WITH_TOKEN=1 restores the Mapbox shots
// (the token ladder below).
const WITH_TOKEN = process.env.SHOTS_WITH_TOKEN === '1'
let TK = ''
let TK_SRC = ''
try {
if (WITH_TOKEN) try {
const cfg = JSON.parse(await readFile(new URL('../vite.doxa-maps-build-config.json', import.meta.url), 'utf8'))
if (typeof cfg.token === 'string' && cfg.token.startsWith('pk.')) { TK = cfg.token; TK_SRC = 'build config (the token the bundles ship)' }
} catch {}
if (!TK) {
if (WITH_TOKEN && !TK) {
try {
const r = await fetch('https://doxa.life/api/maps/token', { signal: AbortSignal.timeout(8000) })
TK = r.ok ? ((await r.json()).token || '') : ''
if (TK) TK_SRC = 'doxa.life/api/maps/token (fallback — needs the Referer header below)'
} catch {}
}
console.log(TK ? `token: ${TK.length} chars from ${TK_SRC}` : 'token: NONE — maps token-limited')
console.log(TK ? `token: ${TK.length} chars from ${TK_SRC}` : (WITH_TOKEN ? 'token: NONE — maps token-limited' : 'token: none, by design (engine auto -> MapLibre); SHOTS_WITH_TOKEN=1 for Mapbox'))
// ★ SwiftShader — WITHOUT THESE ARGS EVERY MAP SHOT IS TAKEN WITH NO GL CONTEXT.
// This script was written under the belief "this VPS has no WebGL", which was a LAUNCH-FLAG
@ -168,7 +173,7 @@ async function shot(name, path, {
let inflight = 0, lastNet = Date.now()
const track = (d) => (req) => { if (/^(websocket|eventsource|media)$/.test(req.resourceType())) return; inflight += d; lastNet = Date.now() }
page.on('request', track(1)); page.on('requestfinished', track(-1)); page.on('requestfailed', track(-1))
const url = (/^https?:/.test(path) ? path : BASE + path) + (tokenFree ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
const url = (/^https?:/.test(path) ? path : BASE + path) + (tokenFree || !TK ? '' : (path.includes('?') ? '&' : '?') + 'tk=' + encodeURIComponent(TK))
try {
await page.goto(url, { waitUntil: 'load', timeout: 45000 })
if (waitFor) await page.waitForSelector(waitFor, { timeout: 20000 })

View file

@ -24,6 +24,7 @@
import sourcesConfig from './sources.json'
import { getApiBaseUrl } from './apiBaseUrl.js'
import { applyPeopleGroupList } from '../lists/peopleGroupList.js'
export class DataSourceManager {
constructor(options = {}) {
@ -39,6 +40,10 @@ export class DataSourceManager {
// Unset → the source's own `lang` (sources.json defaults it to 'en').
this.locale = options.locale || null;
// The map's profile-config (object or ref), for its people-group LIST (library/lists/). Unset
// still honours ?list= and window.MAP_LIST; the default list keeps every row, unfetched.
this.profileConfig = options.profileConfig || null;
// CACHE: Store normalized data per source to avoid re-parsing on tab switch
this.cache = new Map();
this.cacheEnabled = true;
@ -226,7 +231,7 @@ export class DataSourceManager {
try {
const response = await fetch(sourceConfig.url || sourceConfig.endpoint);
const data = await response.json();
this.rawData = Array.isArray(data) ? data : (data.posts || data.data || data.peopleGroups || data.results || []);
this.rawData = await applyPeopleGroupList(Array.isArray(data) ? data : (data.posts || data.data || data.peopleGroups || data.results || []), this.profileConfig);
this.normalizeData(sourceConfig);
return this.normalizedData;
} catch (error) {
@ -272,7 +277,7 @@ export class DataSourceManager {
);
}
const data = await response.json();
this.rawData = Array.isArray(data) ? data : (data.posts || data.data || data.results || []);
this.rawData = await applyPeopleGroupList(Array.isArray(data) ? data : (data.posts || data.data || data.results || []), this.profileConfig);
this.normalizeData(sourceConfig);
return this.normalizedData;
} catch (error) {

View file

@ -1163,8 +1163,8 @@ useShadowStyles(`
:class="{ open: exportMenuOpen }"
:aria-expanded="exportMenuOpen ? 'true' : 'false'"
aria-haspopup="menu"
title="Export selection"
aria-label="Export selection"
:title="t('legend.exportSelection')"
:aria-label="t('legend.exportSelection')"
@click.stop="toggleExportMenu">
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 1.5V9M7 1.5L4.2 4.3M7 1.5L9.8 4.3" stroke="currentColor"
@ -1172,7 +1172,7 @@ useShadowStyles(`
<path d="M2 9.5V11.5C2 12.05 2.45 12.5 3 12.5H11C11.55 12.5 12 12.05 12 11.5V9.5"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="stl-export-label">Export</span>
<span class="stl-export-label">{{ t('legend.exportButton') }}</span>
</button>
<template v-if="exportMenuOpen">
@ -1184,7 +1184,7 @@ useShadowStyles(`
<rect x="2" y="1.5" width="8" height="9" rx="1" stroke="currentColor" stroke-width="1.3"/>
<path d="M4 4.5H8M4 6.5H8M4 8.5H6.5" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
<span class="stl-export-item-label">Poster</span>
<span class="stl-export-item-label">{{ t('legend.export.poster') }}</span>
</button>
<button type="button" class="stl-export-item" role="menuitem" @click.stop="emitExport('packet')">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true">
@ -1192,7 +1192,7 @@ useShadowStyles(`
<path d="M7.5 1.5V3.5H9.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M4.5 6H8M4.5 8H7" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
<span class="stl-export-item-label">Packet</span>
<span class="stl-export-item-label">{{ t('legend.export.packet') }}</span>
</button>
</div>
</template>
@ -1308,7 +1308,7 @@ useShadowStyles(`
<span>Loading {{ tabList[activeTab]?.label || 'rows' }}…</span>
</div>
<div v-else-if="!visibleRows.length && nodes.length === 0" class="stl-empty">Loading data…</div>
<div v-else-if="!visibleRows.length" class="stl-empty">No data at this level</div>
<div v-else-if="!visibleRows.length" class="stl-empty">{{ t('legend.noDataAtThisLevel') }}</div>
<div v-if="topNodesOverflow > 0" class="stl-overflow">
Showing top {{ tabList[activeTab].topLimit.toLocaleString() }} of
{{ topNodesAll.length.toLocaleString() }} —
@ -1321,7 +1321,8 @@ useShadowStyles(`
<button v-if="!panelOpen"
class="stl-reopen"
:data-theme="instance?.theme.value || 'dark'"
title="Expand legend"
:title="t('aria.expandLegend')"
:aria-label="t('aria.expandLegend')"
@click="panelOpen = true; emit('update:open', 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

@ -62,6 +62,7 @@
geocoder — the raw MapboxGeocoder instance (for advanced usage)
-->
<script setup>
import { CDN, pinElement } from '@map/constants/cdn.js'
import { ref, shallowRef, onMounted, onBeforeUnmount, computed, inject, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEntitySearch } from '../../composables/useSearch.js'
@ -380,16 +381,16 @@ const standaloneContainer = ref(null)
// the document head has no effect because the widget renders inside a
// ShadowRoot. We target the closest ShadowRoot of the map container.
const GEOCODER_CSS = {
mapbox: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
maplibre: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
mapbox: CDN.mapboxGeocoderCss.url,
maplibre: CDN.maplibreGeocoderCss.url,
}
// MaplibreGeocoder ships as a CDN global (UMD). Mapbox's is preloaded via
// index.html; MapLibre's is injected on demand so a profile can flip provider
// with no host change.
const MAPLIBRE_GEOCODER_JS = 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js'
const MAPLIBRE_GEOCODER_JS = CDN.maplibreGeocoderJs.url // exact version + SRI: library/constants/cdn.js
// Same version the generated leaf pages preload, so an on-demand load and a preload cannot end up
// on two different geocoders.
const MAPBOX_GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
const MAPBOX_GEOCODER_JS = CDN.mapboxGeocoderJs.url
function injectGeocoderCSS(shadowRoot, prov) {
const HREF = GEOCODER_CSS[prov] || GEOCODER_CSS.mapbox
@ -397,6 +398,7 @@ function injectGeocoderCSS(shadowRoot, prov) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = HREF
pinElement(link, HREF)
shadowRoot.prepend(link)
}
@ -433,6 +435,7 @@ async function ensureGeocoderScript(src, globalName) {
await new Promise((resolve) => {
const el = document.createElement('script')
el.src = src
pinElement(el, src)
el.async = true
// Resolve on error too: a blocked CDN must leave the map usable and the bar absent, which is
// what happened before this existed — never a hang or an unhandled rejection.

View file

@ -980,7 +980,9 @@ useShadowStyles(
<div v-for="item in sec.items" :key="item.id" class="card"
:class="{ 'is-last': lastShown === item.id }" :data-list-id="item.id">
<button type="button" class="tape tape-h"
<!-- A TOGGLE (showOnMap clears the selection on a second press), so it says whether it is on:
without aria-pressed a second press sounded identical to the first to a screen reader. -->
<button type="button" class="tape tape-h" :aria-pressed="lastShown === item.id ? 'true' : 'false'"
:aria-label="t('buttons.showOnMapAria', { label: item.label })" @click="showOnMap(item)">
<span class="hcount">{{ item.count }} {{ unit }}</span>
<span class="hsep">·</span>

View file

@ -511,7 +511,8 @@ export function useMapClustering(options = {}) {
if (algorithm === 'mst') {
lines = mstUtil.generateAllMSTLines(groups, colorFunc);
stats = mstUtil.getMSTStats(groups);
// Not getMSTStats(): it recomputes every tree just for this log line (it doubled the work).
stats = { totalGroups: Object.keys(groups).length };
} else {
lines = networkUtil.generateAllNetworkLines(groups, colorFunc);
stats = networkUtil.getClusteringStats(groups);

View file

@ -4,6 +4,7 @@
// Import Vue functions from NPM package
import { ref, inject } from 'vue';
import { escapeHtml as esc, safeUrl } from '../utils/html.js';
import { useI18n } from 'vue-i18n';
import { engineFor } from './useMapProvider.js';
@ -88,7 +89,7 @@ export function useMapEvents(options = {}) {
width: 32px;
height: 32px;
border-radius: 4px;
background-color: ${avatarColor};
background-color: ${esc(avatarColor)};
display: flex;
align-items: center;
justify-content: center;
@ -98,7 +99,7 @@ export function useMapEvents(options = {}) {
margin-right: 10px;
flex-shrink: 0;
">
${firstLetter}
${esc(firstLetter)}
</div>
<!-- Name and Status -->
@ -111,12 +112,12 @@ export function useMapEvents(options = {}) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
">${properties.name}</h3>
">${esc(properties.name)}</h3>
<p style="
margin: 2px 0 0 0;
font-size: 12px;
color: #8e8e8e;
">${properties.country || 'Unknown'} • ${properties.status || 'Unknown Status'}</p>
">${esc(properties.country || 'Unknown')} • ${esc(properties.status || 'Unknown Status')}</p>
</div>
</div>
@ -131,30 +132,30 @@ export function useMapEvents(options = {}) {
overflow: hidden;
">
<img
src="${imageUrl}"
alt="${properties.name}"
src="${safeUrl(imageUrl, placeholderImage)}"
alt="${esc(properties.name)}"
style="
width: 100%;
height: 100%;
object-fit: cover;
"
onerror="this.onerror=null; this.src='${placeholderImage}'"
onerror="this.onerror=null; this.src=&#39;${esc(placeholderImage)}&#39;"
/>
</div>
<!-- Info Section -->
<div style="padding: 12px;">
<p style="margin: 0 0 6px 0; font-size: 13px;">
<strong>Language:</strong> ${properties.language || 'Unknown'}
<strong>Language:</strong> ${esc(properties.language || 'Unknown')}
</p>
<p style="margin: 0 0 6px 0; font-size: 13px;">
<strong>Family:</strong>
<span style="background: ${familyColor}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
${properties.languageFamily || 'Unknown'}
<span style="background: ${esc(familyColor)}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
${esc(properties.languageFamily || 'Unknown')}
</span>
</p>
${properties.population ? `<p style="margin: 0 0 6px 0; font-size: 13px;"><strong>Population:</strong> ${properties.population}</p>` : ''}
${properties.description ? `<p style="margin: 8px 0; font-size: 13px; color: #262626; line-height: 1.4;">${properties.description}</p>` : ''}
${properties.population ? `<p style="margin: 0 0 6px 0; font-size: 13px;"><strong>Population:</strong> ${esc(properties.population)}</p>` : ''}
${properties.description ? `<p style="margin: 8px 0; font-size: 13px; color: #262626; line-height: 1.4;">${esc(properties.description)}</p>` : ''}
</div>
</div>
`;
@ -187,19 +188,19 @@ export function useMapEvents(options = {}) {
return `
<div style="padding: 8px; min-width: 220px;">
<h3 style="margin: 0 0 8px 0; font-size: 16px; font-weight: 600; color: #333;">
${countryName}
${esc(countryName)}
</h3>
<p style="margin: 4px 0; font-size: 14px;">
<strong>${t('popup.country.wagfRegion')}:</strong> ${wagfRegion}
<strong>${esc(t('popup.country.wagfRegion'))}:</strong> ${esc(wagfRegion)}
</p>
<p style="margin: 4px 0; font-size: 14px;">
<strong>${t('popup.country.wagfBlock')}:</strong> ${wagfBlock}
<strong>${esc(t('popup.country.wagfBlock'))}:</strong> ${esc(wagfBlock)}
</p>
<p style="margin: 4px 0; font-size: 14px;">
<strong>${t('popup.country.country')}:</strong> ${countryName} (${iso3})
<strong>${esc(t('popup.country.country'))}:</strong> ${esc(countryName)} (${esc(iso3)})
</p>
<p style="margin: 4px 0; font-size: 14px;">
<strong>${t('popup.country.uupgCount')}:</strong> ${uupgCount}
<strong>${esc(t('popup.country.uupgCount'))}:</strong> ${esc(uupgCount)}
</p>
</div>
`;

View file

@ -15,7 +15,7 @@ import { mapDefaults } from '../constants/mapDefaults.js';
*
* Includes:
* - flyTo(center, options)
* - flyToCoords({ longitude, latitude }, zoom?)
* - flyToCoords({ longitude, latitude }, zoom?, options?)
* - fitBounds(bounds, padding?)
* - slowFlyToFamily / slowFlyToRegion (domain-aware helpers used by legend clicks)
* - getOffsetCenter — shifts map center to account for a UI panel overlay
@ -40,6 +40,29 @@ export function useMapFly(options = {}) {
defaultZoom = mapDefaults?.zoom || 2
} = options;
// ★ REDUCED MOTION IS DECIDED HERE, NOT BY EACH CALLER (2026-09-26). A reader who asks the OS for
// reduced motion (prefers-reduced-motion: reduce) gets every camera move in this composable as an
// instant jump. Callers used to pass { duration: 0 } themselves, and flyToCoords took no options, so
// theirs was silently dropped and the map still flew: a photosensitivity issue, not a style choice.
// Every animated call below goes through cam(map), so no caller can miss it.
const prefersReducedMotion = () => {
try { return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch { return false; }
};
function cam(map) {
const still = prefersReducedMotion();
const jump = (o = {}) => {
const view = {};
for (const k of ['center', 'zoom', 'bearing', 'pitch', 'padding']) if (o[k] !== undefined) view[k] = o[k];
return map.jumpTo(view);
};
return {
flyTo: (o) => (still ? jump(o) : map.flyTo(o)),
easeTo: (o) => (still ? jump(o) : map.easeTo(o)),
fitBounds: (b, o = {}) => map.fitBounds(b, still ? { ...o, animate: false, duration: 0 } : o),
};
}
// Default fly animation configuration
const FLY_CONFIG = {
speed: 0.5, // Slow fly speed
@ -151,7 +174,7 @@ export function useMapFly(options = {}) {
const offsetCenter = getOffsetCenter(center, zoom);
map.flyTo({
cam(map).flyTo({
center: offsetCenter,
zoom: zoom,
...FLY_CONFIG
@ -197,7 +220,7 @@ export function useMapFly(options = {}) {
const offsetCenter = getOffsetCenter(center, zoom);
map.flyTo({
cam(map).flyTo({
center: offsetCenter,
zoom: zoom,
...FLY_CONFIG
@ -252,7 +275,7 @@ export function useMapFly(options = {}) {
...flyOptions
};
map.flyTo(options);
cam(map).flyTo(options);
}
/**
@ -260,11 +283,12 @@ export function useMapFly(options = {}) {
* @param {Object} coords - { longitude, latitude }
* @param {number} [zoom] - Target zoom (defaults to current zoom + 2)
*/
function flyToCoords(coords, zoom) {
function flyToCoords(coords, zoom, flyOptions = {}) {
const map = getMap();
if (!map || coords?.longitude == null || coords?.latitude == null) return;
const targetZoom = zoom ?? Math.min(map.getZoom() + 2, 8);
flyTo([coords.longitude, coords.latitude], { zoom: targetZoom });
// Caller options (e.g. { duration: 0 }) are honoured; they used to be dropped (no third argument).
flyTo([coords.longitude, coords.latitude], { ...flyOptions, zoom: targetZoom });
}
/**
@ -295,7 +319,7 @@ export function useMapFly(options = {}) {
// for prefers-reduced-motion users. Previously only `maxZoom` was read and every
// other caller option was silently dropped, so a reduced-motion caller still got
// FLY_CONFIG's easing. Passing no options is byte-for-byte the previous behaviour.
map.fitBounds(bounds, { padding, maxZoom, duration, ...FLY_CONFIG, ...options });
cam(map).fitBounds(bounds, { padding, maxZoom, duration, ...FLY_CONFIG, ...options });
}
/**
@ -305,7 +329,7 @@ export function useMapFly(options = {}) {
const map = getMap();
if (!map) return;
map.flyTo({
cam(map).flyTo({
center: defaultCenter,
zoom: defaultZoom,
...FLY_CONFIG
@ -424,7 +448,7 @@ export function useMapFly(options = {}) {
// longitudes; zoom = one-world-fills-width; easeTo (NOT flyTo) so the camera
// never arcs out past the floor and flashes the copies.
function _easeWorldOverview(map, centerLng, minLat, maxLat) {
map.easeTo({
cam(map).easeTo({
center: [centerLng, (minLat + maxLat) / 2],
zoom: _noCopyZoom(map),
duration: LEGEND_FIT_DURATION_MS,
@ -622,7 +646,7 @@ export function useMapFly(options = {}) {
if (_tooWideToFit(map, pBounds, ext.span)) {
_easeWorldOverview(map, ext.center, pMinLat, pMaxLat);
} else {
map.fitBounds(pBounds, {
cam(map).fitBounds(pBounds, {
padding: LEGEND_FIT_PADDING_PX,
maxZoom: LEGEND_FIT_MAX_ZOOM,
duration: LEGEND_FIT_DURATION_MS,
@ -670,7 +694,7 @@ export function useMapFly(options = {}) {
if (pointCount === 0) return;
if (pointCount === 1) {
map.flyTo({
cam(map).flyTo({
center: [firstLng, firstLat],
zoom: LEGEND_SINGLE_PIN_ZOOM,
duration: LEGEND_FIT_DURATION_MS,
@ -693,7 +717,7 @@ export function useMapFly(options = {}) {
return;
}
try {
map.fitBounds(bounds, {
cam(map).fitBounds(bounds, {
padding: LEGEND_FIT_PADDING_PX,
maxZoom: LEGEND_FIT_MAX_ZOOM,
duration: LEGEND_FIT_DURATION_MS,

View file

@ -32,17 +32,10 @@ import { useEdgeScrollAffordance } from './useEdgeScrollAffordance.js'
// The map engine is loaded as a page global (CDN <script>). Profiles / index.html
// may preload it; if absent we inject it here. Mapbox is the default — its URLs
// match the existing <script> tags so nothing double-loads or changes.
// URLs and their integrity hashes come from ONE place (library/constants/cdn.js): exact versions, SRI.
const ENGINES = {
mapbox: {
global: 'mapboxgl',
js: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js',
css: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css',
},
maplibre: {
global: 'maplibregl',
js: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.js',
css: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5/dist/maplibre-gl.css',
},
mapbox: { global: 'mapboxgl', js: CDN.mapboxGlJs.url, css: CDN.mapboxGlCss.url },
maplibre: { global: 'maplibregl', js: CDN.maplibreGlJs.url, css: CDN.maplibreGlCss.url },
}
// ONE MAPBOX WORDMARK PER PAGE: every map instance renders its own logo, so a
@ -76,6 +69,7 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord'
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js'
import { CDN, pinElement } from '../constants/cdn.js'
import { installRtlTextPlugin } from '../maps/rtl-text.js'
import { localizeBasemapLabels } from '../maps/localize-labels.js'
@ -130,6 +124,7 @@ export async function ensureEngineScript(provider) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = cfg.css
pinElement(link, cfg.css)
document.head.appendChild(link)
}
@ -146,6 +141,7 @@ export async function ensureEngineScript(provider) {
await new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = cfg.js
pinElement(s, cfg.js)
s.async = false
s.addEventListener('load', resolve, { once: true })
s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true })
@ -196,6 +192,7 @@ export async function ensureEngineCSS(element, cssUrl) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = cssUrl
pinElement(link, cssUrl)
await Promise.race([
new Promise(resolve => {

View file

@ -224,7 +224,10 @@ export function usePosterPrints(opts = {}) {
// The MAP PAGE is optional by design: `mapSvg` is supplied by the caller because
// only the bundle knows where its posters are served from. If it cannot be
// fetched the packet still prints — a cover without the map beats no packet.
async function printPacket(item, { mapSvg = '' } = {}) {
// `funnel` is the page-2 narrowing data (see packet-render.funnelPage). It is passed THROUGH
// untouched: this composable does not know how a host defines its lists, and deriving the counts
// here would make a second source of truth for numbers the Vision Card already publishes.
async function printPacket(item, { mapSvg = '', funnel = null } = {}) {
if (busy()) return;
const kind = 'Prayer Packet';
set(item, kind, 'fetch', 'fetching people groups', 30);
@ -242,7 +245,7 @@ export function usePosterPrints(opts = {}) {
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
);
set(item, kind, 'render', `printing ${data.length} people groups`, 70);
openPrintFrame(R.buildPacket(item, data, photos, mapSvg), { autoprint: true });
openPrintFrame(R.buildPacket(item, data, photos, mapSvg, funnel), { autoprint: true });
done(item, kind);
} catch (e) { fail(item, kind, e); }
}

44
library/constants/cdn.js Normal file
View file

@ -0,0 +1,44 @@
/**
* cdn.js — EVERY third-party script and stylesheet a plugin loads at runtime, pinned to an EXACT version
* with its Subresource Integrity hash (CRITIC-5, 2026-09-26).
*
* WHY. Floating ranges (maplibre-gl@5, maplibre-gl-geocoder@1) meant a CDN could serve any future release
* to every partner's visitors, and with no integrity attribute nothing would notice a tampered file.
* Each entry is loaded with `integrity` + `crossorigin="anonymous"` (every host here answers
* Access-Control-Allow-Origin: *, measured), so the browser refuses a file whose bytes differ.
*
* TO BUMP A VERSION: change the url, recompute the hash, and run check:cdn-pins (it re-fetches every
* url and compares):
* curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
*
* Not here: the RTL text plugin, which the ENGINE fetches (setRTLTextPlugin), not a <script> tag, so
* integrity cannot be applied; it is pinned to an exact version in library/maps/rtl-text.js.
*/
export const CDN = {
mapboxGlJs: { url: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js', integrity: 'sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ' },
mapboxGlCss: { url: 'https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css', integrity: 'sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV' },
// ⚠ MapLibre stays on 5.x FOR NOW, knowingly: GHSA-jrc7-96c5-q579 (an XSS bypass in DOM.sanitize, reached
// through a source's ATTRIBUTION string) affects every release <= 6.4.0, and 6.x is ESM-only (no UMD
// `maplibregl` global), so upgrading is a loader migration, not a version bump. Exposure meanwhile:
// the attribution strings rendered are those of the fixed basemap styles we configure and our own
// sources, none reader- or partner-supplied, and SRI pins these exact bytes. A partner who passes a
// style from an untrusted origin is exposed until the 6.x migration (tracked separately) lands.
maplibreGlJs: { url: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js', integrity: 'sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp' },
maplibreGlCss: { url: 'https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css', integrity: 'sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK' },
mapboxGeocoderJs: { url: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js', integrity: 'sha384-Lx9WK8XhZk57czwxt6B6nyaDHB5Ayrcrsu4Fw4bCp/Ot/QLOsUZ0xYqLOpOOSXZw' },
mapboxGeocoderCss: { url: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css', integrity: 'sha384-1KNhwJNCQTr0DCi/5/OnQcFytgycxTahxNZaqKbAtqmY8bUhu26qxXqvDGXXwplS' },
maplibreGeocoderJs: { url: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1.9.4/dist/maplibre-gl-geocoder.min.js', integrity: 'sha384-RGV5vfoJd7alk8HE+71IyVfu19ciH1FKA15+AuXs6OeBM2npXrmTPFqG23yMiHXi' },
maplibreGeocoderCss: { url: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1.9.4/dist/maplibre-gl-geocoder.css', integrity: 'sha384-UkDpPApjTpHJHpcJiSNAmbobh30W0/dmt0DoKm4V8CWYRYvQ/Jhd/ijFz0tFdVnH' },
// d3 for the PPLR maps and the Wall, which load it themselves (pplrData.js, wallD3.js) and keep it off
// window.d3; listed so check:cdn-pins can verify their pin.
d3: { url: 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js', integrity: 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i' },
}
/** Set integrity + crossorigin on a <script>/<link> for a registry entry (no-op for an unknown url). */
export function pinElement(el, url) {
const e = Object.values(CDN).find((x) => x.url === url)
if (!e) return el
el.integrity = e.integrity
el.crossOrigin = 'anonymous'
return el
}

View file

@ -65,5 +65,12 @@
"expandRow": "توسيع {label}",
"collapseRow": "طي {label}",
"clearSelection": "إلغاء التحديد"
}
},
"export": {
"poster": "ملصق",
"packet": "كُتيّب"
},
"noDataAtThisLevel": "لا توجد بيانات في هذا المستوى",
"exportButton": "تصدير",
"exportSelection": "تصدير التحديد"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "الافتراضي لكل خريطة",
"engineMapboxNote": "يحتاج إلى رمز",
"engineMaplibreNote": "لا يحتاج إلى رمز",
"tokenNotNeeded": "تم اختيار MapLibre: خرائطه لا تحتاج إلى رمز Mapbox"
"tokenNotNeeded": "تم اختيار MapLibre: خرائطه لا تحتاج إلى رمز Mapbox",
"hideHeader": "إخفاء الشريط العلوي",
"showHeader": "إظهار الشريط العلوي"
}

View file

@ -0,0 +1,3 @@
{
"title": "خريطتي الأولى"
}

View file

@ -65,5 +65,12 @@
"expandRow": "{label} ausklappen",
"collapseRow": "{label} einklappen",
"clearSelection": "Auswahl aufheben"
}
},
"export": {
"poster": "Plakat",
"packet": "Heft"
},
"noDataAtThisLevel": "Keine Daten auf dieser Ebene",
"exportButton": "Exportieren",
"exportSelection": "Auswahl exportieren"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "Je Karte eigenes",
"engineMapboxNote": "braucht einen Token",
"engineMaplibreNote": "kein Token nötig",
"tokenNotNeeded": "MapLibre ist gewählt: Seine Karten brauchen keinen Mapbox-Token"
"tokenNotNeeded": "MapLibre ist gewählt: Seine Karten brauchen keinen Mapbox-Token",
"hideHeader": "Kopfzeile ausblenden",
"showHeader": "Kopfzeile einblenden"
}

View file

@ -0,0 +1,3 @@
{
"title": "Meine erste Karte"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Expand {label}",
"collapseRow": "Collapse {label}",
"clearSelection": "Clear selection"
}
},
"export": {
"poster": "Poster",
"packet": "Packet"
},
"noDataAtThisLevel": "No data at this level",
"exportButton": "Export",
"exportSelection": "Export selection"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "Each map’s own",
"engineMapboxNote": "needs a token",
"engineMaplibreNote": "no token needed",
"tokenNotNeeded": "MapLibre is selected: its maps need no Mapbox token"
"tokenNotNeeded": "MapLibre is selected: its maps need no Mapbox token",
"hideHeader": "Hide header",
"showHeader": "Show header"
}

View file

@ -0,0 +1,3 @@
{
"title": "My first map"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Expandir {label}",
"collapseRow": "Contraer {label}",
"clearSelection": "Borrar la selección"
}
},
"export": {
"poster": "Cartel",
"packet": "Cuadernillo"
},
"noDataAtThisLevel": "No hay datos en este nivel",
"exportButton": "Exportar",
"exportSelection": "Exportar la selección"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "El de cada mapa",
"engineMapboxNote": "necesita un token",
"engineMaplibreNote": "no necesita token",
"tokenNotNeeded": "MapLibre está seleccionado: sus mapas no necesitan token de Mapbox"
"tokenNotNeeded": "MapLibre está seleccionado: sus mapas no necesitan token de Mapbox",
"hideHeader": "Ocultar encabezado",
"showHeader": "Mostrar encabezado"
}

View file

@ -0,0 +1,3 @@
{
"title": "Mi primer mapa"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Développer {label}",
"collapseRow": "Réduire {label}",
"clearSelection": "Effacer la sélection"
}
},
"export": {
"poster": "Affiche",
"packet": "Livret"
},
"noDataAtThisLevel": "Aucune donnée à ce niveau",
"exportButton": "Exporter",
"exportSelection": "Exporter la sélection"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "Celui de chaque carte",
"engineMapboxNote": "nécessite un jeton",
"engineMaplibreNote": "aucun jeton requis",
"tokenNotNeeded": "MapLibre est sélectionné : ses cartes n’ont pas besoin de jeton Mapbox"
"tokenNotNeeded": "MapLibre est sélectionné : ses cartes n’ont pas besoin de jeton Mapbox",
"hideHeader": "Masquer l'en-tête",
"showHeader": "Afficher l'en-tête"
}

View file

@ -0,0 +1,3 @@
{
"title": "Ma première carte"
}

View file

@ -65,5 +65,12 @@
"expandRow": "{label} का विस्तार करें",
"collapseRow": "{label} को छोटा करें",
"clearSelection": "चयन हटाएँ"
}
},
"export": {
"poster": "पोस्टर",
"packet": "पुस्तिका"
},
"noDataAtThisLevel": "इस स्तर पर कोई डेटा नहीं",
"exportButton": "निर्यात",
"exportSelection": "चयन निर्यात करें"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "हर मानचित्र का अपना",
"engineMapboxNote": "टोकन चाहिए",
"engineMaplibreNote": "टोकन की ज़रूरत नहीं",
"tokenNotNeeded": "MapLibre चुना गया है: इसके मानचित्रों को Mapbox टोकन नहीं चाहिए"
"tokenNotNeeded": "MapLibre चुना गया है: इसके मानचित्रों को Mapbox टोकन नहीं चाहिए",
"hideHeader": "हेडर छिपाएँ",
"showHeader": "हेडर दिखाएँ"
}

View file

@ -0,0 +1,3 @@
{
"title": "मेरा पहला नक्शा"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Espandi {label}",
"collapseRow": "Comprimi {label}",
"clearSelection": "Cancella la selezione"
}
},
"export": {
"poster": "Manifesto",
"packet": "Libretto"
},
"noDataAtThisLevel": "Nessun dato a questo livello",
"exportButton": "Esporta",
"exportSelection": "Esporta la selezione"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "Quello di ogni mappa",
"engineMapboxNote": "richiede un token",
"engineMaplibreNote": "nessun token necessario",
"tokenNotNeeded": "MapLibre è selezionato: le sue mappe non richiedono un token Mapbox"
"tokenNotNeeded": "MapLibre è selezionato: le sue mappe non richiedono un token Mapbox",
"hideHeader": "Nascondi intestazione",
"showHeader": "Mostra intestazione"
}

View file

@ -0,0 +1,3 @@
{
"title": "La mia prima mappa"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Expandir {label}",
"collapseRow": "Recolher {label}",
"clearSelection": "Limpar a seleção"
}
},
"export": {
"poster": "Cartaz",
"packet": "Caderno"
},
"noDataAtThisLevel": "Sem dados neste nível",
"exportButton": "Exportar",
"exportSelection": "Exportar a seleção"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "O de cada mapa",
"engineMapboxNote": "precisa de um token",
"engineMaplibreNote": "não precisa de token",
"tokenNotNeeded": "MapLibre está selecionado: os seus mapas não precisam de token do Mapbox"
"tokenNotNeeded": "MapLibre está selecionado: os seus mapas não precisam de token do Mapbox",
"hideHeader": "Ocultar cabeçalho",
"showHeader": "Mostrar cabeçalho"
}

View file

@ -0,0 +1,3 @@
{
"title": "Meu primeiro mapa"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Extinde {label}",
"collapseRow": "Restrânge {label}",
"clearSelection": "Șterge selecția"
}
},
"export": {
"poster": "Afiș",
"packet": "Broșură"
},
"noDataAtThisLevel": "Nu există date la acest nivel",
"exportButton": "Exportă",
"exportSelection": "Exportă selecția"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "Al fiecărei hărți",
"engineMapboxNote": "necesită un token",
"engineMaplibreNote": "nu necesită token",
"tokenNotNeeded": "MapLibre este selectat: hărțile sale nu necesită token Mapbox"
"tokenNotNeeded": "MapLibre este selectat: hărțile sale nu necesită token Mapbox",
"hideHeader": "Ascunde antetul",
"showHeader": "Afișează antetul"
}

View file

@ -0,0 +1,3 @@
{
"title": "Prima mea hartă"
}

View file

@ -65,5 +65,12 @@
"expandRow": "Развернуть {label}",
"collapseRow": "Свернуть {label}",
"clearSelection": "Снять выделение"
}
},
"export": {
"poster": "Плакат",
"packet": "Буклет"
},
"noDataAtThisLevel": "На этом уровне нет данных",
"exportButton": "Экспорт",
"exportSelection": "Экспортировать выбранное"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "Свой у каждой карты",
"engineMapboxNote": "нужен токен",
"engineMaplibreNote": "токен не нужен",
"tokenNotNeeded": "Выбран MapLibre: его картам не нужен токен Mapbox"
"tokenNotNeeded": "Выбран MapLibre: его картам не нужен токен Mapbox",
"hideHeader": "Скрыть заголовок",
"showHeader": "Показать заголовок"
}

View file

@ -0,0 +1,3 @@
{
"title": "Моя первая карта"
}

View file

@ -65,5 +65,12 @@
"expandRow": "展开{label}",
"collapseRow": "折叠{label}",
"clearSelection": "清除选择"
}
},
"export": {
"poster": "海报",
"packet": "祷告册"
},
"noDataAtThisLevel": "此层级没有数据",
"exportButton": "导出",
"exportSelection": "导出所选内容"
}

View file

@ -46,5 +46,7 @@
"engineDefault": "各地图自身的",
"engineMapboxNote": "需要令牌",
"engineMaplibreNote": "无需令牌",
"tokenNotNeeded": "已选择 MapLibre:其地图无需 Mapbox 令牌"
"tokenNotNeeded": "已选择 MapLibre:其地图无需 Mapbox 令牌",
"hideHeader": "隐藏标题栏",
"showHeader": "显示标题栏"
}

View file

@ -0,0 +1,3 @@
{
"title": "我的第一张地图"
}

45
library/lists/lists.json Normal file
View file

@ -0,0 +1,45 @@
{
"_about": "The people-group LISTS a map can be asked to show (profile-config `list`, ?list=, window.MAP_LIST; see peopleGroupList.js). Each list is a FILTER, never a hand-kept roster: `source: doxa` is DOXA's live list as the API serves it; `source: imb` is a filter over the IMB / peoplegroups.org dataset in data/people-groups/people_groups.csv, using its own columns (LostnessPriority, Population, EngagementStatus). library/scripts/people-groups/build-lists.mjs turns each imb filter into data/people-groups/lists/<id>.json. TIER A (today): a map shows the DOXA groups that belong to the list, because the DOXA API is where every map's rows and prayer fields come from. TIER B (later, per map): the list's groups that DOXA does not carry. Definitions and counts: _qa-cycle/03-deployment-cycle/SPEC-PEOPLE-GROUP-LIST-PROP.md.",
"default": "doxa",
"lists": [
{
"id": "doxa",
"aliases": ["2000", "project-2000"],
"label": "Project 2000: DOXA's people groups",
"source": "doxa",
"filter": null
},
{
"id": "unreached",
"aliases": ["7000", "project-7000"],
"label": "Project 7000: unreached people groups",
"definition": "IMB Lostness Priority 0-2 (Frontier, Pioneer or Expanding Unreached: under 2% evangelical)",
"source": "imb",
"filter": { "lostness": [0, 1, 2] }
},
{
"id": "frontier",
"aliases": ["4700", "project-4700"],
"label": "Project 4700: frontier people groups",
"definition": "IMB Lostness Priority 0 (Frontier Unreached: 0.1% evangelical or less)",
"source": "imb",
"filter": { "lostness": [0] }
},
{
"id": "frontier-500k",
"aliases": ["700", "project-700"],
"label": "Project 700: frontier people groups of 500,000 or more",
"definition": "IMB Lostness Priority 0 and Population of at least 500,000",
"source": "imb",
"filter": { "lostness": [0], "minPopulation": 500000 }
},
{
"id": "unengaged",
"aliases": [],
"label": "Unengaged unreached people groups",
"definition": "IMB Lostness Priority 0-2 and Engagement Status Unengaged",
"source": "imb",
"filter": { "lostness": [0, 1, 2], "engagement": ["Unengaged"] }
}
]
}

View file

@ -0,0 +1,97 @@
/**
* peopleGroupList.js — the people-group LIST, a universal prop like engine, locale and token.
*
* Every map shows people groups, and until now always DOXA's (the live API's 2,107). The same maps
* must work for any project's list: Project 7000 (unreached), 4700 (frontier), 700 (frontier of
* 500,000 or more), 2000 (DOXA). A list is chosen the way the engine is, first answer wins:
* 1. profile-config `list` — this embed, decided by whoever mounted the element
* 2. ?list= in the page URL — any generated leaf can be switched from the URL
* 3. window.MAP_LIST — a host global, for a host that sets it programmatically
* 4. 'doxa' — the default: exactly today's behaviour, nothing fetched, nothing filtered
* An id or an alias ('700', 'project-700') is accepted; anything unknown falls back to the default
* and says so once in the console.
*
* WHAT A MAP DOES WITH IT (Tier A). Rows keep coming from the DOXA API, because that is where the
* prayer fields live. applyPeopleGroupList() keeps the rows whose slug belongs to the list, from a
* membership file the build writes (data/people-groups/lists/<id>.json, served under the asset
* pool). So under Project 700 a map shows the DOXA groups that are Project 700 groups. The list's
* groups that DOXA does not carry are Tier B, per map (SPEC-PEOPLE-GROUP-LIST-PROP.md).
*
* FAILS CLOSED. If a non-default list's membership cannot be loaded, the map gets NO rows and the
* console names the list and the URL. A map that silently showed all 2,107 while an embed asked for
* Project 700 would be showing the wrong list with no sign of it.
*
* Pure except for the fetch; safe outside a Vue setup().
*/
import registry from './lists.json'
import { resolveAssetsBase } from '../utils/embedBase.js'
export const LIST_DEFAULT = registry.default
const byKey = new Map()
for (const l of registry.lists) {
byKey.set(l.id, l)
for (const a of l.aliases || []) byKey.set(String(a).toLowerCase(), l)
}
/** Every list the registry defines: { id, label, definition?, source, filter }. */
export function peopleGroupLists() { return registry.lists }
/** The registry entry for an id or alias, or null. */
export function listInfo(idOrAlias) {
return byKey.get(String(idOrAlias ?? '').toLowerCase().trim()) || null
}
/** Unwrap a ref/computed to its config object; parse a JSON string; pass plain objects through. */
function unwrapConfig(profileConfig) {
let cfg = profileConfig
if (cfg && typeof cfg === 'object' && 'value' in cfg && !('list' in cfg) && !('profile' in cfg)) cfg = cfg.value
if (typeof cfg === 'string') { try { cfg = JSON.parse(cfg) } catch { cfg = null } }
return cfg && typeof cfg === 'object' ? cfg : null
}
const warned = new Set()
/** The list this map should show, as a registry id. Always a known id. */
export function resolvePeopleGroupList(profileConfig) {
const cfg = unwrapConfig(profileConfig)
const fromConfig = cfg?.list
const fromUrl = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('list') : null
const fromWindow = typeof window !== 'undefined' ? window.MAP_LIST : undefined
const raw = fromConfig || fromUrl || fromWindow || LIST_DEFAULT
const hit = listInfo(raw)
if (hit) return hit.id
if (!warned.has(raw)) {
warned.add(raw)
console.warn(`[list] unknown people-group list "${raw}", showing "${LIST_DEFAULT}". Known: ${registry.lists.map((l) => l.id).join(', ')}`)
}
return LIST_DEFAULT
}
const membership = new Map()
/** The set of DOXA slugs in a list (fetched once per URL). Rejects if it cannot be loaded. */
export function loadListMembership(id, profileConfig) {
const url = `${resolveAssetsBase(unwrapConfig(profileConfig))}/people-groups/lists/${id}.json`
if (!membership.has(url)) {
membership.set(url, fetch(url)
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
.then((d) => new Set(Array.isArray(d?.slugs) ? d.slugs : []))
.catch((e) => { membership.delete(url); throw new Error(`list "${id}" membership ${url}: ${e.message}`) }))
}
return membership.get(url)
}
/**
* Keep the rows that belong to the resolved list. The default list returns `rows` itself, untouched
* and without a fetch. `key` reads a row's slug (default: row.slug).
*/
export async function applyPeopleGroupList(rows, profileConfig, { key = (r) => r?.slug } = {}) {
const id = resolvePeopleGroupList(profileConfig)
if (id === LIST_DEFAULT || !Array.isArray(rows)) return rows
try {
const slugs = await loadListMembership(id, profileConfig)
return rows.filter((r) => slugs.has(key(r)))
} catch (e) {
console.error(`[list] ${e.message}. Showing no people groups rather than the wrong list.`)
return []
}
}

View file

@ -210,7 +210,103 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
<div class="fine">${esc(t('privacy_note'))}</div>`);
}
// ── PAGES 2..n: the people groups, 20 per sheet ─────────────────────────────
// ── PAGE 2: the funnel — how this list was narrowed out of every people group ─
//
// WHY THIS PAGE EXISTS. Driver 2026-09-26, via the shepherd: the packets go to ALL partners for
// the largest prayer campaign, and a partner holding one list needs to see where their list sits
// in the whole. The narrowing IS the argument: 12,379 people groups on earth, 7,341 of them
// unreached, 4,754 frontier, 2,107 on this network's list, and N of those in the reader's hand.
//
// ★ IT RENDERS NUMBERS IT DID NOT COMPUTE, ON PURPOSE. Every count is READ from the caller's
// `funnel` object, which comes from data/people-groups/vision-funnel.json — the file
// library/scripts/people-groups/build-vision-funnel.mjs generates from the IMB CSV and the live
// DOXA list, and which ALREADY feeds the Vision Card. Recomputing them here would create a second
// source of truth for the same fact, and the two would drift into "two honest surfaces that
// disagree" — each internally consistent, together telling a reader the project cannot count.
// If a number is wrong, it is wrong in the generator, and it is wrong in the Vision Card too.
//
// ⚠ NO STEP IS HARDCODED AND NEITHER IS THE NUMBER OF STEPS. `funnel.steps` is walked as given,
// so when the `list` prop lands (exp-shepherd, Tier A: profile-config.list / ?list= /
// window.MAP_LIST, default 'doxa') and the 7k / 4.7k / 700 lists plug in, their own step chains
// render with no change here. A step whose id has no string falls back to the id itself rather
// than rendering blank.
//
// ⚠ K AND N ARE READ AT RENDER TIME, NEVER TYPED. Measured 2026-09-26: the brief said "~30 lists
// of 50-100" and the catalogue says K=27 with counts from 8 to 118 — west-africa 118,
// asia-india-rajasthan 116, uncovered-scatter 8. Only about half sit inside 50-100, so this page
// never claims that band; it states the share the data can defend. Shepherd approved exactly this
// (ENDTOKEN-DXS-UPG-KDECIDE), K=27 including the two residual buckets until the Driver rules.
//
// ⚠ NUMERALS STAY WESTERN. num() is toLocaleString(lang), and for the bare `ar`/`hi` tags CLDR's
// default numbering system is `latn` — verified: 12,379 and 2,107 in both. Arabic-Indic digits
// need an explicit -u-nu-arab, which nothing here asks for. The brief requires numerals untouched,
// and reusing num() is also what keeps the funnel's digits identical to every other packet page.
function funnelPage(zone, groups, funnel) {
if (!funnel || !Array.isArray(funnel.steps) || !funnel.steps.length) return '';
const steps = funnel.steps;
const top = steps[0] && steps[0].groups;
// The widest bar is the first step; every bar is drawn proportional to it, with a floor so the
// smallest step is still a visible bar rather than a hairline.
const BAR_W = W - 2 * M - 300;
const width = (n) => Math.max(26, Math.round(BAR_W * (Number(n) / Number(top || 1))));
const rows = steps.map((s, i) => {
const label = t(`funnel_${s.id}`) !== undefined ? t(`funnel_${s.id}`) : s.id;
const pop = Number.isFinite(Number(s.population))
? `<div style="font:400 15px Georgia,serif;color:#a89f8c;margin-top:1px">${num(s.population)} ${esc(t('funnel_people'))}</div>`
: '';
// The last IMB step is the network's own list — tint it so the eye lands where the reader is.
const fillCol = i === steps.length - 1 ? '#7a3b2e' : '#cdbf9e';
return `<div style="margin-bottom:22px">
<div style="font:700 34px Georgia,serif;color:#1b1a17;line-height:1">${num(s.groups)}</div>
<div style="font:400 18px Georgia,serif;color:#3b3226;margin-top:2px">${esc(label)}</div>
${pop}
<div style="height:12px;width:${width(s.groups)}px;background:${fillCol};margin-top:6px;border-radius:2px"></div>
</div>`;
}).join('');
// THIS list, the step the reader is holding. `groups.length` is the packet's own row count, so
// the page cannot disagree with the pages that follow it.
const mine = groups.length;
const total = Number(funnel.listTotal ?? (steps[steps.length - 1] || {}).groups) || 0;
const pct = total ? (100 * mine / total) : 0;
// ⚠ A SHARE MUST NEVER ROUND TO ZERO — zero reads as "this packet covers none of them", which is
// false for every list. Measured against the real catalogue (counts 1..118 of 2,107): one decimal
// gave the single-group fixture "0.0%" when the true value is 0.0475%. So: integer at or above
// 10%, one decimal from 1%, two below that, then strip trailing zeros so 0.9967 prints "1" rather
// than "1.00". Verified across 1, 2, 8, 20, 21, 43, 95, 118 and 300 groups.
const pctStr = pct >= 10
? String(Math.round(pct))
: pct.toFixed(pct >= 1 ? 1 : 2).replace(/\.?0+$/, '');
const share = funnel.listCount
? `<div class="funnel-share" style="font:700 22px Georgia,serif;color:#7a3b2e;margin-top:4px">
${esc(fill(t('funnel_share'), { k: num(funnel.listCount), pct: pctStr, total: num(total) }))}</div>`
: '';
const foot = (funnel.sources && funnel.sources.imb)
? esc(fill(t('funnel_foot'), {
imb_pub: funnel.sources.imb.publisher || '',
imb_date: funnel.sources.imb.captured || '',
unassigned: num(funnel.unassigned ?? 0),
total: num(total),
}))
: '';
return sheet(`
<div style="position:absolute;left:${M}px;top:44px;right:${M}px">
<div class="funnel-title" style="font:700 40px Georgia,serif;color:#1b1a17">${esc(t('funnel_title'))}</div>
<div style="font:italic 18px Georgia,serif;color:#6d766e;margin-top:6px">${esc(t('funnel_intro'))}</div>
</div>
<div style="position:absolute;left:${M}px;top:${M + 96}px;right:${M}px">${rows}</div>
<div style="position:absolute;left:${M}px;right:${M}px;bottom:74px">
<div class="funnel-mine" style="font:700 34px Georgia,serif;color:#7a3b2e;line-height:1">${num(mine)}</div>
<div style="font:400 18px Georgia,serif;color:#3b3226;margin-top:2px">${esc(t('funnel_this'))}</div>
${share}
</div>
<div class="fine">${foot}</div>`);
}
// ── PAGES 3..n: the people groups, 20 per sheet ─────────────────────────────
function listPages(zone, groups, photos) {
const pages = [];
for (let start = 0; start < groups.length; start += PER_PAGE) {
@ -329,13 +425,18 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// ── BUILD ONE PACKET ────────────────────────────────────────────────────────
// Assemble the whole document. photos is a Map(slug -> dataURL-or-URL); mapSvg is
// the poster's <svg> plus the stylesheet its var()s resolve against (see mapCss).
function buildPacket(zone, groups, photos, mapSvg) {
// `funnel` is OPTIONAL and the page is omitted when it is absent, so every existing caller keeps
// working unchanged and a host with no funnel data still gets a complete, correct packet. A page
// that cannot state true numbers must not appear at all — better a 4-section packet than one
// asserting figures it could not read.
function buildPacket(zone, groups, photos, mapSvg, funnel = null) {
const pages = [
mapPage(zone, groups, mapSvg),
funnelPage(zone, groups, funnel),
...listPages(zone, groups, photos),
pointsPage(),
wheelPage(),
];
].filter(Boolean);
// ⚠ dir MUST be set for right-to-left languages or the packet renders Arabic
// left-aligned — readable glyphs in the wrong place, which looks like a
// styling quirk rather than the bug it is. Verified in a browser: with
@ -346,5 +447,5 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
<body>${pages.join('\n')}</body></html>`;
}
return { buildPacket, mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
return { buildPacket, mapPage, funnelPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H };
}

View file

@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* build-lists.mjs — the membership of every people-group LIST, computed from data, never typed.
*
* node library/scripts/people-groups/build-lists.mjs
*
* READS library/lists/lists.json the lists, each a FILTER over IMB columns
* data/people-groups/people_groups.csv IMB / peoplegroups.org, one row per group × country
* data/people-groups/doxa-groups-snapshot.json DOXA's list (written by build-vision-funnel.mjs)
* WRITES data/people-groups/lists/<id>.json per non-default list:
* { id, label, definition, filter, counts: { imb, doxa }, slugs: [the DOXA slugs in the list] }
*
* A DOXA group belongs to a list when its IMB row (matched by ROP3 + country, the join the Vision
* Card funnel uses) passes the list's filter. `counts.imb` is the whole list as IMB counts it;
* `counts.doxa` is how many of those DOXA carries: what a Tier A map shows (peopleGroupList.js).
*
* FILTER VOCABULARY (all optional, ANDed):
* lostness: [0..6] IMB LostnessPriority (0 Frontier, 1 Pioneer, 2 Expanding Unreached, …)
* minPopulation: n IMB Population >= n
* engagement: [..] IMB EngagementStatus ('Engaged' | 'Unengaged')
* An unknown key FAILS the run: a filter nobody applies would silently widen the list.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const REGISTRY = JSON.parse(readFileSync(join(ROOT, 'library/lists/lists.json'), 'utf8'))
const CSV = join(ROOT, 'data/people-groups/people_groups.csv')
const SNAP = join(ROOT, 'data/people-groups/doxa-groups-snapshot.json')
const OUT = join(ROOT, 'data/people-groups/lists')
// Minimal RFC-4180 CSV parser (the same one build-vision-funnel.mjs uses).
function parseCsv(text) {
const rows = []; let row = []; let f = ''; let q = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (q) {
if (c === '"') { if (text[i + 1] === '"') { f += '"'; i++ } else q = false } else f += c
} else if (c === '"') q = true
else if (c === ',') { row.push(f); f = '' }
else if (c === '\n') { row.push(f); rows.push(row); row = []; f = '' }
else if (c !== '\r') f += c
}
if (f || row.length) { row.push(f); rows.push(row) }
const head = rows.shift().map((h) => h.replace(/^/, ''))
return rows.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i]])))
}
const KNOWN = new Set(['lostness', 'minPopulation', 'engagement'])
function predicate(filter, id) {
for (const k of Object.keys(filter || {})) if (!KNOWN.has(k)) { console.error(`list "${id}": unknown filter key "${k}"`); process.exit(1) }
return (r) =>
(!filter.lostness || filter.lostness.includes(Number(r.LostnessPriority))) &&
(!filter.minPopulation || (Number(r.Population) || 0) >= filter.minPopulation) &&
(!filter.engagement || filter.engagement.includes(r.EngagementStatus))
}
if (!existsSync(SNAP)) { console.error(`no DOXA snapshot at ${SNAP}: run build-vision-funnel.mjs first`); process.exit(1) }
const imb = parseCsv(readFileSync(CSV, 'utf8'))
const snapshot = JSON.parse(readFileSync(SNAP, 'utf8'))
const byKey = new Map(imb.map((r) => [r.ROP3Code + '|' + r.ISOAlpha3, r]))
mkdirSync(OUT, { recursive: true })
for (const l of REGISTRY.lists) {
if (l.source === 'doxa' && !l.filter) continue // the default list: every DOXA row, no file
if (l.source !== 'imb') { console.error(`list "${l.id}": unknown source "${l.source}"`); process.exit(1) }
const keep = predicate(l.filter, l.id)
const slugs = snapshot.groups.filter((g) => { const r = byKey.get(g.rop3 + '|' + g.country); return r && keep(r) }).map((g) => g.slug).sort()
const out = {
id: l.id, label: l.label, definition: l.definition || '', filter: l.filter,
sources: { imb: 'data/people-groups/people_groups.csv', doxa: `${snapshot.source} (fetched ${snapshot.fetched})` },
counts: { imb: imb.filter(keep).length, doxa: slugs.length },
slugs,
}
writeFileSync(join(OUT, `${l.id}.json`), JSON.stringify(out) + '\n')
console.log(l.id.padEnd(14), String(out.counts.imb).padStart(6), 'in the list ·', String(out.counts.doxa).padStart(5), 'of them DOXA groups')
}

View file

@ -165,7 +165,17 @@
}
],
"point4_passage": "And they came to the other side of the sea, into the country of the Gadarenes. When he had come out of the boat, immediately a man with an unclean spirit met him out of the tombs. He lived in the tombs. Nobody could bind him any more, not even with chains, because he had been often bound with fetters and chains, and the chains had been torn apart by him, and the fetters broken in pieces. Nobody had the strength to tame him. Always, night and day, in the tombs and in the mountains, he was crying out, and cutting himself with stones. When he saw Jesus from afar, he ran and bowed down to him, and crying out with a loud voice, he said, “What have I to do with you, Jesus, you Son of the Most High God? I adjure you by God, don’t torment me.” For he said to him, “Come out of the man, you unclean spirit!” He asked him, “What is your name?” He said to him, “My name is Legion, for we are many.” He begged him much that he would not send them away out of the country. Now on the mountainside there was a great herd of pigs feeding. All the demons begged him, saying, “Send us into the pigs, that we may enter into them.” At once Jesus gave them permission. The unclean spirits came out and entered into the pigs. The herd of about two thousand rushed down the steep bank into the sea, and they were drowned in the sea. Those who fed them fled, and told it in the city and in the country. The people came to see what it was that had happened. They came to Jesus, and saw him who had been possessed by demons sitting, clothed, and in his right mind, even him who had the legion; and they were afraid. Those who saw it declared to them what happened to him who was possessed by demons, and about the pigs. They began to beg him to depart from their region. As he was entering into the boat, he who had been possessed by demons begged him that he might be with him. He didn’t allow him, but said to him, “Go to your house, to your friends, and tell them what great things the Lord has done for you and how he had mercy on you.” He went his way, and began to proclaim in Decapolis how Jesus had done great things for him, and everyone marveled.",
"point4_passage_credit": "Mark 5:1–20 · World English Bible (public domain)"
"point4_passage_credit": "Mark 5:1–20 · World English Bible (public domain)",
"funnel_title": "How this list was found",
"funnel_intro": "Every people group on earth, narrowed to the ones with no church and no worker.",
"funnel_all": "people groups in the world",
"funnel_unreached": "are unreached — under 2% evangelical",
"funnel_frontier": "are frontier — 0.1% or less, and almost no one is going",
"funnel_doxa": "are on this prayer network’s list",
"funnel_this": "are in THIS packet",
"funnel_share": "This packet is 1 of {k} lists · {pct}% of the {total}",
"funnel_foot": "Counts from {imb_pub}, captured {imb_date}. {unassigned} of the {total} are not yet in any list.",
"funnel_people": "people"
},
"es": {
"map_page_kicker": "GRUPOS ÉTNICOS NO EVANGELIZADOS",
@ -323,7 +333,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Cómo se formó esta lista",
"funnel_intro": "Todos los grupos étnicos de la tierra, reducidos a los que no tienen iglesia ni obrero.",
"funnel_all": "grupos étnicos en el mundo",
"funnel_unreached": "no están alcanzados: menos del 2% evangélico",
"funnel_frontier": "son de frontera: 0,1% o menos, y casi nadie va",
"funnel_doxa": "están en la lista de esta red de oración",
"funnel_this": "están en ESTE cuadernillo",
"funnel_share": "Este cuadernillo es 1 de {k} listas · {pct}% de los {total}",
"funnel_foot": "Cifras de {imb_pub}, tomadas el {imb_date}. {unassigned} de los {total} aún no están en ninguna lista.",
"funnel_people": "personas"
},
"fr": {
"map_page_kicker": "GROUPES ETHNIQUES NON ATTEINTS",
@ -481,7 +501,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Comment cette liste a été établie",
"funnel_intro": "Tous les peuples de la terre, réduits à ceux qui n’ont ni église ni ouvrier.",
"funnel_all": "peuples dans le monde",
"funnel_unreached": "sont non atteints — moins de 2% d’évangéliques",
"funnel_frontier": "sont des peuples frontières — 0,1% ou moins, et presque personne n’y va",
"funnel_doxa": "sont sur la liste de ce réseau de prière",
"funnel_this": "sont dans CE livret",
"funnel_share": "Ce livret est 1 des {k} listes · {pct}% des {total}",
"funnel_foot": "Chiffres de {imb_pub}, relevés le {imb_date}. {unassigned} des {total} ne figurent encore sur aucune liste.",
"funnel_people": "personnes"
},
"pt": {
"map_page_kicker": "GRUPOS ÉTNICOS NÃO ALCANÇADOS",
@ -639,7 +669,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Como esta lista foi formada",
"funnel_intro": "Todos os grupos étnicos da terra, reduzidos aos que não têm igreja nem obreiro.",
"funnel_all": "grupos étnicos no mundo",
"funnel_unreached": "não são alcançados — menos de 2% evangélicos",
"funnel_frontier": "são de fronteira — 0,1% ou menos, e quase ninguém vai",
"funnel_doxa": "estão na lista desta rede de oração",
"funnel_this": "estão NESTE caderno",
"funnel_share": "Este caderno é 1 de {k} listas · {pct}% dos {total}",
"funnel_foot": "Números de {imb_pub}, coletados em {imb_date}. {unassigned} dos {total} ainda não estão em nenhuma lista.",
"funnel_people": "pessoas"
},
"de": {
"map_page_kicker": "UNERREICHTE VOLKSGRUPPEN",
@ -797,7 +837,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Wie diese Liste entstand",
"funnel_intro": "Alle Volksgruppen der Erde, eingegrenzt auf die ohne Gemeinde und ohne Mitarbeiter.",
"funnel_all": "Volksgruppen weltweit",
"funnel_unreached": "sind unerreicht — unter 2% evangelikal",
"funnel_frontier": "sind Frontier — 0,1% oder weniger, und fast niemand geht hin",
"funnel_doxa": "stehen auf der Liste dieses Gebetsnetzwerks",
"funnel_this": "sind in DIESEM Heft",
"funnel_share": "Dieses Heft ist 1 von {k} Listen · {pct}% der {total}",
"funnel_foot": "Zahlen von {imb_pub}, erhoben am {imb_date}. {unassigned} der {total} sind noch auf keiner Liste.",
"funnel_people": "Menschen"
},
"it": {
"map_page_kicker": "GRUPPI ETNICI NON RAGGIUNTI",
@ -955,7 +1005,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Come è nata questa lista",
"funnel_intro": "Tutti i gruppi etnici della terra, ridotti a quelli senza chiesa e senza operai.",
"funnel_all": "gruppi etnici nel mondo",
"funnel_unreached": "non sono raggiunti — meno del 2% evangelici",
"funnel_frontier": "sono di frontiera — 0,1% o meno, e quasi nessuno ci va",
"funnel_doxa": "sono nella lista di questa rete di preghiera",
"funnel_this": "sono in QUESTO libretto",
"funnel_share": "Questo libretto è 1 di {k} liste · {pct}% dei {total}",
"funnel_foot": "Dati da {imb_pub}, rilevati il {imb_date}. {unassigned} dei {total} non sono ancora in nessuna lista.",
"funnel_people": "persone"
},
"ro": {
"map_page_kicker": "GRUPURI ETNICE NEATINSE",
@ -1113,7 +1173,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Cum a fost alcătuită această listă",
"funnel_intro": "Toate grupurile etnice ale pământului, restrânse la cele fără biserică și fără lucrător.",
"funnel_all": "grupuri etnice în lume",
"funnel_unreached": "sunt neatinse — sub 2% evanghelici",
"funnel_frontier": "sunt de frontieră — 0,1% sau mai puțin, și aproape nimeni nu merge",
"funnel_doxa": "sunt pe lista acestei rețele de rugăciune",
"funnel_this": "sunt în ACEASTĂ broșură",
"funnel_share": "Această broșură este 1 din {k} liste · {pct}% din {total}",
"funnel_foot": "Cifre de la {imb_pub}, colectate la {imb_date}. {unassigned} din {total} nu sunt încă pe nicio listă.",
"funnel_people": "persoane"
},
"ru": {
"map_page_kicker": "НЕОХВАЧЕННЫЕ НАРОДЫ",
@ -1271,7 +1341,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "Как был составлен этот список",
"funnel_intro": "Все народы земли, сведённые к тем, у кого нет ни церкви, ни работника.",
"funnel_all": "народов в мире",
"funnel_unreached": "неохваченные — менее 2% евангельских верующих",
"funnel_frontier": "это народы-фронтир — 0,1% или меньше, и почти никто к ним не идёт",
"funnel_doxa": "в списке этой молитвенной сети",
"funnel_this": "в ЭТОМ буклете",
"funnel_share": "Этот буклет — 1 из {k} списков · {pct}% от {total}",
"funnel_foot": "Данные {imb_pub}, собраны {imb_date}. {unassigned} из {total} пока не входят ни в один список.",
"funnel_people": "человек"
},
"ar": {
"map_page_kicker": "شعوب غير مبلَّغة",
@ -1429,7 +1509,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "كيف تكوّنت هذه القائمة",
"funnel_intro": "كل المجموعات الشعبية على الأرض، مُصفّاة إلى التي بلا كنيسة وبلا عامل.",
"funnel_all": "مجموعة شعبية في العالم",
"funnel_unreached": "غير مبلَّغة — أقل من 2% إنجيليين",
"funnel_frontier": "مجموعات حدودية — 0.1% أو أقل، ولا يكاد أحد يذهب إليها",
"funnel_doxa": "مدرجة في قائمة شبكة الصلاة هذه",
"funnel_this": "في هذا الكُتيّب",
"funnel_share": "هذا الكُتيّب هو 1 من {k} قائمة · {pct}% من {total}",
"funnel_foot": "الأرقام من {imb_pub}، جُمعت في {imb_date}. {unassigned} من {total} ليست بعد في أي قائمة.",
"funnel_people": "نسمة"
},
"hi": {
"map_page_kicker": "अनछुए जनसमूह",
@ -1587,7 +1677,17 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "यह सूची कैसे बनी",
"funnel_intro": "पृथ्वी के सभी जनसमूह, उन तक सीमित जिनके पास न कलीसिया है न कार्यकर्ता।",
"funnel_all": "जनसमूह संसार में",
"funnel_unreached": "अप्राप्त हैं — 2% से कम सुसमाचारी",
"funnel_frontier": "सीमांत हैं — 0.1% या उससे कम, और लगभग कोई नहीं जाता",
"funnel_doxa": "इस प्रार्थना नेटवर्क की सूची में हैं",
"funnel_this": "इस पुस्तिका में हैं",
"funnel_share": "यह पुस्तिका {k} सूचियों में से 1 है · {total} का {pct}%",
"funnel_foot": "आंकड़े {imb_pub} से, {imb_date} को लिए गए। {total} में से {unassigned} अभी किसी सूची में नहीं हैं।",
"funnel_people": "लोग"
},
"zh": {
"map_page_kicker": "未得之民",
@ -1745,6 +1845,16 @@
"Matthew 28:19–20"
]
}
]
],
"funnel_title": "这份名单是怎么来的",
"funnel_intro": "地上所有的族群,收窄到那些没有教会、没有工人的族群。",
"funnel_all": "个族群在世上",
"funnel_unreached": "是未得之民——福音派不足 2%",
"funnel_frontier": "是前沿族群——0.1% 或更少,几乎无人前往",
"funnel_doxa": "在这个祷告网络的名单上",
"funnel_this": "在这本祷告册里",
"funnel_share": "这本祷告册是 {k} 份名单之一 · 占 {total} 的 {pct}%",
"funnel_foot": "数据来自 {imb_pub},采集于 {imb_date}。{total} 中有 {unassigned} 还不在任何名单上。",
"funnel_people": "人"
}
}

View file

@ -111,6 +111,29 @@ const { mapPage, listPages, pointsPage, wheelPage, css, mapCss, PER_PAGE, W, H }
// ── DATA ────────────────────────────────────────────────────────────────────
const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
// ── THE PAGE-2 FUNNEL ───────────────────────────────────────────────────────
// The same context the browser path builds in upg-100-list.vue's packetFunnel(), so the Node
// generator and the in-page print cannot disagree about page 2 any more than they can about the
// pages around it.
//
// ★ READ, NEVER COUNTED. The four steps come verbatim from vision-funnel.json, generated by
// build-vision-funnel.mjs from the IMB CSV and the live network list, and already driving the
// Vision Card. K and the unassigned count ARE measured here — but from the catalogue that defines
// them, at build time, so no figure on the page is a typed constant.
// A missing file yields null, and buildPacket omits page 2 rather than printing numbers it could
// not read.
const FUNNEL = (() => {
try {
const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8'));
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
...(clusters.uncovered_lists || [])];
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
const assigned = lists.reduce((s, l) => s + (Number(l.count) || 0), 0);
return { ...raw, listCount: lists.length, listTotal, unassigned: Math.max(0, listTotal - assigned) };
} catch (_) { return null; }
})();
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
@ -194,11 +217,14 @@ async function fetchPhotos(groups, limit = 16) {
// the shared renderer, so the two packets cannot drift.
async function buildPacket(zone, groups, mapSvg) {
const photos = await fetchPhotos(groups);
const html = R.buildPacket(zone, groups, photos, mapSvg);
const html = R.buildPacket(zone, groups, photos, mapSvg, FUNNEL);
const withPhoto = groups.filter((g) => photos.has(g.slug)).length;
// 1 map + N list pages + points + wheel — asked of the renderer, not recomputed
// here, so this count cannot disagree with what was actually emitted.
const pageCount = 1 + Math.ceil(groups.length / R.PER_PAGE) + 2;
// ★ COUNT THE SHEETS IN THE EMITTED HTML, do not re-derive them from a formula. The old line
// computed `1 + ceil(n/PER_PAGE) + 2` while its own comment claimed the number was "asked of the
// renderer, not recomputed here" — it WAS recomputed, and adding the funnel page silently made it
// wrong by one, which is precisely the drift the comment warned about. Counting the artifact
// cannot disagree with the artifact.
const pageCount = (html.match(/class="sheet /g) || []).length;
return { html, pageCount, withPhoto };
}

View file

@ -137,39 +137,43 @@ export class MSTClusteringUtil {
if (validPoints.length < 2) return [];
// Step 1: Build all candidate edges
const edges = [];
for (let i = 0; i < validPoints.length; i++) {
for (let j = i + 1; j < validPoints.length; j++) {
const distance = this._calculateDistance(validPoints[i], validPoints[j]);
if (distance <= maxConnectionDistance) {
edges.push({
from: i,
to: j,
distance,
fromPoint: validPoints[i],
toPoint: validPoints[j]
});
}
}
}
// Step 2: Sort edges by distance ascending
edges.sort((a, b) => a.distance - b.distance);
// Step 3: Kruskal via Union-Find
const uf = this._createUnionFind(validPoints.length);
// ★ PRIM, O(n²) time and O(n) memory — NOT all-pairs Kruskal (2026-09-26). Kruskal built and
// sorted every pair: ~2.2 million edge objects for the 2,107-pin roster, ~12 s ON THE MAIN THREAD
// (twice, with the stats pass), so choosing "MST" froze the page and a browser gate read the map
// before any line existed ("MST drew no cluster lines"). Prim over the implicit complete graph
// gives a minimum spanning tree of the same total length with no edge list; starting a new tree
// whenever the nearest outside point is beyond maxConnectionDistance gives the same spanning
// FOREST Kruskal did. Ties may pick a different, equally short edge.
const n = validPoints.length;
const lat = validPoints.map(p => parseFloat(p.latitude));
const lng = validPoints.map(p => parseFloat(p.longitude));
const inTree = new Uint8Array(n);
const best = new Float64Array(n).fill(Infinity);
const via = new Int32Array(n).fill(-1);
const mstEdges = [];
let edgesAdded = 0;
const targetEdges = validPoints.length - 1;
for (const edge of edges) {
if (edgesAdded >= targetEdges) break;
if (uf.union(edge.from, edge.to)) {
mstEdges.push(edge);
edgesAdded++;
let remaining = n;
while (remaining > 0) {
// Next point: the closest one to the growing tree, or a new root if none is within reach.
let u = -1, ud = Infinity;
for (let i = 0; i < n; i++) if (!inTree[i] && best[i] < ud) { ud = best[i]; u = i; }
if (u === -1 || ud > maxConnectionDistance) {
u = -1;
for (let i = 0; i < n; i++) if (!inTree[i]) { u = i; break; }
via[u] = -1;
}
inTree[u] = 1; remaining--;
if (via[u] >= 0) {
const f = via[u];
mstEdges.push({ from: f, to: u, distance: best[u], fromPoint: validPoints[f], toPoint: validPoints[u] });
}
for (let v = 0; v < n; v++) {
if (inTree[v]) continue;
const d = euclideanDistance(lat[u], lng[u], lat[v], lng[v]);
if (d < best[v]) { best[v] = d; via[v] = u; }
}
}
const edgesAdded = mstEdges.length;
const targetEdges = n - 1;
// Step 4: Convert to GeoJSON
const lineFeatures = mstEdges.map(edge => ({

23
library/utils/html.js Normal file
View file

@ -0,0 +1,23 @@
/**
* html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26).
*
* Popups are built as HTML strings and handed to setHTML(), which parses them. A people-group name,
* a description or a country label comes from DATA (an API or a partner's list), so interpolating it
* raw lets a `<img onerror>` in the data run in the host page. Escape every data value; pass every URL
* through safeUrl so a `javascript:` link cannot ride in on a data field.
*/
const MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '`': '&#96;' }
/** Text or attribute value → safe to interpolate into HTML (null/undefined → ''). */
export function escapeHtml(v) {
return v == null ? '' : String(v).replace(/[&<>"'`]/g, (c) => MAP[c])
}
/** An http(s) URL, escaped for an attribute; anything else (javascript:, data:, garbage) → fallback. */
export function safeUrl(u, fallback = '') {
try {
const url = new URL(String(u), typeof location !== 'undefined' ? location.href : 'https://example.invalid/')
if (url.protocol === 'http:' || url.protocol === 'https:') return escapeHtml(url.href)
} catch { /* not a URL */ }
return escapeHtml(fallback)
}

View file

@ -28,14 +28,17 @@
"check:card-deck": "node internal/check-card-deck.mjs",
"check:catalog-currency": "node internal/check-catalog-currency.mjs",
"check:catalog-identity": "node internal/check-catalog-identity.mjs",
"check:cdn-pins": "node internal/check-cdn-pins.mjs",
"check:click-sheet": "node plugin-designs/gospel-resources/scripts/verify-gospel-click-sheet.mjs",
"check:composed-filters": "node internal/check-gospel-composed-filters.mjs",
"check:composite": "node internal/check-composite-freshness.mjs",
"check:clustering-modes": "node plugin-designs/doxa-research-map/scripts/check-clustering-modes.mjs",
"check:composite-embed-knobs": "node internal/check-composite-embed-knobs.mjs",
"check:composite-interop": "node internal/check-composite-interop.mjs",
"check:composite-locale-leaks": "node plugin-designs/research-map-plus-grid/scripts/check-composite-locale-leaks.mjs",
"check:composite-reg": "node internal/check-composite-registration.mjs",
"check:composite-srcs": "node internal/check-composite-srcs.mjs",
"check:composites-mount": "node internal/check-composites-mount.mjs",
"check:coverage-consistent": "node internal/check-gospel-coverage-consistent.mjs",
"check:coverage-consistent:poison": "node internal/check-gospel-coverage-consistent.mjs --poison",
"check:csv-shape": "node internal/check-csv-export-shape.mjs",
@ -100,9 +103,13 @@
"check:library-index": "node internal/check-library-index.mjs",
"check:link-rot": "node internal/check-link-rot-sample.mjs",
"check:linked-vocabulary": "node plugin-designs/research-map-plus-grid/scripts/check-linked-vocabulary.mjs",
"check:list-prop-unit": "node internal/check-list-prop-unit.mjs",
"check:locale-reachable": "node internal/check-locale-reachable.mjs",
"check:manifest-covers": "node internal/check-manifest-covers-discovery.mjs",
"check:map-data-painted": "node internal/check-map-data-painted.mjs",
"check:map-data-painted:poison": "node internal/check-map-data-painted.mjs --poison",
"check:map-embed-cells": "node internal/check-map-embed-cells.mjs",
"check:map-fly-reduced-motion": "node internal/check-map-fly-reduced-motion.mjs",
"check:map-paints": "node internal/check-gospel-map-paints.mjs",
"check:map-paints:poison": "node internal/check-gospel-map-paints.mjs --poison",
"check:map-props-unit": "node internal/check-map-props-unit.mjs",
@ -112,12 +119,15 @@
"check:mfe-two-tab": "node internal/check-mfe-two-tab.mjs",
"check:mjs-parse": "node internal/check-mjs-parses.mjs",
"check:mobile": "bun run cap:sync && bun run cap:verify",
"check:mst-clustering": "node internal/check-mst-clustering.mjs",
"check:nav-reachable": "node internal/check-nav-reachable-mobile.mjs",
"check:nested-token": "node internal/check-nested-token-delivery.mjs",
"check:no-abs-paths": "node internal/check-no-absolute-paths.mjs",
"check:no-codename": "node internal/check-no-internal-codename.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",
"check:no-eval": "node internal/check-no-eval.mjs",
"check:no-free-identifiers": "node plugin-designs/research-map-plus-grid/scripts/check-no-free-identifiers.mjs",
"check:no-orphan-gates": "node internal/check-no-orphan-gates.mjs",
"check:no-public-binds": "node internal/check-no-public-binds.mjs",
"check:one-palette": "node internal/check-one-palette.mjs",
@ -127,6 +137,7 @@
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs",
"check:packets-all": "node internal/check-packets-all.mjs",
"check:parse": "node internal/inline-parse-check.mjs",
"check:popup-escaping": "node internal/check-popup-escaping.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
@ -136,6 +147,7 @@
"check:prayer-walk-planner": "node internal/check-prayer-walk-planner.mjs",
"check:print-geometry": "node internal/check-print-geometry.mjs",
"check:pin-dim-slider": "node plugin-designs/doxa-research-map/scripts/check-pin-dim-slider.mjs",
"check:profiles-mount": "node internal/check-composites-mount.mjs --all-profiles",
"check:provider-switch": "node internal/check-provider-switch.mjs",
"check:public-hygiene": "node internal/public-hygiene-check.mjs",
"check:published": "node internal/check-published-matches-build.mjs",
@ -185,9 +197,10 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"library-index": "node internal/generate-library-index.mjs",
"lists": "node library/scripts/people-groups/build-lists.mjs",
"packet-fixture": "node internal/generate-packet-fixture.mjs",
"packet:ci": "node internal/build-packet-fixtures.mjs",
"packets": "cd library/scripts/posters && VITE_API_BASE_URL=https://pray.doxa.life node packet.mjs",
@ -241,12 +254,15 @@
"check:capability-citations": "a doc that names a bundle as \"capability N\" must match manifest.json, which is what the BUILD computed. README captioned doxa-vision-card as cap-1; it ships 2 instances so it is parameterized (cap-2). The SAME error was fixed once in verify-5-capabilities.mjs and never reached the README — two copies of one fact, nothing forcing agreement.",
"check:capability-exemplars": "REGENERATES and content-compares (not mtimes: a full build rewrites manifest.json every time, so an mtime rule would cry wolf after every build and get switched off).",
"check:catalog-identity": "proves the LIVE server serves the CATALOG’s exact bytes, by hashing each live bundle and comparing sha384 against the manifest’s own integrity field. An HTTP 200 cannot do this: /app/doxa-maps/ returns 200 with a 2872-byte HTML SIGN-IN PAGE. Network-dependent, so NOT in build:unlocked and NOT in CI; SKIPs cleanly when unreachable. CATALOG=<dir> also requires the live manifest to match a catalog checkout.",
"check:cdn-pins": "every runtime third-party script/stylesheet in shipped code is an exact version listed in library/constants/cdn.js with its SRI hash (no floating ranges). --online re-fetches each and compares the hash.",
"check:click-sheet": "the COUNTRY-CLICK SHEET without a GPU: every country in the roster resolves to >=1 pin, every link the sheet would render is absolute, and has_audio stays the STRING \"unknown\" where the pool was never checked (collapsing it to false would read as \"no audio exists\"). It had been CRASHING on a path deleted when data moved out of public/, and its two inputs were a private by-country copy and the curated 2,106-row subset the sheet stopped reading — so it was unrunnable AND aimed at files the surface no longer uses. Joins by ISO3 now, not by country-name substring. Carries its own 4-case self-test. Static, sub-second.",
"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:composite": "a COMPOSITE is a COPY taken at its own build time: fails if a nested child's source is newer than the parent's bundle. NOT in build:unlocked on purpose — there everything was just built, so it could never catch the real case (a PARTIAL build). Run it before PUBLISH.",
"check:composite-embed-knobs": "a composite must pass the HOST's tk and provider down to the map it nests, proven on a genuinely foreign origin (tree served on one port, partner page on another). Our own leaves bake a token and assign window.MAP_TOKEN, so this only ever breaks where there is no leaf - the partner page the embed snippet is written for, and the case nothing else tested. Keyless maplibre asserts ZERO requests to api.mapbox.com, because 'it rendered' would pass on a build that quietly fell back with a baked key.",
"check:clustering-modes": "every option in the research map's clustering picker must DRAW something different. Choosing 'Native' changed nothing: mapStore never declared clusteringMode, so the picker wrote to a property nothing watched, AND the Clusters checkbox force-set 'mst' regardless of the store, so even a fixed picker would have snapped back on the next tick. Asserts on what the GL style RENDERS (circles, count labels, lines), not on state - 'clusteringMode === mapbox' would pass on a build still drawing MST. Also asserts no orphan layers between modes, and that MST and Network differ, since two labels for one behaviour is the same defect as a dead option.",
"check:composite-interop": "every link between a capability-4 composite's children exists on BOTH ends, headless: the event, attribute or config-key name is sent in the sender file and listened for in the receiver (a rename on one side fails, naming both files), the same literal is in every built bundle that ships an end, every composite is mapped (or says why it has no links), and no host lookup calls getCurrentInstance() inside a plain function, where Vue returns null when it runs from a watcher. Exists because Simple Research map -> grid died that way while a synthetic-event proof passed. Both ends existing is necessary, not sufficient: the runtime half is check:gallery-embed's linked phase.",
"check:composite-locale-leaks": "a composite rendered in a non-English locale must not show a string the catalogue already translates. The criterion is a LOOKUP, not a category: English catalogue holds the string as a value, this locale holds a different value for that key, the page still renders English. The previous sweep split strings into chrome/data/third-party and scored single words like 'Region' and 'Prayer' as data, so 28 leaks on Big Picture and 41 on Simple Research counted as zero. Surfaces that a lookup cannot judge - vendor map chrome, table DATA cells, facet-rail values - are suppressed by NAME with a stated reason, never by word.",
"check:composites-mount": "every capability-4/5 composite's OWN setup() and mounted hooks run without throwing, in en and ar, headless (no browser): vite loads each composite as its bundle entry defines it (configureApp, and ProfileLoader's provides where the entry mounts ProfileLoader), and @vue/runtime-core mounts it on a no-op renderer. Exists because 08f89f55 shipped two composites calling getRuntimeLocale/detectLocale/RTL_LOCALES with no import: the build was green, setup threw a ReferenceError in every locale, and Big Picture and Simple Research rendered nothing. Proves setup runs; cannot prove the children render.",
"check:coverage-consistent": "the gospel-resources surfaces must agree: no resource on an ISO RESERVED code (und/mul/mis/zxx), every resourced code NAMED, table reach recounted off the pins, no invented URL, tiers reconcile. Static, sub-second. Self-test: `bun run check:coverage-consistent:poison`, all 12 clauses (A-L) must fail. Clause L is the one that is not about consistency: it fails when the link-verification record goes stale, because 'link checked' is a claim about the present tense. Written after the same drift defect was found by hand three times.",
"check:csv-shape": "the export must OPEN as a table: header on line 0, every row matching. Run the `check:csv-shape:self-test` script for the NEGATIVE test (forces the provenance comment back to line 0 IN MEMORY and requires this gate to REJECT it) — the header used to claim 'NEGATIVE-TESTED' with no way to re-run it.",
"check:dashboard-geocoder-provider": "the research dashboard leaf at ?provider=maplibre makes ZERO requests to api.mapbox.com, and its search bars still suggest (built tree; browser gate). It was kept out of gates:post while the generated leaf hard-linked the Mapbox engine; the leaf now loads only the engine it is asked for.",
@ -287,26 +303,35 @@
"check:library-index": "the DERIVED library menu must not DRIFT from library/. REGENERATES the index into a throwaway tree (the generator, unmodified, over a symlinked library/) and diffs the BYTES — it used to compare mtimes, so `touch` or a rebase turned it red on a byte-identical doc. Fix: bun run library-index",
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
"check:linked-vocabulary": "the two halves of research-map-plus-grid must mean the SAME THING by a religion family. The map filters by the legend's FIRST-LETTER key ('B'), the grid by a DERIVED family name ('Buddhism'), and both read ONE shared table (library/colors/religion.js) in opposite directions - so a wrong link stays invisible: both halves look filtered and show different peoples. Compares the total reached over the composite's wire against the total the grid gives for the same facet on its own, and runs the MUTANT first (the legend key instead of the name), failing as NO-TEETH if the wrong mapping scores the same as the right one.",
"check:list-prop-unit": "the people-group LIST prop, measured in Node with no browser: the ladder (profile-config list > ?list= > window.MAP_LIST > doxa) and aliases, the default list untouched and unfetched, a list keeping exactly its membership, an unloadable list failing closed (no rows, never DOXA's whole list), and every imb list having a membership file that matches its registry filter.",
"check:manifest-covers": "every DISCOVERED (non-gated) bundle must be in the built manifest",
"check:map-data-painted": "Every DECOUPLED map leaf must have OUR DATA on it, not just a basemap. verify-map-paint asserts a live GL context, attribution and zero console errors — all TRUE of a flawless basemap carrying none of our zones, polygons or pins, so every data-layer regression lands green. Measured 2026-09-26 by starve-probe (abort /assets/{boundaries,tables,people-groups}/ and the people-groups API, leave the basemap alone): 9 of the 13 leaves still report canvas, GL, attribution and 0 errors, so the hole is the MAJORITY of the suite; the other 4 lose their canvas entirely and the existing gate already fails them, so they are deliberately NOT listed — a leaf list is a claim about where a failure would otherwise go unseen. Each leaf is compared AGAINST ITSELF (healthy vs starved) because healthy leaves span 459..2821 colours and any global floor is vacuous or flaky: check:map-paints' 40-colour floor passes a starved upg-100-list at 306. Two measures, pass on EITHER — palette ratio <=0.55 OR >=3% of pixels changed — because each alone false-reds a real leaf (dashboard-plus-grid's 686x340 pane repaints 26.4% of the canvas at palette ratio 0.788; demo-map transforms the palette at ratio 0.196 while repainting 5.0%). A data-free map moves NEITHER. Uses page.screenshot({clip}), not elementHandle.screenshot(), whose stability wait never settles on an animating dashboard canvas (that cost 3 leaves a 20s timeout). Refuses to judge (exit 75) if either run has no canvas, if the starve aborted 0 requests, if the images cannot be compared, or if the gate itself throws. Run check:map-data-painted:poison to prove it can still fail. Browser gate, @browser-slot-max-hold 2700.",
"check:map-data-painted:poison": "The mutation arm for check:map-data-painted, and MANDATORY because that gate's assertion is a DISJUNCTION (palette moved OR pixels moved) — a weakened assertion that still passes everything reads as coverage while proving nothing. Starves BOTH runs of our data, so neither measure can move, and requires every leaf to go RED. A green poison run means the bars have drifted into vacuity. Browser gate, @browser-slot-max-hold 2700.",
"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:map-fly-reduced-motion": "every camera move in useMapFly is an instant jump when the reader asks for reduced motion (prefers-reduced-motion), and flyToCoords honours caller options. Headless: the real composable against a recording map.",
"check:map-paints": "The map must put PIXELS on screen, not just features. Exists because injecting mapbox-gl.css collapsed this profile's canvas from 1440x900 to 1440x300 - a white rectangle - and EVERY assertion went green over it: verify-gospel-map counted 2,107 of 2,107 pins rendered, leaves-mount found a full shadow root, the i18n gates found every label translated and nothing clipped. queryRenderedFeatures does not care whether anything is visible. This screenshots the element and counts distinct colours (a basemap has hundreds, a blank rectangle one or two) and checks the canvas fills its container. --poison hides the canvas and requires a red verdict.",
"check:map-props-unit": "the engine and token props decided in the library, measured in Node with no browser: auto picks the engine from the token ladder, an explicit provider wins, Clear clears the page-wide token, and tokenUrl is fetched and honoured (26 cases, one per defect U1/U3/U7).",
"check:map-unmount-mid-init": "end-to-end, deterministic: holds mapbox-gl.js, opens a map plugin in the studio, switches away (destroying the pane) mid-initialise, then releases the script. Fails on any page error. REQUIRES --url. Exit 2 if the race was not staged.",
"check:map-unmount-race": "browser-free, sub-second: runs the shipped ensureEngineCSS with a null element (what an unmounted pane hands it) against a copy with its guard stripped; the guarded arm must resolve and the stripped arm must throw \"reading getRootNode\". By gospel-plugin, adopted by studio.",
"check:mobile": "deploy-2 CONTRACT check: needs NO JDK/SDK/device. cap sync is a file copy + plugin wiring, so it runs anywhere; cap:verify then asserts assets/public/index.html exists. Guards the SOFT FAILURE capacitor.config.json warns about: a wrong webDir yields an EMPTY assets/public and a BLANK WHITE SCREEN at launch, not an error.",
"check:mst-clustering": "the MST clustering mode draws a true minimum spanning tree/forest (same edge count and length as a reference Kruskal) and 2,107 pins take under 3 s on the main thread. Headless.",
"check:nav-reachable": "a cap-5 shell must have ONE on-screen control that opens its tab rail at phone width (needs `bun run serve` on 4173; browser gate, not in build:unlocked)",
"check:no-abs-paths": "no /home/… or /Users/… in any TRACKED file (a clone must work on any machine)",
"check:no-codename": "the development-era INTERNAL CODE NAME may not ship in any TRACKED file. Same hand-off family as check:no-abs-paths: meaningless to the Doxa team who inherit this, and invisible to every other gate (none read for vocabulary). Deliberately does NOT fail on the same letters appearing as the unix USERNAME inside a /home/… path — that is a portability defect check:no-abs-paths already reports better, and two gates disagreeing about one string teaches everyone to ignore both.",
"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.",
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
"check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
"check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
"check:popup-escaping": "the library's pin and region popups render hostile data (an <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
"check:poster-print-fallback": "clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
"check:profiles-mount": "check:composites-mount over EVERY profile of every bundle and every preset instance (with its props), in en and ar: any setup, watcher or mount error fails it. Headless, ~40 s. A source mount: a build-only error (a minified name hoisted out of order) needs the built bundle and is not covered.",
"check:provider-switch": "the provider switch must actually SWITCH: counts requests BY HOST (not window globals, which stay defined after a swap) and asserts the maplibre run stays within the leaf HTML's own declared api.mapbox.com preloads. Defaults to the upg-100 leaf; pass a URL or set BASE_URL for another. CONDITIONAL: SKIPs when nothing is served.",
"check:published": "what readers are SERVED must be what we BUILT. Every other gate measures the local tree, so all of them stay green while a stale publish serves something else — and manufactures bug reports that cannot be reproduced. Compares the sha384 per bundle. SKIPs when the published host is unreachable (a network gate that goes red on flaky wifi gets ignored).",
"check:quoted-scripts": "documentation/README-SECTION-3-cli-commands.md QUOTES the scripts block verbatim; it had silently drifted TWICE (a stale bunx cap:sync, and build:unlocked missing check:public-hygiene + check:label-joins). package.json is what RUNS — update the DOC.",
@ -331,6 +356,7 @@
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
"library-index": "regenerate documentation/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
"lists": "regenerate data/people-groups/lists/<id>.json, the DOXA slugs in each people-group list of library/lists/lists.json, from the IMB CSV and the DOXA snapshot. Re-run after either changes.",
"packet-fixture": "regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.",
"packet:ci": "build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.",
"packets": "the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch.",

View file

@ -80,6 +80,10 @@ const hostLang = computed(() => {
})
watchEffect(() => { if (hostLang.value) setRuntimeLocale(hostLang.value) })
// The host's people-group LIST (library/lists/), handed to both children as the only key of their
// profile-config, so nothing else about them changes. Absent = the default list.
const listConfig = computed(() => (config.value.list ? JSON.stringify({ list: config.value.list }) : ''))
const prayBaseUrl = computed(() => config.value.prayBaseUrl || '')
const languageCode = computed(() => config.value.languageCode || '')
@ -114,6 +118,7 @@ function onGroupsFiltered(payload) {
:count="matched"
:pray-base-url="prayBaseUrl"
:language-code="languageCode"
:profile-config="listConfig"
@research-filter="onResearchFilter"
/>
@ -139,6 +144,7 @@ function onGroupsFiltered(payload) {
:show-total="false"
:pray-base-url="prayBaseUrl"
:language-code="languageCode"
:profile-config="listConfig"
@groups-filtered="onGroupsFiltered"
/>
</div>

View file

@ -40,6 +40,7 @@ import { ref, reactive, watch, computed, onMounted, getCurrentInstance } from 'v
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { tStatic } from '@map/i18n/index.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// ★ THIS HEADER HAD NO i18n AT ALL, and it is the top of Big Picture. Every label below was an
// English literal passed as a PROP — FilterDropdown itself translates nothing, it renders what the
@ -61,6 +62,8 @@ const props = defineProps({
// injected data host so the header builds its dropdown options from the SAME source the grid uses.
prayBaseUrl: { type: String, default: '' },
languageCode: { type: String, default: '' },
// the composite's profile-config (JSON), for its people-group LIST (library/lists/).
profileConfig: { type: String, default: '' },
})
const emit = defineEmits(['research-filter'])
@ -131,7 +134,8 @@ async function loadOptions() {
try {
const res = await fetch(url)
const data = await res.json()
const list = Array.isArray(data.posts) ? data.posts : []
// Options come from the groups this page's people-group LIST shows (library/lists/).
const list = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], props.profileConfig)
options.wagf_region = extractFrom(list, 'wagf_region')
options.wagf_block = extractFrom(list, 'wagf_block')
options.rop1 = extractFrom(list, 'rop1')

View file

@ -88,7 +88,7 @@ const err = ref(null)
const PG_DOT = '#e11d48'
const uiStore = inject('uiStore', null)
const _lang = (() => { const v = inject('lang', null)?.value; return (v && v !== 'en') ? v : null })()
const dsm = new DataSourceManager({ locale: _lang })
const dsm = new DataSourceManager({ locale: _lang, profileConfig })
const groups = shallowRef([])
const dotCount = ref(0)
const selectedPin = useSelectedPin({ getMap: () => mapRef.value, getActivePinColor: () => PG_DOT })

View file

@ -53,6 +53,9 @@ import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponen
// ─── Composables (framework) ─────────────────────────────────────────────────
import { useMapInstance } from '@map/composables/useMapInstance.js'
// The SAME dataset the poster wall renders from, so a country's bbox has one source of truth
// rather than being copied through an attribute. Already in this bundle -- the wall imports it too.
import posterData from '@map/data/country-posters.json'
import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js'
import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js'
import { useMapEvents } from '@map/composables/useMapEvents.js'
@ -613,7 +616,7 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
// ─── Data composable ─────────────────────────────────────────────────────────
// Lean instantiation — no lookups, no detectLocale. The shared DSM handles
// {value,label} from pray-tools natively; raw codes fall through unchanged.
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale })
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale, profileConfig })
const mapData = useMapData({
mapId,
dataSourceId: dataSource.value,
@ -1441,6 +1444,53 @@ function switchTab(tabId) {
// ─── Legend → fly-to wiring ──────────────────────────────────────────────────
// LegendRows emits a window event when a row is clicked; we fly the map there.
// ─── "Show on map" FROM THE POSTER WALL ──────────────────────────────────────────────────────────
// ★ THIS LINK DID NOT EXIST. country-posters-mfe's onShowOnMap() switched to the Map view and
// returned, so the camera stayed at world view and nothing was selected -- measured by rega. The
// shell now records the requested country as a `select-country` ATTRIBUTE on this pane element and
// also announces it by event; both arrive here.
//
// RETAINED, because the request routinely arrives before this map can act on it. fitBounds needs a
// live map with a loaded style, and the first click happens while the pane is still coming up. The
// old failure mode in the sibling plugin was exactly this -- apply once against unready state, then
// discard with no record, so the user had to click twice. Here an early request is remembered in
// _pendingCountryId and drained when isMapReady flips.
let _pendingCountryId = null
let _selectCountryObserver = null
/** The ONE apply path. Returns true when the request was satisfied. */
function applySelectCountry(countryId) {
// ★ DESELECT RESTORES THE VIEW. rega measured that a second press left the camera on India:
// this branch used to clear the flag and return, so toggling OFF did nothing by construction.
// PosterWall's button IS a toggle (it tracks lastShown and sends listId: null on the second
// press), so a control the user can turn on but not off reads as broken even when the first
// press worked. Restore the configured opening view -- the same values useMapInstance booted
// with, so "off" means what it meant before anything was picked.
if (!countryId) {
_pendingCountryId = null
if (isMapReady.value) {
const c = profileConfig?.value?.center ?? mapDefaults.center ?? [20, 10]
const z = profileConfig?.value?.zoom ?? mapDefaults.zoom ?? 1.8
mapFly.flyToCoords?.({ longitude: c[0], latitude: c[1] }, z)
}
return true
}
const rec = (posterData.countries || []).find((c) => c.id === countryId || c.iso3 === countryId)
// An id we cannot resolve is NOT a readiness problem -- retaining it would spin forever.
if (!rec || !Array.isArray(rec.bbox) || rec.bbox.length !== 4) { _pendingCountryId = null; return true }
if (!isMapReady.value) { _pendingCountryId = countryId; return false } // not ready -- REMEMBER it
const [w, sLat, e, n] = rec.bbox
// The same call a legend row click makes, so the camera behaves identically however it was asked.
mapFly.fitBounds?.([[w, sLat], [e, n]], 60)
_pendingCountryId = null
return true
}
function onSelectCountry(evt) { applySelectCountry(evt?.detail?.countryId ?? null) }
// Drain when the map becomes usable -- the half that makes retention real.
watch(isMapReady, (ready) => { if (ready && _pendingCountryId) applySelectCountry(_pendingCountryId) })
function onLegendRowClick(evt) {
const detail = evt?.detail
if (!detail || detail.mapId !== mapId) return
@ -2047,6 +2097,21 @@ onMounted(async () => {
// (UX 2026-04-27). The active-tab scrollIntoView watcher above still keeps
// tapped tabs visible if the bar overflows.
window.addEventListener('doxa:legend-row-click', onLegendRowClick)
// The retained request, read from the ELEMENT rather than waited for: an attribute set before
// this component existed is still here now. Then observe it, since the shell rewrites it on
// every click. Both triggers end at applySelectCountry().
{
const host = mapContainer.value?.getRootNode?.()?.host
if (host) {
host.addEventListener('doxa:select-country', onSelectCountry)
const requested = host.getAttribute('select-country')
if (requested) applySelectCountry(requested)
_selectCountryObserver = new MutationObserver(() => {
applySelectCountry(host.getAttribute('select-country') || null)
})
_selectCountryObserver.observe(host, { attributes: true, attributeFilter: ['select-country'] })
}
}
window.addEventListener('legend:highlight', onLegendHighlight)
const dataPromise = mapData.loadData().catch(err => {
@ -2081,6 +2146,9 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener('doxa:legend-row-click', onLegendRowClick)
_selectCountryObserver?.disconnect()
_selectCountryObserver = null
mapContainer.value?.getRootNode?.()?.host?.removeEventListener('doxa:select-country', onSelectCountry)
window.removeEventListener('legend:highlight', onLegendHighlight)
if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null }
poster.value?.cleanup?.()

View file

@ -104,7 +104,7 @@ let offScroll = null
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)
@ -133,11 +133,34 @@ function go(id) {
// nothing in a dropped embed or a nested composite: the event fired into a document
// with no handler. Owning it here is what makes the two tabs one element rather
// than two pages that happen to share a bundle.
// Pane elements by id, so the map pane can be addressed after mount.
const paneEls = {}
const SHOW_ON_MAP = 'doxa-country-posters:show-on-map'
function onShowOnMap(e) {
const d = e?.detail || {}
if (d.instanceId && d.instanceId !== instanceId.value) return
go('map')
// ★ THE FORWARDING THAT WAS NEVER WRITTEN. This handler used to end at go('map'), so the tab
// changed and the map was never told anything at all -- rega measured exactly that: "the camera
// stays at world view, nothing selected". It is not a timing bug and no amount of waiting fixes
// it; the link simply did not exist.
//
// Same shape as upg-100's: RECORD the request as an attribute, then announce it by event.
// An attribute is state, so it survives custom-element upgrade and is still readable when the
// map pane finally gets there (and when its style finishes loading later still); the event is
// only the live path for a map that is already up. Both end in one applySelectCountry() inside
// the pane, so there is one apply path with two triggers.
//
// The attribute carries the country ID, not its geometry: countries-map resolves the id against
// the same library/data/country-posters.json the wall renders from, so the bbox has exactly one
// source of truth rather than being copied through an attribute.
const countryId = d.listId ?? null
const el = paneEls.map
if (!el) return
if (countryId) el.setAttribute('select-country', String(countryId))
else el.removeAttribute('select-country')
el.dispatchEvent(new CustomEvent('doxa:select-country', { detail: { countryId } }))
}
onMounted(() => {
@ -146,7 +169,12 @@ onMounted(() => {
if (!column) return
for (const pane of column.panes) {
if (!pane.config) continue
column.mountEl(pane.id)?.appendChild(paneElement(pane))
// ★ KEEP THE ELEMENT. It used to be created and dropped on the same line, so there was no
// handle to address the map pane with later -- which is part of why "Show on map" could only
// ever switch tabs here. upg-100-mfe already retained its pane elements; this matches it.
const el = paneElement(pane)
paneEls[pane.id] = el
column.mountEl(pane.id)?.appendChild(el)
}
// Tabs are a readout as well as a control — follow the user's scroll.
offScroll = column.onScroll(() => {

View file

@ -316,6 +316,11 @@ const sharedToken = useSharedMapToken()
const mapboxToken = computed(() => hostToken.value || sharedToken.value || '')
provide('mapboxToken', mapboxToken)
// The host's people-group LIST (library/lists/), threaded like the engine: into the dashboard's
// provided config below, and to the grid as the only key of its profile-config.
const hostList = computed(() => (typeof hostConfig.value?.list === 'string' ? hostConfig.value.list : ''))
const gridConfig = computed(() => (hostList.value ? JSON.stringify({ list: hostList.value }) : ''))
const SHARE_LEAF = 'dashboard-plus-grid/dashboard-plus-grid/index.html'
const shareBase = resolveEmbedBase(null)
provide('profileConfig', computed(() => ({
@ -327,6 +332,7 @@ provide('profileConfig', computed(() => ({
...(hostToken.value ? { tk: hostToken.value } : {}),
...(hostProvider.value ? { provider: hostProvider.value } : {}),
...(hostLang.value ? { lang: hostLang.value } : {}),
...(hostList.value ? { list: hostList.value } : {}),
})))
const appI18n = inject('appI18n', null)
@ -397,7 +403,8 @@ const sections = [
:gospel-access="filter.gospelAccess"
:religion-family="filter.religionFamily"
:language-family="filter.languageFamily"
:show-search="false" :show-filters="false" :show-total="false" />
:show-search="false" :show-filters="false" :show-total="false"
:profile-config="gridConfig" />
</template>
</StackLayout>
</template>

View file

@ -706,7 +706,7 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
// ─── Data composable ─────────────────────────────────────────────────────────
// Lean instantiation — no lookups, no detectLocale. The shared DSM handles
// {value,label} from pray-tools natively; raw codes fall through unchanged.
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale })
const dsm = new DataSourceManager({ sourcesConfig, locale: activeLocale, profileConfig })
const mapData = useMapData({
mapId,
dataSourceId: dataSource.value,

View file

@ -481,7 +481,7 @@ mapTheme.setLook('dark')
// search bar searches comes back in the selected language — the same `lang` param
// the people-group detail fetch uses (PeopleGroupDetail.vue). null → API default.
const _apiLocale = (() => { const v = inject('lang', null)?.value; return (v && v !== 'en') ? v : null })()
const dsm = new DataSourceManager({ locale: _apiLocale })
const dsm = new DataSourceManager({ locale: _apiLocale, profileConfig })
// ─── Map instance ─────────────────────────────────────────────────────────────
const { map, isMapReady, initializeMap, destroy } = useMapInstance({

View file

@ -72,6 +72,7 @@
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
@ -938,7 +939,8 @@ async function load() {
/* 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. */
const roster = (doxaData.rows || [])
// …and within that roster, only the groups of this page's people-group LIST (library/lists/).
const roster = await applyPeopleGroupList(doxaData.rows || [], profileConfig)
doxaTotal.value = roster.length
/**
* ═══ THE TIER COMES FROM THE INDEX, NOT FROM THE ROSTER'S COPY OF IT ════════════════════

View file

@ -40,6 +40,8 @@
*/
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import PosterWall from '@map/components/poster/PosterWall.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
// Named imports: only the list arrays ship in the bundle; the file's bookkeeping fields stay out.
@ -131,7 +133,7 @@ async function fetchListRecords(list) {
+ 'image_url,primary_language'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`)
const records = normalizeRecords(extractRecords(await resp.json()))
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
// ★ pg_slugs BYPASSES the shared evaluator — MEASURED live (2026-09-23): 12 of the 27
// lists (the entire India + China buckets) carry `{ type:'pg_slugs', values:[...] }`,
// and selector-evaluator.js documents that it "intentionally THROWS" on that type

View file

@ -22,6 +22,8 @@
* via a single setStyle(style,{diff:false}).
*/
import { inject, provide, ref, shallowRef, markRaw, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups.
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import SemanticTreeLegend from '@map/components/legends/SemanticTreeLegend.vue'
// The shared mobile bottom-sheet. Fed this profile's own tree through the `nodes`
@ -624,6 +626,16 @@ function ensureLayers() {
// F2-D2: brighter, thicker outline gives contrast on dark without washing out light.
paint: { 'line-color': matchColorExpr, 'line-width': 1.2, 'line-opacity': 0.9 },
})
// ★ THE REAL READINESS SIGNAL. onLegendSelect() returns early unless map.getLayer('tapestry-fill')
// exists, so a "Show on map" request that arrives before this function runs cannot hold a
// selection however correctly it was delivered. This is the third layer of the same defect --
// apply once against unready state, then discard -- and this is the point where the state is
// finally ready. Drains any request retained by applySelectZone().
if (_pendingZoneId) {
const id = _pendingZoneId
nextTick(() => { if (_pendingZoneId === id) applySelectZone(id) })
}
}
// ── 09b shift-select tool: selection highlight layers (D-2) ──────────────────
@ -1243,7 +1255,7 @@ async function packetRecords(zone) {
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 records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
let slugs;
if (zone.selector && zone.selector.type === 'pg_slugs') {
slugs = new Set(zone.selector.values || []);
@ -1280,15 +1292,59 @@ async function printPrayerPacket(zoneId) {
import('@map/scripts/posters/packet-strings.json').then((m) => m.default || m),
]);
const R = createPacketRenderer({ strings, lang: packetLang() });
await packetPrints.printPacket(zone, { mapSvg: await packetMapSvg(zoneId, R) });
await packetPrints.printPacket(zone, {
mapSvg: await packetMapSvg(zoneId, R),
funnel: await packetFunnel(),
});
}
// Every list across all three cluster buckets, flattened.
//
// ★ THE ONE SEAM THE `list` PROP WILL FEED. Driver 2026-09-26 via the shepherd: UPG-100 is the mass
// prayer mobiliser — a GENERATOR that takes a list in and puts posters, packets and cards out, so a
// partner can generate their own. Every list-consuming path in this profile goes through THIS
// function (allLists -> selectedLists, printPrayerPacket, the legend, the funnel), so when the
// universal `list` prop lands (exp-shepherd Tier A: profile-config.list / ?list= / window.MAP_LIST,
// default 'doxa') it replaces the body here and nothing else moves. Do not read `clusters` directly
// anywhere else; that is what makes the swap a one-line change instead of an audit.
function allLists() {
return [...(clusters.lists || []), ...(clusters.asia_lists || []),
...(clusters.uncovered_lists || [])];
}
// ── THE PAGE-2 FUNNEL CONTEXT ───────────────────────────────────────────────
// What the packet's funnel page needs that the packet itself cannot know: the nested IMB/network
// steps, how many lists the catalogue holds, and how many groups are in no list at all.
//
// ★ IT READS, IT DOES NOT COUNT. The four steps come verbatim from
// data/people-groups/vision-funnel.json, which build-vision-funnel.mjs generates from the IMB CSV
// and the live network list and which ALREADY drives the Vision Card. Re-deriving them here would
// give the same fact two sources that drift apart.
//
// ⚠ K AND THE UNASSIGNED COUNT ARE MEASURED FROM THE CATALOGUE AT CALL TIME, NEVER TYPED. Measured
// 2026-09-26: K = 27 (13 + 12 + 2 buckets) with per-list counts from 8 to 118, against a brief that
// said "~30 lists of 50-100" — only about half sit in that band, so no copy here claims it. The
// 27 lists account for 2,086 of the 2,107, leaving 21 in no list; the footnote states that plainly
// rather than letting a partner discover it by adding up rows (shepherd, ENDTOKEN-DXS-UPG-KDECIDE).
async function packetFunnel() {
try {
const raw = (await import('../../data/people-groups/vision-funnel.json')).default;
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
const lists = allLists();
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
const assigned = lists.reduce((sum, l) => sum + (Number(l.count) || 0), 0);
return {
...raw,
listCount: lists.length,
listTotal,
unassigned: Math.max(0, listTotal - assigned),
};
} catch (_) {
// A missing funnel file omits page 2 rather than failing the packet — see buildPacket.
return null;
}
}
// ── 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
@ -1321,7 +1377,7 @@ async function selectionAsList() {
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 records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set();
for (const l of lists) {
@ -1356,7 +1412,16 @@ async function makeSelectionPacket() {
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 });
// ⚠ "1 OF 27 LISTS" IS FALSE FOR A CUSTOM SELECTION — it is not one of the catalogue's lists at
// all. The NARROWING is still true and still the point, and "N of the 2,107" stays exactly true
// because N is the selection's own row count. So a multi-list selection keeps the funnel steps
// and drops listCount, which funnelPage already treats as "omit the share line". Same judgement
// the map above makes: print nothing rather than something that reads true and is not.
const funnel = await packetFunnel();
await packetPrints.printPacket(sel, {
mapSvg,
funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null),
});
} 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);
@ -1514,6 +1579,61 @@ function attachPinHandlers() {
// one page therefore never cross-talk.
// detail.listId = a zoneKey to select, or NULL to deselect.
let _selectZoneHandler = null;
// ★ A REQUEST THAT ARRIVES TOO EARLY IS NOW REMEMBERED, NOT DROPPED.
// rega measured the defect: the FIRST "Show on map" on a poster card did nothing, while a SECOND
// one after the map had loaded isolated the zone and fitted the camera. So the listener and the
// handler both work -- the first request simply died before this component was ready for it.
// There were two ways for it to die and the fix has to survive both, which is why it retains the
// request rather than trying to attach the listener sooner:
// 1. the listener is attached at the END of an async onMounted, after 5 awaits, and only
// `if (host)` -- an element dispatch before that has no replay and is gone;
// 2. even once attached, the zone id is resolved against legendNodes, which is populated by the
// data fetch. Before that, findLegendNode returns nothing and the old code did
// `if (node) onLegendSelect(node)` with no else -- discarded silently, with no record that
// anything had been asked for.
// Retaining covers both without depending on which one fired.
let _pendingZoneId; // undefined = nothing outstanding
let _selectZoneObserver = null; // watches the retained `select-zone` attribute
/** The ONE apply path. Returns true when the request was satisfied (or was a deselect). */
// ★ THE APPLY REPORTS ITS OWN OUTCOME. rega's timeline proved delivery works (attribute written
// t=4106, and main already reads it at registration t=4763) and a headless test proved the lookup
// works (asia-india-rajasthan -> "Northwest India", 37 legend keys). So the failure is DOWNSTREAM of
// onLegendSelect and invisible: nothing throws, and "selected but not visibly selected" is
// indistinguishable at the rendered layer from "never selected". Writing the outcome onto the HOST
// makes it readable by rega's existing 50 ms poll, with no CDP and no new instrument.
function _markZoneState(state, zoneId) {
try {
const h = mapEl.value?.getRootNode?.()?.host;
if (!h) return;
h.setAttribute('data-select-zone-state', state);
if (zoneId) h.setAttribute('data-select-zone-applied', String(zoneId));
} catch (_) { /* never let a diagnostic break the apply */ }
}
function applySelectZone(zoneId) {
if (!zoneId) { onLegendSelect(null); _pendingZoneId = undefined; _markZoneState('cleared', ''); return true; }
const node = findLegendNode(legendNodes.value, zoneId);
if (!node) { _pendingZoneId = zoneId; _markZoneState('unresolved', zoneId); return false; } // not ready -- REMEMBER it
onLegendSelect(node);
// selectedKey (:163, "currently isolated zoneKey (or null)") is this component's own record of the
// isolation, so 'applied-but-no-selection' means the path was REACHED and its EFFECT lost -- a
// different bug from delivery, and the one rega's timeline points at.
// ★ I first wrote selectedZoneKey here, which does not exist; the try/catch would have swallowed
// the ReferenceError and reported 'applied' every time. A diagnostic that cannot report failure is
// worse than none. main's check:no-free-identifiers (6b05f513) now catches this class.
// ★ IF THE EFFECT DID NOT TAKE, THE REQUEST IS STILL OUTSTANDING. rega's re-shoot on 27eed341
// read exactly this state twice, 13 ms apart, with the right id -- so the node WAS found and
// onLegendSelect WAS called, and the selection still did not hold. The cause is onLegendSelect's
// own first line: `if (!map || !map.getLayer('tapestry-fill')) return`. It bails silently when the
// map's tapestry layer is not up, which at t=4.9s it was not. So the readiness signal was never
// legendNodes or mapRef -- it is THE LAYER, and the old code cleared _pendingZoneId here as though
// the work were done. Keeping it means ensureLayers() can drain it the moment the layer exists.
if (!selectedKey.value) { _pendingZoneId = zoneId; _markZoneState('applied-but-no-selection', zoneId); return false; }
_markZoneState('applied', zoneId);
return true;
}
function _onSelectZone(ev) {
const zoneId = ev?.detail?.listId ?? null;
// REUSE THE CANONICAL SELECT PATH. onLegendSelect(node) is what a legend row
@ -1526,9 +1646,7 @@ function _onSelectZone(ev) {
// to the SAME legend node object and hands it to the SAME function.
// null => onLegendSelect(null), the canonical un-isolate that restores the full
// tapestry. That is what makes the wall's green toggle honest.
if (!zoneId) { onLegendSelect(null); return; }
const node = findLegendNode(legendNodes.value, zoneId);
if (node) onLegendSelect(node);
applySelectZone(zoneId);
}
// Depth-first lookup of a legend node by zoneKey/id. The tree is now grouped into
@ -1575,7 +1693,7 @@ async function enrichLegendFromRecords() {
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,latitude,longitude'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`list ${resp.status}`)
const records = normalizeRecords(extractRecords(await resp.json()))
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
countryNameMap = buildCountryNameMap(records)
legendNodes.value = buildLegendNodes(clusters, { deafCount: DEAF_COUNT, records })
} catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ }
@ -1826,7 +1944,7 @@ onMounted(async () => {
const fields = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,latitude,longitude'
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=en`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`list ${resp.status}`)
const records = normalizeRecords(extractRecords(await resp.json()))
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
// 09b shift-select §1(b): build iso3→display-name from the LIVE records so an
// ADM0 country click resolves to the exact label the legend shows (the ADM0
// geojson features carry only iso3 — no name). Falls back to ISO3_NAME inside
@ -2054,11 +2172,49 @@ onMounted(async () => {
if (host) {
_selectZoneHandler = _onSelectZone
host.addEventListener('doxa:select-zone', _selectZoneHandler)
// ★ THE RETAINED REQUEST, read from the ELEMENT rather than waited for.
// The shell also records the selection as a `select-zone` ATTRIBUTE on this element. An
// attribute survives custom-element upgrade and is readable whenever we get here, so a
// request made before this line ran is still available instead of having been dispatched
// into nothing. That is what makes the FIRST click work; the event above remains the live
// path for a map that is already up, so both triggers end at applySelectZone().
const requested = host.getAttribute('select-zone')
if (requested) applySelectZone(requested)
// And react to later writes, since the shell sets the attribute on every click.
_selectZoneObserver = new MutationObserver(() => {
applySelectZone(host.getAttribute('select-zone') || null)
})
_selectZoneObserver.observe(host, { attributes: true, attributeFilter: ['select-zone'] })
}
}
})
// When the legend data finally lands, satisfy anything that was asked for too early. This is the
// half that makes retention real -- without it the request is remembered and never applied.
//
// ★ ON nextTick, AND NOT AS A PRECAUTION. upg100-plugin flagged the race from experience with this
// file: watch([batchMode, isDark]) at :222 calls markLegendBatchRows() on nextTick, and this watcher
// would otherwise fire PRE-FLUSH -- so the selection would be applied against the legend rows that
// are about to be replaced by the re-render from these very legendNodes, and its row highlight
// could be stomped. That failure is invisible in the worst way: "the selection happened but you
// cannot see it" looks identical at the rendered layer to "the selection never happened", which is
// the bug this whole change exists to fix. Draining after the flush lets the rows exist and be
// marked first.
// ★ DRAIN ON EITHER READINESS SIGNAL, AND RE-READ THE ATTRIBUTE RATHER THAN THE BOOKKEEPING.
// rega measured legendNodes populated at t=1017 and the request at t=4106, so a legendNodes-change
// drain can never re-fire for it. Re-reading the host attribute makes the outcome independent of
// which hook fired when, which is the whole reason state was chosen over an event.
const _hostEl = () => { try { return mapEl.value?.getRootNode?.()?.host || null } catch (_) { return null } }
watch([legendNodes, mapRef], () => {
nextTick(() => {
const requested = _hostEl()?.getAttribute('select-zone') || _pendingZoneId || null
if (requested) applySelectZone(requested)
})
})
onBeforeUnmount(() => {
_selectZoneObserver?.disconnect();
_selectZoneObserver = null;
if (_selectZoneHandler) {
const host = mapEl.value?.getRootNode?.()?.host;
host?.removeEventListener('doxa:select-zone', _selectZoneHandler);

View file

@ -140,7 +140,7 @@ const paneEls = {}
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'locale', 'lang', 'isDark', 'qa', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)
@ -182,11 +182,24 @@ function onShowOnMap(e) {
const d = e?.detail || {}
if (d.instanceId && d.instanceId !== instanceId.value) return
go('map')
// The element is the target, so a pane that has not mounted yet simply gets nothing rather
// than throwing — the scroll still happens, which is the degradation we want.
paneEls.map?.dispatchEvent(new CustomEvent('doxa:select-zone', {
detail: { listId: d.listId ?? null },
}))
// ★ RECORD THE REQUEST, THEN ANNOUNCE IT — in that order, and the order is the fix.
// The comment that used to sit here called the miss "the degradation we want": a pane that had
// not mounted got nothing, and the scroll still happened. rega measured what that actually costs
// — the FIRST "Show on map" did nothing at all, and only a SECOND click, after the map had
// loaded, selected the zone. A control the user must press twice reads as broken.
//
// An element dispatch has no replay, so it can only reach a listener that already exists. An
// ATTRIBUTE is state: it survives custom-element upgrade, it is readable whenever the pane
// finally gets there, and it is still readable if the pane's data arrives later still. So the
// selection is written down first and the event is kept purely as the live path for a map that
// is already up. Both triggers end in the same applySelectZone() inside the pane, so there is one
// apply path with two triggers rather than two ways to select.
const zoneId = d.listId ?? null
if (paneEls.map) {
if (zoneId) paneEls.map.setAttribute('select-zone', String(zoneId))
else paneEls.map.removeAttribute('select-zone')
paneEls.map.dispatchEvent(new CustomEvent('doxa:select-zone', { detail: { listId: zoneId } }))
}
}
onMounted(() => {

View file

@ -45,6 +45,7 @@ import Fuse from '@map/components/search/fuzzy.js'
import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { tStatic } from '@map/i18n/index.js'
import { countrySlug } from './src/countries.js'
// ── DERIVED family axes (BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ──
@ -774,8 +775,12 @@ async function getUUPGs() {
// success. Both looked identical to "your filter matched nothing".
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
total.value = data.total
uupgs.value = Array.isArray(data.posts) ? data.posts : []
// The people-group LIST this embed shows (library/lists/). The default keeps every row, so the
// API's total stands; any other list is counted from the rows it kept.
const rows = Array.isArray(data.posts) ? data.posts : []
const listed = await applyPeopleGroupList(rows, props.profileConfig)
total.value = listed === rows ? data.total : listed.length
uupgs.value = listed
extractFilterOptions()
filterUUPGs()
} catch (error) {

View file

@ -55,7 +55,7 @@
:dir="uiDir"
:nav-label="tr('navLabel')"
:labels="{ toggleList: tr('toggleList'), openViews: tr('openViews'), expandList: tr('expandList'), collapseList: tr('collapseList'), collapse: tr('collapse') }"
:header="true"
:header="!headerHidden"
:active="activeKey"
@update:active="select">
@ -142,6 +142,14 @@
<p class="ps-token-note">{{ tr('tokenStoredHere') }}</p>
</form>
</span>
<!-- COLLAPSE THE HEADER: the plugin takes the full height (as the Research Gallery does), and the
floating button below brings the header back — the header is the props panel, so it must be
one click away. The choice is remembered in this browser. -->
<button type="button" class="ps-locale-btn ps-hide-header" :title="tr('hideHeader')" :aria-label="tr('hideHeader')"
@click="setHeaderHidden(true)">
<span aria-hidden="true">⌃</span>
<span class="ps-locale-name">{{ tr('hideHeader') }}</span>
</button>
</template>
<!-- Toolbar strip: the active element's identity and copy-embed. Kept to ONE line on purpose —
@ -180,6 +188,11 @@
GPU context (measured: canvas count 1→0 and webglcontextlost fired). Without this,
every map ever visited would hold a live context until the browser silently dropped
the OLDEST one and an old tab went blank with no error. -->
<!-- The way back when the header is collapsed: always visible, top corner, above every plugin. -->
<button v-if="headerHidden" type="button" class="ps-show-header" :title="tr('showHeader')" :aria-label="tr('showHeader')"
@click="setHeaderHidden(false)">
<span aria-hidden="true">⌄</span> {{ tr('showHeader') }}
</button>
<div class="ps-stage">
<section
v-for="row in flatRows" :key="row.key" class="ps-pane"
@ -210,6 +223,7 @@ import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapTok
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
const TOKEN_KEY = 'plugin-studio:mapbox-token'
const TOKEN_URL_KEY = 'plugin-studio:mapbox-token-url'
const HEADER_KEY = 'plugin-studio:header-hidden' // '1' = collapsed
const PROVIDER_KEY = 'plugin-studio:map-provider' // 'mapbox' | 'maplibre' | '' (each map's own default)
function readStored(k) { try { return (typeof window !== 'undefined' && window.localStorage.getItem(k)) || '' } catch (_) { return '' } }
function writeStored(k, v) { try { if (v) window.localStorage.setItem(k, v); else window.localStorage.removeItem(k) } catch (_) { /* storage unavailable */ } }
@ -231,6 +245,8 @@ const PS_SHADOW_CSS = `
.vtl-top { position:relative; z-index:3; }
.vtl-body { isolation:isolate; }
.ps-token { position:relative; display:inline-flex; }
.ps-show-header { position:fixed; top:8px; inset-inline-end:8px; z-index:2147483000; display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border:1px solid #d1d4cd; border-radius:999px; background:rgba(255,255,255,.94); color:#3b463d; font:600 12px system-ui, sans-serif; cursor:pointer; box-shadow:0 4px 14px rgba(0,0,0,.18); }
.ps-show-header:hover, .ps-show-header:focus-visible { background:#ffffff; outline:2px solid #92b195; }
.ps-token-dot { width:8px; height:8px; border-radius:50%; background:#b8bdb4; }
.ps-token-dot.is-yours, .ps-token-dot.is-url { background:#92b195; }
.ps-token-dot.is-built { background:#c9b36a; }
@ -494,6 +510,7 @@ export default defineComponent({
localeOpen: false,
// Map token control — the reader's own entry, persisted in localStorage (never in the build).
settingsOpen: false,
headerHidden: readStored(HEADER_KEY) === '1',
engineOpen: false,
userProvider: readStored(PROVIDER_KEY),
tokenBusy: false,
@ -743,6 +760,12 @@ export default defineComponent({
this.settingsOpen = false
} finally { this.tokenBusy = false }
},
// Collapse / restore the header (the props panel). Remembered in this browser.
setHeaderHidden(v) {
this.headerHidden = !!v
this.settingsOpen = false; this.engineOpen = false; this.localeOpen = false
writeStored(HEADER_KEY, v ? '1' : '')
},
setProvider(id) {
this.engineOpen = false
if (id === this.userProvider) return
@ -1089,6 +1112,8 @@ export default defineComponent({
.vtl-top { position:relative; z-index:3; }
.vtl-body { isolation:isolate; }
.ps-token { position:relative; display:inline-flex; }
.ps-show-header { position:fixed; top:8px; inset-inline-end:8px; z-index:2147483000; display:inline-flex; align-items:center; gap:6px; padding:6px 12px; border:1px solid #d1d4cd; border-radius:999px; background:rgba(255,255,255,.94); color:#3b463d; font:600 12px system-ui, sans-serif; cursor:pointer; box-shadow:0 4px 14px rgba(0,0,0,.18); }
.ps-show-header:hover, .ps-show-header:focus-visible { background:#ffffff; outline:2px solid #92b195; }
.ps-token-dot { width:8px; height:8px; border-radius:50%; background:#b8bdb4; }
.ps-token-dot.is-yours, .ps-token-dot.is-url { background:#92b195; }
.ps-token-dot.is-built { background:#c9b36a; }

View file

@ -20,6 +20,7 @@
*/
import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue'
import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { escHtml } from '../utils/pplrMap.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { useInstance } from '../composables/usePplrInstance.js'
@ -108,13 +109,13 @@ function renderSuggestion(item) {
const local = item?.properties?.__pplr_local
const dotColor = local?.color || ''
const dot = dotColor
? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${dotColor};margin-right:8px;vertical-align:middle"></span>`
? `<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:${escHtml(dotColor)};margin-right:8px;vertical-align:middle"></span>`
: ''
const tag = kind
? `<span style="font:600 9px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.06em;margin-left:auto">${kind}</span>`
? `<span style="font:600 9px ui-monospace,monospace;color:#73A17F;text-transform:uppercase;letter-spacing:0.06em;margin-left:auto">${escHtml(kind)}</span>`
: ''
return `<div class="mapboxgl-ctrl-geocoder--suggestion-title" style="display:flex;align-items:center;gap:4px;width:100%">
${dot}<span style="flex:1">${item.place_name}</span>${tag}
${dot}<span style="flex:1">${escHtml(item.place_name)}</span>${tag}
</div>`
}

View file

@ -6,6 +6,7 @@
* which joins the tree's shared assets base (set once by the hub from resolveAssetsBase) with
* pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/.
*/
import { CDN, pinElement } from '@map/constants/cdn.js'
let base = './assets'
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
export function dataUrl(file) { return base + '/pplr/' + file }
@ -24,19 +25,51 @@ export function ensureD3() {
if (d3Lib) return Promise.resolve(d3Lib)
if (typeof window === 'undefined') return Promise.reject(new Error('no window'))
if (!d3Promise) d3Promise = new Promise((res, rej) => {
const hadOwn = Object.prototype.hasOwnProperty.call(window, 'd3')
const hostD3 = window.d3
const s = document.createElement('script')
s.src = D3_URL
s.integrity = D3_SRI
s.crossOrigin = 'anonymous'
s.onload = () => {
d3Lib = window.d3
if (hadOwn) window.d3 = hostD3
else { try { delete window.d3 } catch (_) { window.d3 = undefined } }
res(d3Lib)
// ★ PUTTING THE HOST'S window.d3 BACK IS NOT ENOUGH. d3's UMD build writes its exports INTO an
// existing window.d3 — `(t = globalThis).d3 = t.d3 || {}` — so a host that owns a d3 had ours
// written over it before we restored it: rega measured 577 added keys and version '7.9.0' on a
// host's own object (a host on d3 v6 would get v7 functions). So while OUR script executes
// (document.currentScript === s), window.d3 reads and writes a fresh slot of ours; any other code,
// at every moment, reads and writes the host's own value. Nothing is blanked while the file loads.
const own = Object.getOwnPropertyDescriptor(window, 'd3')
let hostD3 = window.d3
let ours
let trapped = false
if (!own || (own.configurable && 'value' in own)) {
Object.defineProperty(window, 'd3', {
configurable: true, enumerable: own ? own.enumerable : true,
get: () => (document.currentScript === s ? ours : hostD3),
set: (v) => { if (document.currentScript === s) ours = v; else hostD3 = v },
})
trapped = true
} else if (own.writable) {
// A `var d3` global is non-configurable, so it cannot be trapped: empty it for the load instead
// (the host reads undefined until onload), which still keeps our exports out of its object.
window.d3 = undefined
}
s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load (network or integrity)')) }
const restore = () => {
if (trapped) {
delete window.d3
if (own || hostD3 !== undefined) {
Object.defineProperty(window, 'd3', { value: hostD3, writable: own ? own.writable : true,
configurable: true, enumerable: own ? own.enumerable : true })
}
} else if (own && own.writable) {
window.d3 = hostD3
}
}
s.onload = () => {
const lib = trapped ? ours : window.d3
restore()
d3Lib = lib
if (d3Lib) res(d3Lib)
else { d3Promise = null; rej(new Error('d3 loaded but exported nothing (an AMD loader on the page?)')) }
}
s.onerror = () => { restore(); d3Promise = null; rej(new Error('d3 failed to load (network or integrity)')) }
document.head.appendChild(s)
})
return d3Promise
@ -57,12 +90,9 @@ export function loadNames() {
// 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' },
// Exact versions + integrity, from the one registry (library/constants/cdn.js, CRITIC-5).
mapbox: { js: CDN.mapboxGeocoderJs.url, css: CDN.mapboxGeocoderCss.url, global: 'MapboxGeocoder' },
maplibre: { js: CDN.maplibreGeocoderJs.url, css: CDN.maplibreGeocoderCss.url, global: 'MaplibreGeocoder' },
}
const geocoderPromises = {}
export function ensureGeocoder(provider = 'mapbox') {
@ -71,6 +101,7 @@ export function ensureGeocoder(provider = 'mapbox') {
if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => {
const s = document.createElement('script')
s.src = g.js
pinElement(s, g.js)
s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) }
document.head.appendChild(s)
})

View file

@ -325,10 +325,15 @@ export async function addPGPoints(map, {
}
/** Standard popup HTML for a PG feature (PG-in-country grain). */
// Every value interpolated into popup / suggestion HTML goes through this (CRITIC-3, the critic
// fact-check). The data is PPLR's baked JSON and, for place search, the Mapbox geocoding API, and
// setHTML() parses whatever it is handed; escaping costs nothing and closes the door.
export const escHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
export function popupForPG(p, extraRows = '') {
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : v)
const dash = v => (v == null || v === 'None' || v === '' ? '<span class="m">—</span>' : escHtml(v))
return `<div class="pplr-popup">
<h3>${p.name || '(no name)'}</h3>
<h3>${p.name ? escHtml(p.name) : '(no name)'}</h3>
<table>
<tr><td>Country</td><td>${dash(p.country)} <code>${dash(p.iso3)}</code></td></tr>
<tr><td>ROP3</td><td><code>${dash(p.rop3)}</code></td></tr>

View file

@ -12,7 +12,7 @@ import { usePplrT } from '../composables/usePplrT.js'
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, loadOnce, LOCALES, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import { addCountryPolygons, addPGPoints, loadOnce, LOCALES, popupForPG, POPUP_CSS, escHtml } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
import DataSourcesDropdown from '../components/DataSourcesDropdown.vue'
@ -101,9 +101,9 @@ onMounted(async () => {
map.value.on('click', 'choropleth', e => {
const p = e.features[0].properties
const html = `<div class="pplr-popup">
<h3>${p.CtryNm || p.ctryNmDisp || '—'}<small>${p.GENCalpha3 || p.ISOalpha2 || '?'}</small></h3>
<h3>${escHtml(p.CtryNm || p.ctryNmDisp || '—')}<small>${escHtml(p.GENCalpha3 || p.ISOalpha2 || '?')}</small></h3>
<table>
<tr><td>PG count</td><td><b>${p.pgCount || 0}</b></td></tr>
<tr><td>PG count</td><td><b>${escHtml(p.pgCount || 0)}</b></td></tr>
<tr><td>Avg locales filled</td><td>
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>

View file

@ -16,7 +16,7 @@ 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 { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
@ -410,10 +410,10 @@ function setupLayers() {
}
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : escHtml(v)
const color = familyColor(p.fam || 'Other')
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Language</td><td>${d(p.language)}</td></tr>
<tr><td>Family</td><td><b style="color:${color}">${d(p.fam)}</b></td></tr>

View file

@ -12,7 +12,7 @@ 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 { addCountryPolygons, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
import FlatLegend from '../components/FlatLegend.vue'
@ -231,10 +231,10 @@ const detailColor = computed(() => {
})
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : escHtml(v)
const grp = RELIGION_GROUPS.find(g => g.key === p.rg)
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Religion</td><td><b style="color:${grp?.color || '#ccc'}">${d(p.religion)}</b></td></tr>
<tr><td>Language</td><td>${d(p.language)}</td></tr>

View file

@ -12,7 +12,7 @@ import { inject, onMounted, onBeforeUnmount, ref, computed, shallowRef, watch }
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 { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick, escHtml } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
import PinDetailCard from '../components/PinDetailCard.vue'
@ -376,9 +376,9 @@ function setupLayers() {
}
function popupHtml(p) {
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : v
const d = v => (v == null || v === 'None' || v === '' || v === '*') ? '—' : escHtml(v)
return `<div class="pplr-popup">
<h3>${p.name || '—'}<small>${d(p.country)}</small></h3>
<h3>${d(p.name)}<small>${d(p.country)}</small></h3>
<table>
<tr><td>Affinity Bloc</td><td><b>${d(p.rop1)}</b></td></tr>
<tr><td>Cluster</td><td><b>${d(p.rop2)}</b></td></tr>

View file

@ -51,4 +51,19 @@ export const DXLIFE_TOKENS =
' --font-size-sm:15.625px; --font-size-md:19.53125px; --font-size-lg:24.4140625px;' +
' --font-size-xl:30.517578125px; --font-size-2xl:38.146972656px; --font-size-3xl:47.68371582px;' +
' --font-size-4xl:59.604644775px; --font-size-5xl:74.505805969px;' +
// ★ THEIR BASE LINE-HEIGHT, AND IT WAS MISSING ENTIRELY -- the same class of defect as the
// box-sizing reset: a global rule of theirs that never crossed the shadow boundary.
// _globals.scss:14 is `body { line-height: var(--line-height-normal) }` and
// tokens/semantic/_typography.scss:37 is `$line-height-normal: 1.5`. We declared every spacing
// and font token above and no line-height at all, so every text element in the clone inherited
// whatever the EMBEDDING PAGE happened to set, while theirs is deterministically 1.5.
// MEASURED CONSEQUENCE, via rega: our goal cards came out 24px shorter than theirs (571 vs 595),
// their big number sitting ~12px lower and its label ~24px lower. .rd-big carried
// line-height:1.1 to compensate for this missing base; at --font-size-4xl (59.6046px) that is a
// 65.6px line box against their 89.4px -- a 23.8px difference that accounts for the 24px almost
// exactly, split roughly half above and half below the glyphs.
// Their own tighter cases stay explicit and already agree: .region-stat__value is line-height:1
// and so is our .rd-stat__value.
' --line-height-none:1; --line-height-tight:1.25; --line-height-normal:1.5;' +
' line-height:var(--line-height-normal);' +
'}'

View file

@ -26,6 +26,7 @@
*/
import { inject, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.
@ -653,7 +654,8 @@ async function loadRegions() {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
regions.value = summarizeRegions(posts)
points.value = posts.map((p) => ({
name: p.name || '', slug: p.slug || '',
@ -799,7 +801,7 @@ useShadowStyles(
'.rd-scroll > * + *{ margin-top:var(--spacing-3xl); }' + // .stack .stack--3xl
'.rd-scroll > *{ width:calc(90% + 16px); max-width:1200px;' +
' margin-left:auto; margin-right:auto; }' +
'.rd-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rd-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rd-status.rd-failed{ font-weight:600; }' +
// ── head: RegionDetail.vue:47-51, a .stack--xs (7.5px) block, centred ──
@ -960,7 +962,29 @@ useShadowStyles(
// 372px card, which reads as two numbers. Stepped to --font-size-3xl (the next rung down on
// THEIR scale) and pinned nowrap so a figure can never be split across lines. The stat-card
// values keep the full var(--font-size-3xl) -- those cards are wider and were measured to fit.
'.rd-big{ margin:0; font-size:var(--font-size-4xl); font-weight:500; line-height:1.1; }' +
// ★ NO line-height HERE ANY MORE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
// .font-size-4xl utility sets ONLY a font-size — so their number inherits body's 1.5. Ours forced
// 1.1, which was compensating for the base line-height we never ported (now on :host in
// dxlife-tokens.js). rega measured the cost: 24px of card height, their number ~12px lower and its
// label ~24px lower. Inheriting is the faithful port.
// ★ NOW A CLAMP, LIKE THEIRS, AND IT RESOLVES THE NOWRAP WORRY RATHER THAN FIGHTING IT.
// utilities/_typography.scss gives .font-size-4xl
// font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
// and ours was a flat var(--font-size-4xl). Identical at 1440 and above (both sit on the ceiling),
// but below that ours never shrank -- MEASURED against their resolved values:
// 390px viewport theirs 38.15 ours 59.60 ours 21.46 too large
// 768px viewport theirs 46.08 ours 59.60 ours 13.52 too large
// 1024px and up theirs 59.60 ours 59.60 identical
// I had reported this as unchangeable because of the nowrap note above -- a figure must never
// split across lines in a 372px card. That was backwards: the clamp makes the figure SMALLER
// exactly where the wrap was observed. In a 372px container 6cqw is 22.3px, below the 2xl floor,
// so it resolves to 38.15px against the 59.60px that was breaking. The clamp helps the very
// problem the flat value was chosen for, and the nowrap stays as a belt.
// cqw not vw, consistent with every other clamp in this element (the container-vs-viewport
// substitution the Driver is still ruling on); at 768 that lands 42.4 against their 46.08, which
// is 3.68 off instead of 13.52.
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
' font-weight:500; }' +
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
'.rd-big-label{ margin:0; font-size:var(--font-size-lg); }' +
// .stack .stack--xs, inside the Prayer Coverage card.
@ -968,7 +992,18 @@ useShadowStyles(
'.rd-stack-xs > * + *{ margin-top:10px; }' +
// .region-goal — margin-top:auto, spelled at higher specificity than the stack rule above
// so the override is unambiguous rather than depending on source order.
'.rd-goal{ margin:0; font-size:var(--font-size-sm); }' +
// ★ margin-top:auto, AND IT IS LOAD-BEARING. Theirs is RegionDetail.vue:24 `.region-goal {
// margin-top: auto }` inside `<div class="stack stack--lg | card | text-center">`, and
// composition/_stack.scss makes .stack a flex COLUMN with `> * + * { margin-top:
// var(--stack-spacing) }`. So the auto OVERRIDES that child margin and pushes the goal line to the
// BOTTOM of the card. Ours was margin:0, which left it directly under the text above it.
// The visible consequence, and why it matters for a clone: the three goal cards hold different
// amounts of copy, so theirs align their goal lines flush along the cards' bottom edge while ours
// floated at three different heights.
// FOUND BY READING THEIR MARKUP after rega's spacing probe measured this pair as "same" -- which
// it is, at 1440, because with no free space in the card the auto resolves to 0. An equal computed
// margin hid a real difference, so the probe was right and incomplete at the same time.
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the
// UA's bold. The 'Goal:' label in all three cards is a <strong>, so ours was rendering 700.
'.rd-root strong{ font-weight:500; }' +

View file

@ -40,6 +40,7 @@
*/
import { inject, ref, computed, onMounted, watch, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
import { splitLastWord } from './src/useLastWordHighlight.js'
import { useRegionsI18n } from './src/useRegionsI18n.js'
@ -242,7 +243,8 @@ async function loadRegions() {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
// Only the groups of this page's people-group LIST (library/lists/).
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
regions.value = summarizeRegions(posts)
} catch (error) {
console.error('[regions-list] fetch error:', error)
@ -385,7 +387,7 @@ useShadowStyles(
// max-width: 1200px; margin-left/right: auto
// So it is FLUID at 90%+16px until the viewport reaches 1315.625px, then pinned at 1200px centred.
// Our cards previously stretched to the full pane at every width -- there was no cap at all.
// box-sizing:border-box so .rl-status's own 28px padding stays inside the cap.
// box-sizing:border-box so .rl-status's own padding (--spacing-xl) stays inside the cap.
'.rl-scroll > *{ width:calc(90% + 16px); max-width:1200px;' +
' margin-left:auto; margin-right:auto; }' +
// Their head block: .stack--xs (10px) gap, centred. h1 is _globals' clamp(--font-size-3xl,
@ -422,7 +424,7 @@ useShadowStyles(
' font-weight:400; }' +
'.rl-title__last{ color:#92b195; }' +
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status.rl-failed{ font-weight:600; }' +
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.

View file

@ -96,7 +96,7 @@ let offScroll = null
// partner who asked for MapLibre a Mapbox map in every nested pane -- the pane reads
// provider as undefined and falls back. Same shape of bug as the missing 'urlSync' here
// before it: the inner map cannot honour a key the shell never passed down.
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint', 'urlSync', 'provider']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint', 'urlSync', 'provider', 'list']
function paneElement(pane) {
const el = document.createElement(TAG)

View file

@ -742,6 +742,18 @@ useShadowStyles(
'.rcm{ display:block; position:relative; width:100%; min-height:780px; aspect-ratio:16/9;' +
' overflow:hidden; border-radius:inherit; }' +
'@media (max-width:768px){ .rcm{ min-height:0; aspect-ratio:1/2; } }' +
// ★ THE CHOOSER IS SQUARE ON MOBILE, AND THIS IS GEOMETRY RATHER THAN TASTE.
// Their .country-map flips to aspect-ratio 1/2 under 768px and the rule above ports that verbatim
// -- correct for the CLONE pages, which frame one region or country in portrait. The chooser is
// the Driver's sanctioned addition and renders under the GLOBE projection, so its subject is a
// CIRCLE whose diameter cannot exceed the frame's short side. In a 1:2 frame that circle covers at
// most W/2W = 50% of the height: at 390px wide, 390px of globe in a 780px box and the rest empty.
// A SQUARE is the only frame a circle fills on BOTH axes -- vertical coverage 50% -> 100%, and area
// fill pi/4 = 78.5%, the maximum a circle can reach in any rectangle. Taller wastes height, wider
// wastes width. Driver 2026-09-26: "the only changes are to the map to make it look better, and to
// add the all-regions map ... which needs to be intuitive". So: chooser ONLY, and the cloned pages
// above keep their 1/2 untouched.
'@media (max-width:768px){ .rcm--chooser{ aspect-ratio:1/1; } }' +
'.rcm__canvas{ position:absolute; inset:0; }' +
'.rcm__note{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center;' +
' text-align:center; padding:24px; color:#6d766e; font-size:15.625px; background:#f3f3f1; }' +
@ -802,7 +814,9 @@ useShadowStyles(
</script>
<template>
<div class="rcm">
<!-- rcm--chooser is the sanctioned addition's own frame: see the aspect rule in the styles
below for why a globe needs a square on mobile and the cloned pages must not get one. -->
<div class="rcm" :class="{ 'rcm--chooser': colorMode === 'region' }">
<div ref="mapContainer" class="rcm__canvas" />
<!-- THE SHARED EDGE-SCROLL STRIPS. useEdgeScrollAffordance does NOT create these — it scans
the wrapper for them (useEdgeScrollAffordance.js:57, `if (!edges.length) return NOOP`),

View file

@ -26,8 +26,9 @@
* the /api/people-groups/list response we consume carries has_photo + image_url, so the img is
* wired. Where a group has no photo the card reflows without one, as theirs does.
*/
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
// document.documentElement.dir, which belongs to the host page.
@ -132,6 +133,9 @@ const active = ref({ wagf_region: null, wagf_block: null, rop1: null, religion:
const label = (f) => (f && typeof f === 'object' ? f.label : f) || ''
const value = (f) => (f && typeof f === 'object' ? f.value : f) || ''
// The page's profile-config, for its people-group LIST; this list sits under the same ProfileLoader.
const listCfg = inject('profileConfig', null)
async function load() {
loading.value = true
failed.value = false
@ -139,7 +143,8 @@ async function load() {
const r = await fetch(`${props.prayBaseUrl}/api/people-groups/list?fields=${FIELDS}&lang=${props.languageCode}`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const d = await r.json()
const all = Array.isArray(d.posts) ? d.posts : []
// Only the groups of this page's people-group LIST (library/lists/).
const all = await applyPeopleGroupList(Array.isArray(d.posts) ? d.posts : [], listCfg)
rows.value = props.lockCountryCode
? all.filter((p) => value(p.country_code) === props.lockCountryCode)
: all

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