Update from source 4ca2dcf9
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions

This commit is contained in:
Doxa Maps 2026-09-27 02:07:55 +00:00
parent 7606af602a
commit bca8ce73ad
86 changed files with 26370 additions and 9672 deletions

File diff suppressed because one or more lines are too long

View file

@ -59,7 +59,7 @@
"meta": { "meta": {
"title": "The Deaf Gap", "title": "The Deaf Gap",
"grain": "one row per Deaf people group", "grain": "one row per Deaf people group",
"generated": "2026-09-25", "generated": "2026-09-27",
"measured": "150 sign languages · 234 people groups · 84,159,050 people. 136 languages / 183 groups / 55,199,840 people with NOTHING found.", "measured": "150 sign languages · 234 people groups · 84,159,050 people. 136 languages / 183 groups / 55,199,840 people with NOTHING found.",
"bridge_rule": "A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.", "bridge_rule": "A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.",
"compounding_problem": "40 of these languages carry PRIVATE-USE (qaa-qtz) codes outside ISO 639-3. No resource pool indexes a private-use code, so those groups cannot be matched to a resource even if one exists.", "compounding_problem": "40 of these languages carry PRIVATE-USE (qaa-qtz) codes outside ISO 639-3. No resource pool indexes a private-use code, so those groups cannot be matched to a resource even if one exists.",
@ -2621,7 +2621,7 @@
"iso639_3": "jos", "iso639_3": "jos",
"countries": "SYR", "countries": "SYR",
"population": 221000, "population": 221000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138", "signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true, "has_signed_film": true,
"has_any_resource": true, "has_any_resource": true,
"code_unmatchable": false, "code_unmatchable": false,
@ -2660,7 +2660,7 @@
"iso639_3": "jos", "iso639_3": "jos",
"countries": "JOR", "countries": "JOR",
"population": 116000, "population": 116000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138", "signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true, "has_signed_film": true,
"has_any_resource": true, "has_any_resource": true,
"code_unmatchable": false, "code_unmatchable": false,
@ -2738,7 +2738,7 @@
"iso639_3": "jos", "iso639_3": "jos",
"countries": "LBN", "countries": "LBN",
"population": 51000, "population": 51000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138", "signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true, "has_signed_film": true,
"has_any_resource": true, "has_any_resource": true,
"code_unmatchable": false, "code_unmatchable": false,

View file

@ -3,7 +3,7 @@
"title": "Doxa people groups — what the gospel is available in, and what is missing", "title": "Doxa people groups — what the gospel is available in, and what is missing",
"grain": "one row per people group on Doxa's roster", "grain": "one row per people group on Doxa's roster",
"source": "pray.doxa.life roster joined to the resource index by ISO 639-3", "source": "pray.doxa.life roster joined to the resource index by ISO 639-3",
"generated": "2026-09-26", "generated": "2026-09-27",
"groups": 2107, "groups": 2107,
"by_access": { "by_access": {
"nothing found": 369, "nothing found": 369,
@ -19768,7 +19768,7 @@
"translated_by": "", "translated_by": "",
"translated_year": "", "translated_year": "",
"scripture_url": "", "scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138", "film_url": "https://arc.gt/g8wmw",
"audio_url": "", "audio_url": "",
"bible_video_url": "", "bible_video_url": "",
"stories_url": "", "stories_url": "",
@ -20048,7 +20048,7 @@
"translated_by": "", "translated_by": "",
"translated_year": "", "translated_year": "",
"scripture_url": "", "scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138", "film_url": "https://arc.gt/g8wmw",
"audio_url": "", "audio_url": "",
"bible_video_url": "", "bible_video_url": "",
"stories_url": "", "stories_url": "",
@ -21928,7 +21928,7 @@
"translated_by": "", "translated_by": "",
"translated_year": "", "translated_year": "",
"scripture_url": "", "scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138", "film_url": "https://arc.gt/g8wmw",
"audio_url": "", "audio_url": "",
"bible_video_url": "", "bible_video_url": "",
"stories_url": "", "stories_url": "",

View file

@ -9,7 +9,7 @@
"bridge_strength_note": "MEASURED: 24% of aligned rows rest on a bridge shared by only 2-3 groups. bridge_strength grades this (strong >=8 groups, moderate >=4, weak <4). A WEAK bridge is a hypothesis, not a route — e.g. \"Gawri\" was offered as the bridge for 10.3M Pakistanis where the real lingua franca is Urdu, which no unengaged group here claims as primary and which this method therefore cannot see. Filter on bridge_strength before acting.", "bridge_strength_note": "MEASURED: 24% of aligned rows rest on a bridge shared by only 2-3 groups. bridge_strength grades this (strong >=8 groups, moderate >=4, weak <4). A WEAK bridge is a hypothesis, not a route — e.g. \"Gawri\" was offered as the bridge for 10.3M Pakistanis where the real lingua franca is Urdu, which no unengaged group here claims as primary and which this method therefore cannot see. Filter on bridge_strength before acting.",
"bridge_caveat": "A bridge candidate is measured from THIS corpus (a language primary to >1 DX group in the country). It is NOT proof the group speaks it, and it is NOT a national trade language — a lingua franca no unengaged group claims as primary is invisible here.", "bridge_caveat": "A bridge candidate is measured from THIS corpus (a language primary to >1 DX group in the country). It is NOT proof the group speaks it, and it is NOT a national trade language — a lingua franca no unengaged group claims as primary is invisible here.",
"pools_caveat": "Resource status is from eBible + JESUS Film. GRN and FCBH audio are key-blocked and unchecked — absence is never proof.", "pools_caveat": "Resource status is from eBible + JESUS Film. GRN and FCBH audio are key-blocked and unchecked — absence is never proof.",
"resource_flags_refreshed": "2026-09-25", "resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json — the same index the map draws. Curated bridge, internet and channel columns are untouched." "resource_flags_source": "data/tables/people-group-pins.json — the same index the map draws. Curated bridge, internet and channel columns are untouched."
}, },
"columns": [ "columns": [

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,12 @@
"meta": { "meta": {
"title": "Gospel access by province — India and China", "title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.", "why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26", "generated": "2026-09-27",
"provinces_with_groups": 46, "provinces_with_groups": 46,
"provinces_available": 48, "provinces_available": 48,
"groups_assigned": 1005, "groups_assigned": 1005,
"groups_unassigned": 4, "groups_unassigned": 4,
"per_group_file": "gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time",
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.", "india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.", "unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.", "china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",

View file

@ -1,7 +1,7 @@
{ {
"meta": { "meta": {
"title": "Gospel resource link verification", "title": "Gospel resource link verification",
"generated": "2026-09-25T09:56:59.860Z", "generated": "2026-09-27T01:21:18.374Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.", "method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.", "scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": { "states": {
@ -96,12 +96,12 @@
] ]
}, },
"summary": { "summary": {
"records": 8524, "records": 8525,
"checkedThisPass": 8, "checkedThisPass": 1,
"carriedForward": 74, "carriedForward": 74,
"pruned": 0, "pruned": 0,
"broken": 74, "broken": 74,
"verified": 8445, "verified": 8446,
"unknown": 5 "unknown": 5
}, },
"byHost": { "byHost": {
@ -123,7 +123,7 @@
"broken": 1 "broken": 1
}, },
"arc.gt": { "arc.gt": {
"verified": 33, "verified": 34,
"broken": 1 "broken": 1
}, },
"zume.training": { "zume.training": {
@ -42766,6 +42766,11 @@
"state": "verified", "state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)", "detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)",
"at": "2026-09-25T09:56:59.503Z" "at": "2026-09-25T09:56:59.503Z"
},
"https://arc.gt/g8wmw": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/jordanian-sign-language.html)",
"at": "2026-09-27T01:21:17.988Z"
} }
} }
} }

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@
"honest_limit": "This is NOT the same as a national trade language. It finds languages widely used AMONG THE UNENGAGED GROUPS TRACKED HERE, and will MISS a lingua franca that no unengaged group claims as primary (e.g. Hindi in India). Named bridge_candidates, not trade_languages, for that reason.", "honest_limit": "This is NOT the same as a national trade language. It finds languages widely used AMONG THE UNENGAGED GROUPS TRACKED HERE, and will MISS a lingua franca that no unengaged group claims as primary (e.g. Hindi in India). Named bridge_candidates, not trade_languages, for that reason.",
"why_not_a_list": "No open machine-readable list of national trade languages was available; bridge candidates are derived from this corpus." "why_not_a_list": "No open machine-readable list of national trade languages was available; bridge candidates are derived from this corpus."
}, },
"resource_flags_refreshed": "2026-09-25", "resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json. prevalence_rank, bridge_candidate, groups_speaking and full_bible are curated and untouched." "resource_flags_source": "data/tables/people-group-pins.json. prevalence_rank, bridge_candidate, groups_speaking and full_bible are curated and untouched."
}, },
"columns": [ "columns": [

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -117,6 +117,8 @@ and a change here can break other maps.
`import … from '@map/composables/useAvatarGenerator.js'` `import … from '@map/composables/useAvatarGenerator.js'`
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search. - **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
`import … from '@map/composables/useCountryOutline.js'` `import … from '@map/composables/useCountryOutline.js'`
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
`import … from '@map/composables/useCsvExport.js'`
- **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid. - **useDashboardLayout** — useDashboardLayout.js — reusable drag-resize + persistence engine for the CMV dashboard grid.
`import … from '@map/composables/useDashboardLayout.js'` `import … from '@map/composables/useDashboardLayout.js'`
- **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page. - **useDocumentFont** — useDocumentFont.js — register a self-hosted @font-face at DOCUMENT level, once per page.
@ -163,10 +165,14 @@ and a change here can break other maps.
`import … from '@map/composables/useSelectedPin.js'` `import … from '@map/composables/useSelectedPin.js'`
- **useSelectionBus** — useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus. - **useSelectionBus** — useSelectionBus.js — hand-rolled pub/sub brushing-and-linking bus.
`import … from '@map/composables/useSelectionBus.js'` `import … from '@map/composables/useSelectionBus.js'`
- **useSelectionCamera** — useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
`import … from '@map/composables/useSelectionCamera.js'`
- **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection - **useShadowStyles** — useShadowStyles.js — Shadow DOM Style Injection
`import … from '@map/composables/useShadowStyles.js'` `import … from '@map/composables/useShadowStyles.js'`
- **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it. - **useSharedMapToken** — useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
`import … from '@map/composables/useSharedMapToken.js'` `import … from '@map/composables/useSharedMapToken.js'`
- **useTextHighlight** — highlightRanges(value, indices) — wrap each matched index range in
`import … from '@map/composables/useTextHighlight.js'`
### Utilities (pure helper functions) ### Utilities (pure helper functions)

View file

@ -0,0 +1,44 @@
/**
* _published-base.mjs — where the PUBLISHED tree lives, for the few tools that measure the live catalogue.
*
* The repo names no host (white-label: it ships to people who publish somewhere else). A tool that
* compares against the live tree reads its location from DOXA_PUBLISHED_BASE: the URL the tree folders sit
* under, e.g. https://<your-host>/plugin-assets (the tree name is appended here, from _tree-name.mjs). Set it
* in the environment, or in the gitignored .env / .env.local at the repo root.
*
* With nothing set, such a tool has nothing to measure: it says NOT MEASURED and exits 75, the fleet's
* third bucket, instead of passing or failing against a host it was never told about.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
function fromEnvFiles() {
for (const f of ['.env.local', '.env']) {
try {
const m = readFileSync(join(ROOT, f), 'utf8').match(/^\s*DOXA_PUBLISHED_BASE\s*=\s*["']?([^"'\s#]+)/m)
if (m) return m[1]
} catch { /* no such file */ }
}
return ''
}
/** The published tree's URL (…/<TREE_NAME>, no trailing slash), or '' when none is configured. */
export function publishedTreeUrl() {
const base = (process.env.DOXA_PUBLISHED_BASE || fromEnvFiles()).replace(/\/+$/, '')
return base ? `${base}/${TREE_NAME}` : ''
}
/** For a tool that cannot run without it: the tree URL, or NOT MEASURED (exit 75) naming what to set. */
export function requirePublishedTreeUrl(label) {
const url = publishedTreeUrl()
if (!url) {
console.log(`${label}: NOT MEASURED — no published tree to measure. Set DOXA_PUBLISHED_BASE (environment or .env)`)
console.log(` to the URL your tree folders sit under, e.g. https://<your-host>/plugin-assets`)
process.exit(75)
}
return url
}

View file

@ -34,11 +34,12 @@ import { createHash } from 'node:crypto'
import { resolve, dirname, join } from 'node:path' import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs' import { TREE_NAME } from './_tree-name.mjs'
import { requirePublishedTreeUrl } from './_published-base.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2) const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d } const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const BASE = (argOf('--base', `https://nuxtbase.app/plugin-assets/${TREE_NAME}`)).replace(/\/+$/, '') const BASE = (argOf('--base', '') || requirePublishedTreeUrl('CATALOG-CURRENCY')).replace(/\/+$/, '')
const TIMEOUT = Number(argOf('--timeout', 25000)) const TIMEOUT = Number(argOf('--timeout', 25000))
const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64') const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')

View file

@ -6,7 +6,7 @@
* Card 1 (IDEA-MAPS-BUNDLER-PUBLIC-FORGEJO-PIPELINE) asks that ONE public Forgejo repo be the live * Card 1 (IDEA-MAPS-BUNDLER-PUBLIC-FORGEJO-PIPELINE) asks that ONE public Forgejo repo be the live
* catalog source, and that the merge lose nothing. The tempting verification is `curl -o /dev/null * catalog source, and that the merge lose nothing. The tempting verification is `curl -o /dev/null
* -w '%{http_code}'` over a few URLs. That proves only that SOMETHING answered: * -w '%{http_code}'` over a few URLs. That proves only that SOMETHING answered:
* · https://nuxtbase.app/app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML * · the host platform's /app/doxa-maps/manifest.json returns **200** and is a 2,872-byte HTML
* SIGN-IN PAGE. An http-status check reads it as healthy. * SIGN-IN PAGE. An http-status check reads it as healthy.
* · a stale CDN copy also returns 200, with the wrong bytes. * · a stale CDN copy also returns 200, with the wrong bytes.
* *
@ -22,7 +22,7 @@
* additionally require that it matches a cloned catalog checkout. * additionally require that it matches a cloned catalog checkout.
* *
* MEASURED 2026-09-21: 17 of 17 bundles identity-proven, 0 mismatched, and the live public manifest * MEASURED 2026-09-21: 17 of 17 bundles identity-proven, 0 mismatched, and the live public manifest
* is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what nuxtbase serves. * is byte-identical (sha256 88ae3832…) to platform/mfe-index's. The catalog IS what the host platform serves.
* *
* USAGE * USAGE
* bun run check:catalog-identity # live public door vs its own manifest * bun run check:catalog-identity # live public door vs its own manifest
@ -35,8 +35,9 @@ import { readFileSync, existsSync } from 'node:fs'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { join } from 'node:path' import { join } from 'node:path'
import { TREE_NAME } from './_tree-name.mjs' import { TREE_NAME } from './_tree-name.mjs'
import { requirePublishedTreeUrl } from './_published-base.mjs'
const BASE = (process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}/`).replace(/\/?$/, '/') const BASE = (process.env.BASE || requirePublishedTreeUrl('CATALOG-IDENTITY')).replace(/\/?$/, '/')
const CATALOG = process.env.CATALOG || '' const CATALOG = process.env.CATALOG || ''
const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64') const sha384 = (buf) => 'sha384-' + createHash('sha384').update(buf).digest('base64')

View file

@ -84,7 +84,7 @@ for (const b of manifest.bundles || []) {
const byCompose = composes.some((c) => mapBundles.has(c)) const byCompose = composes.some((c) => mapBundles.has(c))
const profile = (b.profiles || []).find((p) => p.element && (byCompose || p.type === 'nested-elements-mfe')) const profile = (b.profiles || []).find((p) => p.element && (byCompose || p.type === 'nested-elements-mfe'))
if (!profile || !b.scriptUrl) continue if (!profile || !b.scriptUrl) continue
targets.push({ bundle: b.bundle, element: profile.element, script: `${b.bundle}/${b.bundle}.js` }) targets.push({ bundle: b.bundle, element: profile.element, profile: profile.profile, script: `${b.bundle}/${b.bundle}.js` })
} }
if (!targets.length) { if (!targets.length) {
console.log('COMPOSITE-EMBED-KNOBS: SKIP — the manifest declares no composite that nests a map.') console.log('COMPOSITE-EMBED-KNOBS: SKIP — the manifest declares no composite that nests a map.')
@ -153,7 +153,10 @@ try {
lines.push(` · ${t.element} [${c.knob}] SKIPPED — no baked token in this tree to hand the host`) lines.push(` · ${t.element} [${c.knob}] SKIPPED — no baked token in this tree to hand the host`)
continue continue
} }
const cfg = JSON.stringify(c.config(bakedToken)).replace(/'/g, '&#39;') // The composite's OWN profile rides with the knob under test (rega, 1a761741: gospel-resources,
// my-upg-100-list, regions-dashboard and two more drew no map under a knob-only config, because a
// multi-profile element needs to be told which profile is the composite). A knob still wins.
const cfg = JSON.stringify({ profile: t.profile, ...c.config(bakedToken) }).replace(/'/g, '&#39;')
pageHtml = `<!doctype html><html><head><meta charset=utf-8><title>partner</title></head><body style="margin:0"> pageHtml = `<!doctype html><html><head><meta charset=utf-8><title>partner</title></head><body style="margin:0">
<header style="height:48px;background:#eee">HOST HEADER</header> <header style="height:48px;background:#eee">HOST HEADER</header>
<script>window.MAP_APP_API_URL="https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL="https://pray.doxa.life";</script>

View file

@ -44,7 +44,13 @@ import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const SRC = join(ROOT, 'plugin-designs/research-dashboard/src/composables/useCsvExport.js') // ★ FOLLOWS THE FILE (dashboard-kit landing 3). This gate does not import useCsvExport — it READS
// the source and extracts esc() out of it. Left pointing at the plugin path after the move it would
// have read the one-line re-export shim, found no esc() to extract, and failed with "could not
// extract esc() from useCsvExport.js" — a gate accusing a working exporter because its subject moved.
// That is the same defect landing 1 nearly shipped with check-facet-cascade's BUS_SRC, which is why
// looking for path-reading gates is now the FIRST step of a landing rather than a discovery during it.
const SRC = join(ROOT, 'library/composables/useCsvExport.js')
if (!existsSync(SRC)) { if (!existsSync(SRC)) {
console.error('CSV-SHAPE: FAIL — useCsvExport.js not found at ' + SRC.replace(ROOT + '/', '')) console.error('CSV-SHAPE: FAIL — useCsvExport.js not found at ' + SRC.replace(ROOT + '/', ''))

View file

@ -54,7 +54,7 @@ try {
// The leaf's own arithmetic is the oracle — computed here rather than hardcoded, so the // The leaf's own arithmetic is the oracle — computed here rather than hardcoded, so the
// expectation cannot drift from what the generated HTML actually means. // expectation cannot drift from what the generated HTML actually means.
const TREES = [ const TREES = [
['live catalog', `https://nuxtbase.app/plugin-assets/${TREE_NAME}`], ['live catalog', `https://maps.example.org/plugin-assets/${TREE_NAME}`],
['dev tree', `http://127.0.0.1:4173/${TREE_NAME}`], ['dev tree', `http://127.0.0.1:4173/${TREE_NAME}`],
['doxa.life drop-in', `https://doxa.life/js/doxa-maps-build/${TREE_NAME}`], ['doxa.life drop-in', `https://doxa.life/js/doxa-maps-build/${TREE_NAME}`],
['deep subpath', `https://cdn.example.org/a/b/c/${TREE_NAME}`], ['deep subpath', `https://cdn.example.org/a/b/c/${TREE_NAME}`],

View file

@ -0,0 +1,201 @@
/**
* check-link-verification-coverage.mjs — rule 4 of the brief, as a gate instead of a good habit.
*
* "Verify a URL resolves before shipping it." verify-gospel-links.mjs does the verifying and writes
* the record; nothing asserted that the record COVERS what the build actually ships. So the honest
* state of the corpus was a number somebody had to go and measure, and a number nobody re-measures
* is a number that was true once.
*
* ⚠ THE URLS ARE DISCOVERED, NOT LISTED. Every other place in this bundle that walks the pin index
* names the fields it reads — and that list has gone stale twice: `ga` (Words of Life) and `se`
* (Scripture Earth) shipped unscreened because a field list written before those pools existed
* could not see them, and the SUN block and the access table each had to be added after the fact.
* This one recurses over the whole structure and treats ANY string that starts with http as a
* shipped link. A new pool is then covered on the day it lands, without anyone remembering to come
* here — which is the only kind of coverage check worth having.
*
* WHAT COUNTS AS A PASS:
* every shipped URL has an entry — an unrecorded link has never been looked at
* no shipped URL is recorded BROKEN — the builder drops dead links; if one survives, it
* is shipping against its own evidence
* `unknown` only on hosts that cannot be — live.bible.is and knowgod.com answer 200 with
* checked by status, and always counted identical bytes for any path, so a status check
* there is a false green, and the verifier says so
*
* Self-test: `--poison` adds a fabricated URL to the corpus, marks a shipped one broken, and moves
* an `unknown` onto a host that IS checkable. All three arms must fire.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const POISON = process.argv.includes('--poison')
/* What cannot be proved by a status code, kept here as literals: a check that reads its own
exemptions out of the file it is checking exempts whatever that file says.
⚠ THE LAST ENTRY IS A URL SHAPE, NOT A HOST, and that distinction is the whole point of the
migration that preceded it: jesusfilm.org/watch/jesus/<slug> DOES discriminate (a bogus slug
404s) and 2,049 film links became checkable because of it, while the older
/watch/?languageId=<n> form renders byte-identical pages for any id, valid or bogus. Exempting
the host would have re-blinded every one of those 2,049. */
const STATUS_BLIND = [
{ re: /^(https?:\/\/)?([a-z0-9-]+\.)*(live\.bible\.is|bible\.is)(\/|$)/i, why: '200 with identical bytes for any fileset' },
{ re: /^(https?:\/\/)?([a-z0-9-]+\.)*knowgod\.com(\/|$)/i, why: 'page bytes identical; proved through Cru\'s API instead' },
{ re: /^(https?:\/\/)?([a-z0-9-]+\.)*sun\.bible(\/|$)/i, why: '200 for /sun/<anything>; proved by content' },
{ re: /jesusfilm\.org\/watch\/\?languageId=/i, why: 'the pre-shareUrl form renders the same bytes for any id' },
]
const statusBlind = (u) => STATUS_BLIND.find((x) => x.re.test(u)) || null
/* ⚠ A CONTACT IS VERIFIED SOMEWHERE ELSE, and the recursive walk found that out the honest way:
it reported 18 unrecorded links, all of them `cr.u` — the translator organisations' own
homepages. They were fetched on the run that wrote them, but by verify-translator-org-links.mjs
into its OWN record, so gospel-link-verification.json has never heard of them. Resolving them
against that second record is right; declaring them exempt would not be. */
let orgVerified = new Set()
try {
const orgs = JSON.parse(readFileSync(resolve(REPO, 'data/tables/translator-org-links.json'), 'utf8')).orgs || {}
orgVerified = new Set(Object.values(orgs).filter((o) => o.state === 'verified').map((o) => o.url))
} catch { /* no org record: those URLs then have no evidence anywhere, and must fail below */ }
/* SUN IS THE THIRD RECORD, and its evidence is STRONGER than a status code rather than weaker:
sun.bible answers 200 for /sun/<anything>, so fetch-sun-deaf-index.mjs proves each page by
CONTENT against a catch-all control (a bogus path returns 904 characters; the New Testament page
returns 22,043). The recursive walk found these three the same way it found the contacts — by not
knowing which fields were supposed to hold links. */
/* PRAYER PAGES ARE PROVED BY A LISTING, NOT A FETCH — the fourth record, and the reason is in
fetch-pray-index.mjs: pray.doxa.life answers 200 with identical bytes for ANY slug, so a status
check there is a false green. Existence comes from doxa.life's own /api/people-groups/list, and
every URL this build ships is built from a slug that listing returned. A pray.doxa.life URL that
is NOT in that index is therefore a CONSTRUCTED one, and must fail here. */
let prayListed = new Set()
try {
const pray = JSON.parse(readFileSync(resolve(REPO, 'data/tables/pray-index.json'), 'utf8')).byKey || {}
for (const e of Object.values(pray)) if (e && e.u) prayListed.add(e.u.split('?')[0])
} catch { /* no prayer index: those URLs then have no evidence, and must fail below */ }
/* ⚠ THE INDEX IS A SUBSET AND THE ROSTER IS THE LISTING. pray-index.json holds only the 1,723
groups whose (name, country) matched a pin; the access table ships a prayer link for all 2,107,
so 809 of them were reported unrecorded here. They are not unproved — doxa-people-groups.json IS
the API listing, one row per group with the slug doxa.life returned, and the index is that
listing narrowed by a join this check has no business requiring. */
try {
const roster = JSON.parse(readFileSync(resolve(REPO, 'data/tables/doxa-people-groups.json'), 'utf8')).rows || []
for (const r of roster) if (r && r.pray_url) prayListed.add(String(r.pray_url).split('?')[0])
} catch { /* no roster: the subset above still covers what it covers */ }
let sunVerified = new Set()
try {
const sun = JSON.parse(readFileSync(resolve(REPO, 'data/tables/sun-deaf-index.json'), 'utf8'))
if ((sun.meta || {}).catch_all_control) sunVerified = new Set((sun.resources || []).map((r) => r.url))
} catch { /* no SUN record: its links then have no evidence, and must fail below */ }
const fails = []
const idx = JSON.parse(readFileSync(resolve(REPO, 'data/tables/people-group-pins.json'), 'utf8'))
const verif = JSON.parse(readFileSync(resolve(REPO, 'data/tables/gospel-link-verification.json'), 'utf8')).links || {}
/** Every http string anywhere in the structure, with a breadcrumb of where it was found. */
const found = new Map()
const walk = (node, path) => {
if (node == null) return
if (typeof node === 'string') {
/* A citation is not an offer. `source`, `_note` and everything under `meta` record WHERE the
data came from — an API endpoint, a licence page — and no surface renders them as links. */
if (/(^|\.)(meta|_note|source|sources|site|fetchedFrom|licenceSource|boundarySource|why|note)(\.|$|\[)/.test(path)) return
if (/^https?:\/\//i.test(node) && !found.has(node)) found.set(node, path)
return
}
if (Array.isArray(node)) { node.forEach((v, i) => walk(v, `${path}[${i}]`)); return }
if (typeof node === 'object') for (const [k, v] of Object.entries(node)) walk(v, path ? `${path}.${k}` : k)
}
walk(idx.codes, 'codes')
walk(idx.sun, 'sun')
walk(idx.bridges, 'bridges')
walk(idx.bridgeCandidates, 'bridgeCandidates')
/* ⚠ THE PIN INDEX IS NOT THE ONLY THING SHIPPED, and scoping this check to it repeated the very
blind spot it was written to close: people-group-resources.json — the "Resources by People
Group" tab — was serving 2,294 URL cells of which 1,412 were the retired jesusfilm
?languageId= form, links the site ignores, while the map beside it had been migrated onto
Arclight shareUrls. Nothing saw it, because every check looked at one file.
*
* ⚠ AND THE SCOPE IS "WHAT A READER CAN CLICK", which is narrower than "what is staged". Reading
* every staged file reported 6,115 unrecorded links and 53 broken ones, and NONE of them is
* offered to anybody: they are API endpoints in `source` fields, and the raw upstream catalogues
* (arclight-share-urls and friends) which are build CACHES that happen to sit in the same folder —
* they still hold the 53 share URLs the verifier proved dead, which is exactly why the pin builder
* drops them. Judging those as shipped would make this check red on correct behaviour, and a gate
* that is red on correct behaviour gets switched off. So the scope is the tables the SURFACES
* read, taken from the surfaces, plus provenance fields excluded by path. */
const SURFACES = [
resolve(REPO, 'plugin-designs/gospel-resources/data-tables.vue'),
resolve(REPO, 'plugin-designs/gospel-resources/resources-map.vue'),
resolve(REPO, 'plugin-designs/gospel-resources/resources-list.vue'),
resolve(REPO, 'plugin-designs/gospel-resources/charts.vue'),
]
const wanted = new Set()
for (const f of SURFACES) {
let src = ''
try { src = readFileSync(f, 'utf8') } catch { continue }
for (const m of src.matchAll(/file:\s*'([a-z0-9-]+)'/gi)) wanted.add(`${m[1]}.json`)
for (const m of src.matchAll(/tables\/([a-z0-9-]+)\.json/gi)) wanted.add(`${m[1]}.json`)
}
if (wanted.size < 5) fails.push(`only ${wanted.size} reader-facing table(s) found in the surfaces — the scope is not being derived, so this check would under-measure`)
const POOL = resolve(REPO, 'public', TREE_NAME, 'assets/tables')
for (const f of wanted) {
/* The verification record is a LEDGER, deliberately keeping URLs proved dead; walking it would
ask the record to vouch for itself. */
if (f === 'gospel-link-verification.json') continue
try { walk(JSON.parse(readFileSync(resolve(POOL, f), 'utf8')), `tables/${f}`) } catch (e) { fails.push(`${f} is read by a surface but could not be read here: ${e.message}`) }
}
if (POISON) found.set('https://ebible.org/this-was-never-verified/', 'codes.__poison.s')
const unrecorded = [], broken = [], unknownOnCheckable = []
let unknownBlind = 0, verified = 0, contacts = 0, byContent = 0, listed = 0
let poisonedBroken = false, poisonedUnknown = false
for (const [url, where] of found) {
let e = verif[url]
if (POISON && !poisonedBroken && e && e.state === 'verified') { e = { ...e, state: 'broken' }; poisonedBroken = true }
else if (POISON && !poisonedUnknown && e && e.state === 'verified' && !statusBlind(url)) { e = { ...e, state: 'unknown' }; poisonedUnknown = true }
if (!e && orgVerified.has(url)) { contacts++; continue }
if (!e && sunVerified.has(url)) { byContent++; continue }
/* The shipped URL may carry a tracking suffix the index does not (or vice versa); the SLUG is
what the listing proved, so compare on origin+path. */
if (!e && /pray\.doxa\.life/i.test(url)) {
if (prayListed.has(url.split('?')[0])) { listed++; continue }
}
if (!e) { unrecorded.push(`${url} (${where})`); continue }
if (e.state === 'broken') { broken.push(`${url} (${where})`); continue }
if (e.state === 'unknown') {
if (statusBlind(url)) unknownBlind++
else unknownOnCheckable.push(`${url} — ${e.detail || 'no reason recorded'}`)
continue
}
verified++
}
if (!found.size) fails.push('no URL found in the pin index at all — this check would pass vacuously')
if (unrecorded.length) fails.push(`${unrecorded.length} shipped link(s) have NO verification entry (e.g. ${unrecorded[0]})`)
if (broken.length) fails.push(`${broken.length} shipped link(s) are recorded BROKEN (e.g. ${broken[0]})`)
if (unknownOnCheckable.length) fails.push(`${unknownOnCheckable.length} link(s) are 'unknown' on a host that CAN be checked by status (e.g. ${unknownOnCheckable[0]})`)
if (POISON) {
const want = ['have NO verification entry', 'recorded BROKEN', "'unknown' on a host that CAN be checked"]
const missed = want.filter((w) => !fails.some((f) => f.includes(w)))
if (missed.length) { console.error(`LINK-COVERAGE --poison: these arms did NOT fire: ${missed.join(' | ')}`); process.exit(1) }
console.log(`LINK-COVERAGE --poison: PASS — all 3 arms fired (${fails.length} failure(s)).`)
process.exit(0)
}
if (fails.length) {
console.error(`LINK-COVERAGE: FAIL — ${fails.length} problem(s):`)
for (const f of fails) console.error(` ${f}`)
console.error(' Fix: node plugin-designs/gospel-resources/scripts/verify-gospel-links.mjs')
process.exit(1)
}
console.log(`LINK-COVERAGE: PASS — ${found.size} distinct shipped link(s): ${verified} verified by fetch, ` +
`${contacts} translator contact(s) and ${byContent} SUN page(s) verified in their own records, ` +
`${listed} prayer page(s) proved by doxa.life's own listing, ` +
`${unknownBlind} on targets no status code can prove; ` +
'0 unrecorded, 0 broken.')

View file

@ -138,6 +138,17 @@ const SITES = [
['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null], ['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null], ['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null], ['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
// available to decide that this bundle should start honouring the locale on a bare host. An unowned
// file is the wrong place to make a product decision by default.
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 'fallback: (full) =>'],
// wave 3c — my-upg-100-list (owner: upg100). Both used the humanizeKey fallback and no namespace,
// since callers pass full keys. Each keeps humanizeKey, so a bare host reads the same title-cased
// last segment it always did.
['plugin-designs/my-upg-100-list/poster-wall.vue', 'useOptionalT', 'humanizeKey(k)'],
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', 'humanizeKey(k)'],
] ]
// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived. // ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived.
// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the // resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the

View file

@ -38,11 +38,12 @@ import { createHash } from 'node:crypto'
import { resolve, dirname, join } from 'node:path' import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs' import { TREE_NAME } from './_tree-name.mjs'
import { requirePublishedTreeUrl } from './_published-base.mjs'
const HERE = dirname(fileURLToPath(import.meta.url)) const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = process.env.DOXA_REPO || resolve(HERE, '..') const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
const PUBLISHED_BASE = (process.env.PUBLISHED_BASE const PUBLISHED_BASE = (process.env.PUBLISHED_BASE
|| `https://nuxtbase.app/plugin-assets/${TREE_NAME}`).replace(/\/$/, '') || requirePublishedTreeUrl('PUBLISHED-MATCHES-BUILD')).replace(/\/$/, '')
const TREE = resolve(REPO, `public/${TREE_NAME}`) const TREE = resolve(REPO, `public/${TREE_NAME}`)
const MANIFEST = join(TREE, 'manifest.json') const MANIFEST = join(TREE, 'manifest.json')

View file

@ -86,14 +86,14 @@ function readBuildConfig() {
// The data API every generated page points at, baked as window.MAP_APP_API_URL before the // The data API every generated page points at, baked as window.MAP_APP_API_URL before the
// bundle loads — the HOT-SWAPPABLE backend seam. Empty = same-origin. Set it to the live API // bundle loads — the HOT-SWAPPABLE backend seam. Empty = same-origin. Set it to the live API
// now (https://pray.doxa.life) and flip this ONE field to the self-hosted campaigns-server URL // now (https://pray.doxa.life) and flip this ONE field to the self-hosted campaigns-server URL
// once it runs on go-base; every map repoints with no code change. A per-instance apiBaseUrl // once it runs on the host platform; every map repoints with no code change. A per-instance apiBaseUrl
// in a *.instances.json still overrides this for that instance. // in a *.instances.json still overrides this for that instance.
const apiBaseUrl = (typeof cfg.apiBaseUrl === 'string' ? cfg.apiBaseUrl.trim() : '').replace(/\/+$/, '') const apiBaseUrl = (typeof cfg.apiBaseUrl === 'string' ? cfg.apiBaseUrl.trim() : '').replace(/\/+$/, '')
// GATED profiles: bundle folder names withheld from the generated tree, staging pages and // GATED profiles: bundle folder names withheld from the generated tree, staging pages and
// manifest (governance — e.g. key-cities). Their .js still BUILDS via vite; they simply never // manifest (governance — e.g. key-cities). Their .js still BUILDS via vite; they simply never
// appear on any public surface the generator emits. convert.py gates the plugin index too. // appear on any public surface the generator emits. convert.py gates the plugin index too.
const gatedProfiles = Array.isArray(cfg.gatedProfiles) ? cfg.gatedProfiles.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : [] const gatedProfiles = Array.isArray(cfg.gatedProfiles) ? cfg.gatedProfiles.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : []
// QA corner widget baked into generated embed pages (the go-base plugin surface). Default true. // QA corner widget baked into generated embed pages (the host platform's plugin surface). Default true.
// Partner embeds use the copy-embed SNIPPET with their own config, so they never get this. // Partner embeds use the copy-embed SNIPPET with their own config, so they never get this.
const qaWidget = cfg.qaWidget !== false const qaWidget = cfg.qaWidget !== false
// Ship the 42 MB print-resolution posters/ inside the tree? DEFAULT FALSE. The wall renders // Ship the 42 MB print-resolution posters/ inside the tree? DEFAULT FALSE. The wall renders
@ -758,7 +758,7 @@ function mapPageHtml(opts) {
const apiScript = apiBaseUrl const apiScript = apiBaseUrl
? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>` ? `\n <script>window.MAP_APP_API_URL = ${JSON.stringify(apiBaseUrl)};</script>`
: '' : ''
// Bake the QA corner widget onto the go-base embed surface (opt-out via build-config qaWidget:false). // Bake the QA corner widget onto the host platform's embed surface (opt-out via build-config qaWidget:false).
const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config const pageConfig = DEST_CONFIG.qaWidget ? { ...config, qa: true } : config
// PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or // PROVIDER-AWARE PAGE (see library/composables/useMapProvider.js): a profile or
// instance whose config says provider:'maplibre' gets the MapLibre peer (token- // instance whose config says provider:'maplibre' gets the MapLibre peer (token-

View file

@ -85,7 +85,7 @@ for (const layout of new Set(nested)) {
} }
// SUBPATH-PORTABLE imports: `../<layout>/` resolves only at the web-root (../ cannot escape // SUBPATH-PORTABLE imports: `../<layout>/` resolves only at the web-root (../ cannot escape
// root); served under a subpath (e.g. as a go-base plugin at /plugin-assets/doxa-maps-spa/) // root); served under a subpath (e.g. as a host-platform plugin at /plugin-assets/doxa-maps-spa/)
// it 404s. The layouts are copied as SIBLINGS of index.html, so `./<layout>/` is correct at // it 404s. The layouts are copied as SIBLINGS of index.html, so `./<layout>/` is correct at
// EVERY depth. Rewrite the copied entry so the shell works both at root and under any subpath. // EVERY depth. Rewrite the copied entry so the shell works both at root and under any subpath.
{ {

View file

@ -81,7 +81,7 @@ const LEAK_STRICT = [
[/doxa-life\/doxa-maps/, 'private repo name'], [/doxa-life\/doxa-maps/, 'private repo name'],
[/\bdoxa-life\b/, 'private org name'], [/\bdoxa-life\b/, 'private org name'],
[/\bforgejo\b/i, 'self-hosted forge name'], [/\bforgejo\b/i, 'self-hosted forge name'],
[/\bnuxtbase\b/, 'internal platform name'], [new RegExp('\\b' + ['nuxt', 'base'].join('') + '\\b'), 'internal platform name'], // built from parts so this file does not itself carry the name
[/\bpersecution\b/, 'operational-context vocabulary (allowed only inside data CSVs)'], [/\bpersecution\b/, 'operational-context vocabulary (allowed only inside data CSVs)'],
[/\.forgejo\/workflows/, 'CI workflow path — process, never ships in a public tree'], [/\.forgejo\/workflows/, 'CI workflow path — process, never ships in a public tree'],
] ]
@ -97,7 +97,7 @@ const LEAK_STRICT = [
// ★ pk. IS DELIBERATELY NOT HERE (the agent ruling 2026-09-21): "all maps can use the pk from the // ★ pk. IS DELIBERATELY NOT HERE (the agent ruling 2026-09-21): "all maps can use the pk from the
// api, no problem — if they want to set a mapbox sk they can". A Mapbox `pk.` is a PUBLIC key: it // api, no problem — if they want to set a mapbox sk they can". A Mapbox `pk.` is a PUBLIC key: it
// is designed to ship in client code and is protected by URL RESTRICTIONS, not by secrecy // is designed to ship in client code and is protected by URL RESTRICTIONS, not by secrecy
// (measured: the same pk. serves tiles 200 with a doxa.life Referer and 403s with nuxtbase.app). // (measured: the same pk. serves tiles 200 with a doxa.life Referer and 403s with the live test host's Referer).
// Flagging it made the token ladder's rung-3 baked token UNBUILDABLE — this gate runs inside // Flagging it made the token ladder's rung-3 baked token UNBUILDABLE — this gate runs inside
// build:unlocked, so `pk.` in public/ failed the build that is supposed to produce it. // build:unlocked, so `pk.` in public/ failed the build that is supposed to produce it.
// `sk.` (SECRET key) and `tk.` (temporary) stay flagged: those are real credentials. // `sk.` (SECRET key) and `tk.` (temporary) stay flagged: those are real credentials.

View file

@ -148,7 +148,7 @@ async function shot(name, path, {
// Verified in-browser before wiring: 8/8 tile responses 200 with the header, 403 without. // Verified in-browser before wiring: 8/8 tile responses 200 with the header, 403 without.
// This unblocks BUG-README-SCREENSHOTS-SHOW-BLANK-BASEMAPS and // This unblocks BUG-README-SCREENSHOTS-SHOW-BLANK-BASEMAPS and
// BUG-FOUR-README-PROOF-IMAGES-SHIP-A-BLANK-BASEMAP without needing the agent to change the // BUG-FOUR-README-PROOF-IMAGES-SHIP-A-BLANK-BASEMAP without needing the agent to change the
// token's allow-list. That change is still worth making for LIVE nuxtbase.app rendering, which // token's allow-list. That change is still worth making for rendering on the LIVE test host, which
// this does not address — a browser sets its own Referer and cannot be overridden this way. // this does not address — a browser sets its own Referer and cannot be overridden this way.
// //
// ★ WHY A CONTEXT-WIDE HEADER AND NOT ROUTE INTERCEPTION, measured both ways 2026-09-22: // ★ WHY A CONTEXT-WIDE HEADER AND NOT ROUTE INTERCEPTION, measured both ways 2026-09-22:

View file

@ -4,7 +4,7 @@
* *
* ★ THE DEFECT THIS EXISTS FOR, hit live 2026-09-23. `bun run serve` was * ★ THE DEFECT THIS EXISTS FOR, hit live 2026-09-23. `bun run serve` was
* `python3 -m http.server`, which resolves a DIRECTORY request to its index.html. The live * `python3 -m http.server`, which resolves a DIRECTORY request to its index.html. The live
* nuxtbase.app static layer does NOT: * test host's static layer does NOT:
* *
* /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/ -> 404 live, 200 local * /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/ -> 404 live, 200 local
* /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html -> 200 both * /plugin-assets/doxa-maps/dashboard-plus-grid/dashboard-plus-grid/index.html -> 200 both
@ -122,10 +122,10 @@ createServer((req, res) => {
// composed surfaces (cap-4/cap-5) then FETCH manifest.json + assets/ -- and fetch() // composed surfaces (cap-4/cap-5) then FETCH manifest.json + assets/ -- and fetch()
// enforces CORS, so on any third-party host the shell mounted and every manifest-composed // enforces CORS, so on any third-party host the shell mounted and every manifest-composed
// tab died with 'Could not load ...' (measured 2026-09-24 from a second local origin; the // tab died with 'Could not load ...' (measured 2026-09-24 from a second local origin; the
// LIVE nuxtbase static layer has the same gap -- carded with the Traefik ready-fix). // LIVE test host's static layer has the same gap -- carded with the Traefik ready-fix).
// ACAO:* is the standard for credential-less public assets; production parity is the // ACAO:* is the standard for credential-less public assets; production parity is the
// whole point of this server, so it must teach the same requirement. // whole point of this server, so it must teach the same requirement.
// LIVE_CORS=1 reproduces the LIVE static layer's rule instead (measured on nuxtbase 2026-09-25): // LIVE_CORS=1 reproduces the LIVE static layer's rule instead (measured on the live test host 2026-09-25):
// ACAO on every path INSIDE a folder of the tree, NOT on the tree-root manifest.json. Off by // ACAO on every path INSIDE a folder of the tree, NOT on the tree-root manifest.json. Off by
// default; check:gallery-embed --live-cors uses it to prove the gallery still boots cross-origin // default; check:gallery-embed --live-cors uses it to prove the gallery still boots cross-origin
// under the rule partners actually get. // under the rule partners actually get.

View file

@ -37,9 +37,10 @@ import { requireThisTree } from './_serves-this-tree.mjs'
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
import { fileURLToPath as _fileURLToPath } from 'node:url' import { fileURLToPath as _fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs' import { TREE_NAME } from './_tree-name.mjs'
import { requirePublishedTreeUrl } from './_published-base.mjs'
const GL=['--use-gl=swiftshader','--enable-unsafe-swiftshader','--ignore-gpu-blocklist','--enable-webgl','--no-sandbox'] const GL=['--use-gl=swiftshader','--enable-unsafe-swiftshader','--ignore-gpu-blocklist','--enable-webgl','--no-sandbox']
const B=(process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}/`).replace(/\/?$/,'/') const B=(process.env.BASE || requirePublishedTreeUrl('VERIFY-5-CAPABILITIES')).replace(/\/?$/,'/')
const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..') const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..')
// ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port // ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port

View file

@ -34,6 +34,7 @@ import { requireThisTree } from './_serves-this-tree.mjs'
import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path' import { resolve as _resolvePath, dirname as _dirnamePath } from 'node:path'
import { fileURLToPath as _fileURLToPath } from 'node:url' import { fileURLToPath as _fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs' import { TREE_NAME } from './_tree-name.mjs'
import { requirePublishedTreeUrl } from './_published-base.mjs'
// The SwiftShader launch. Without these a headless Chromium here has NO GL context at all. // The SwiftShader launch. Without these a headless Chromium here has NO GL context at all.
const GL_ARGS = [ const GL_ARGS = [
@ -44,7 +45,7 @@ const GL_ARGS = [
'--no-sandbox', '--no-sandbox',
] ]
const BASE = process.env.BASE || `https://nuxtbase.app/plugin-assets/${TREE_NAME}` const BASE = process.env.BASE || requirePublishedTreeUrl('VERIFY-MAP-PAINT')
const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..') const ROOT = _resolvePath(_dirnamePath(_fileURLToPath(import.meta.url)), '..')
// ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port // ★ MEASURE THIS BUILD, OR MEASURE NOTHING. Several worktrees run on one machine and a default port
@ -121,7 +122,7 @@ console.log('MAP-PAINT: GL ok —', gl.version, '/', gl.renderer)
* ★ SAY WHICH TREE WAS MEASURED (2026-09-25) — this gate defaults to the DEPLOYED site, and a * ★ SAY WHICH TREE WAS MEASURED (2026-09-25) — this gate defaults to the DEPLOYED site, and a
* failure line that does not say so reads as "your code is broken" when it means "your code is * failure line that does not say so reads as "your code is broken" when it means "your code is
* not deployed yet". MEASURED TWICE IN ONE DAY, both times wrongly: resources-map and * not deployed yet". MEASURED TWICE IN ONE DAY, both times wrongly: resources-map and
* connectivity-map both reported FAIL ("the stylesheet is ABSENT here") against nuxtbase.app * connectivity-map both reported FAIL ("the stylesheet is ABSENT here") against the live test host
* while the fix for exactly that defect was sitting, verified, on disk. Served locally, the * while the fix for exactly that defect was sitting, verified, on disk. Served locally, the
* same gate passed all 13 leaves. * same gate passed all 13 leaves.
* *

View file

@ -0,0 +1,258 @@
/**
* useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
* generic: `exportLabel` (what the file calls itself) and `sources` (who to credit), both
* defaulting to what research-dashboard shipped. Everything else is unchanged.
*
* useCsvExport.js — RDASH phase 4: export-any-selection to CSV (native Blob, 0-dep).
*
* Priority model (plan §INTERACTION): selectionSet (explicit picks) > filterSet result (in scope) >
* full dataset. Serializes the FULL stored record per row (flattening {value,label} -> label),
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
* export touches no rendered DOM/styles.
*
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
* table / charts render. The detail endpoint exposes 67.
*
* So the wide record is fetched LAZILY, here, on the first export click — not at mount:
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY
* bundle that shares that file, to serve one button;
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
* for all rows (verified live: 2,107 rows x 67 keys).
* The result is cached for the session; if the fetch fails we still export the loaded record
* (24 fields) rather than producing nothing — degraded, never broken.
*/
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { inject } from 'vue'
import sourcesConfig from '@map/api/sources.json'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
/**
* The full field set for the export, in a stable, human-readable order: identity first, then
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
*/
export const EXPORT_FIELDS = [
// identity
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
'joshua_project_id', 'imb_affinity_code',
// place
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
'imb_subregion', 'location_description',
// people / language
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
'work_in_local_language', 'imb_is_indigenous',
// religion
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
// population / believers
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
'imb_evangelical_level', 'imb_congregation_existing',
// prayer + engagement
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
'disciple_and_church_multiplication',
// scripture + media resources
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
// priority indices
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
// media / description
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
]
/**
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
*/
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
// What this export calls itself, and who to credit for the data. Defaults keep every existing
// caller's file identical to the byte.
exportLabel = 'Research Dashboard', sources = '',
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
// slug -> wide API record, filled by the lazy all-fields fetch below.
let wideStore = null
let widePromise = null
// The map's people-group LIST (library/lists/). Every caller runs in setup, where inject works.
let profileConfig = dataOpts.profileConfig || null
if (!profileConfig) { try { profileConfig = inject('profileConfig', null) } catch (_) { /* outside setup */ } }
/**
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
*/
function fetchWideRows() {
if (widePromise) return widePromise
widePromise = (async () => {
const sourceId = dataOpts.source || sourcesConfig.activeSource
const src = sourcesConfig?.sources?.[sourceId] || {}
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const lang = src.queryParams?.lang || 'en'
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
const json = await res.json()
// Only the groups of this page's people-group LIST (library/lists/) are exported.
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
wideStore = map
return map
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
return widePromise
}
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
function widen(r) {
if (!wideStore) return r
const w = wideStore.get(r && (r.slug || r.id))
return w ? { ...r, ...w } : r
}
function pickRows() {
if (selectionSet && selectionSet.size) {
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
}
const keys = Object.keys(filterSet)
if (keys.length) {
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
// the reader is looking at, and a private copy of the rule is how an export starts
// disagreeing with the screen it was taken from.
return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC))
}
return rows.value || []
}
function scopeLabel() {
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
return Object.keys(filterSet).length ? 'filter result' : 'all'
}
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
function cell(v) {
if (v === null || v === undefined) return v
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
// URL rather than dumping raw JSON braces into the cell.
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
return v
}
function flatten(r) {
const o = {}
for (const k of Object.keys(r)) o[k] = cell(r[k])
return o
}
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
function esc(v) {
let s = v === null || v === undefined ? '' : String(v)
if (/^[=+\-@]/.test(s)) s = "'" + s
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
return s
}
function buildCsv() {
const picked = pickRows().map(widen).map(flatten)
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
// people → engagement → resources), then any key the API returned that the list doesn't name.
// Union across rows, because a null-valued field can be absent on an individual record.
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
// columns below are built from the API record via flatten()/cell() and are untouched, because
// a consumer's formulas and downstream joins key on whatever those columns already emit.
const chips = Object.entries(filterSet)
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : v}`)
.join(' | ') || 'none'
// ★ THE PROVENANCE LINE IS PARAMETERISED (dashboard-kit landing 3), and it is the one piece of
// this exporter that was never generic. "Research Dashboard" is this dashboard's name, and the
// line said nothing about WHERE the data came from — which is the thing a provenance line is for.
// A partner opening a CSV can see the scope and the filters and still not know who to cite.
// `sources` is appended only when a caller supplies it, so today's output is byte-for-byte
// unchanged; go-int's spec asks for exactly this (its tables carry their own meta.sources).
const provenance = `# ${exportLabel} export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
+ (sources ? `; sources: ${sources}` : '')
// ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV".
// the agent-reported 2026-09-22: "this is me exporting the CSV directly without making any edits...
// it does look a little bit glitched out."
//
// MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT
// and must not be "fixed" on suspicion:
// line 0 ... 1 column "# Research Dashboard export — scope: ..."
// line 1 ... N columns slug,name,religion,...
// rows ... N columns each, ZERO with a wrong column count
// embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so
// esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The
// file is comma-separated, not tab-separated — the tabs in the agent's paste were his terminal
// rendering, not the bytes.
//
// The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header.
// So the scope sentence became the column names, the real header became data row 1, and a 1-column
// line above a 68-column table is exactly what "glitched" looks like on screen.
//
// Moving it BELOW the data keeps the provenance — which is deliberate and load-bearing, since it
// records WHICH FILTERS produced the file, the difference between a dataset and an anecdote —
// while making line 1 the header. A trailing 1-column line lands as a final row with one populated
// cell: obvious to a human, harmless to a formula, and it cannot destroy the column mapping.
const lines = [cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(',')), provenance]
return lines.join('\r\n')
}
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
function exportCsv() {
const csv = buildCsv()
const rows = pickRows().length
// The header is now line 1 — the '#' provenance comment moved to the END of the file so that a
// spreadsheet reads the real column row first (see buildCsv). This index had to move WITH it:
// reading line 2 would have counted the columns of DATA ROW 1, which is usually the same number,
// so it would have looked correct and been wrong — the quiet kind of breakage a positional change
// leaves behind.
const fields = (csv.split('\r\n')[0] || '').split(',').length
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
let clipboard = false, downloaded = false
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
// 2) native file download — works when NOT in a download-blocked sandbox
try {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = 'research-dashboard.csv'; a.style.display = 'none'
document.body.appendChild(a); a.click(); downloaded = true
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) { /* download blocked — clipboard above already has it */ }
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
}
/**
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
*
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
* first click on a cold cache resolves the wide rows and then writes: the download path always
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
* settles in a microtask, activation survives, and clipboard works as before.
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
*/
async function exportCsvAsync() {
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
return exportCsv()
}
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
}

View file

@ -0,0 +1,272 @@
/**
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection."
*
* Subscribes to the selection bus and drives the map camera ONCE per committed selection
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps
* `bus.selectionVersion`; this composable watches that version. That is exactly the
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
* no table code.
*
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the agent is photosensitive) ──────────────
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
*
* 1. ONE animation per committed selection change — never per frame, never per row.
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
* so selecting 2,107 rows emits ONE version bump, not 2,107.
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
* many times in a few hundred ms; only the final state moves the camera. Each new bump
* CANCELS the pending move, so intermediate states are never animated.
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
* easing. Same destination, zero traversal, no optical flow across the viewport.
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
* callback, i.e. at most once per settled selection, never inside a computed/watcher
* that the map redraw depends on.
*
* ── COST ──────────────────────────────────────────────────────────────────────────────
* One O(n) pass over the selected rows per settled change (n = selection size), using the
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
* happens once, after the debounce, off the render path.
*/
import { watch } from 'vue'
// ── Camera policy constants ───────────────────────────────────────────────────────────
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
// cascade, short enough that a single checkbox click still feels immediate.
const CAMERA_DEBOUNCE_MS = 150
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
// and the filter camera feel like the same gesture.
const FLY_DURATION_MS = 1200
// Viewport padding so selected pins never sit flush against the panel edge.
const FIT_PADDING_PX = 70
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
const FIT_MAX_ZOOM = 5.5
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
const SINGLE_ROW_ZOOM = 6
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
// sit within metres of each other), which is a zero-area bbox by another route. Below this
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
const DEGENERATE_SPAN_DEG = 0.01
// Past this longitude span a selection effectively wraps the globe; framing it literally
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
const WIDE_SPAN_DEG = 110
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
/**
* ★ THE CAMERA'S POLICY IN ONE NAMED PLACE (dashboard-kit landing 3).
*
* The eight constants above are not magic numbers; together they are a decision about how this
* camera behaves, and the reason for each is in the comment beside it. Collecting them here is what
* the kit spec asked for, and the failure it prevents is specific: a second dashboard re-picking
* them one at a time, arriving at a camera that feels different from Big Picture's for no reason
* anybody wrote down. A consumer that cites CAMERA_POLICY.FLY_DURATION_MS can be asked why it
* differs; a consumer that retypes 1200 has made an invisible decision.
*
* ★ READ-ONLY, AND DELIBERATELY NOT YET OVERRIDABLE. Making each value a per-caller option means
* threading eight of them through every use, and no consumer has asked for a different camera.
* Exporting the decision so it can be READ and CITED is the half that has a use today; the override
* belongs with the first consumer that needs one, who will also be able to say which value and why.
* Saying that here is better than shipping eight options nobody passes.
*/
export const CAMERA_POLICY = Object.freeze({
CAMERA_DEBOUNCE_MS, FLY_DURATION_MS, FIT_PADDING_PX, FIT_MAX_ZOOM,
SINGLE_ROW_ZOOM, DEGENERATE_SPAN_DEG, WIDE_SPAN_DEG, WORLD_ZOOM,
})
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
* preference can be toggled mid-session, and a stale `false` would animate at them. */
function prefersReducedMotion() {
try {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
} catch (_) { return false }
}
/**
* Numeric coordinate pair for a row, or null when unusable.
*
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
*
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
* sentinel that a missing geocode degrades into. Flying there would show the camera an
* empty patch of the Gulf of Guinea.
*/
// ★ HOW a row carries its coordinate is a parameter; WHAT makes a coordinate usable is not.
// `row.longitude` / `row.latitude` is pray-tools' spelling, not a universal one — the next
// dashboard's rows put the same idea under different keys. So only the READ is configurable, and
// every guard below it (Number() on API strings, 0 accepted per-axis, the exact 0,0 null-island
// sentinel rejected, the out-of-range check) applies to every consumer. A kit that let each caller
// re-implement the validation would hand the next dashboard the same four bugs to rediscover.
const DEFAULT_COORD_READ = (row) => [row.longitude, row.latitude]
function coordsOf(row, readPair = DEFAULT_COORD_READ) {
if (!row) return null
const pair = readPair(row) || []
const lng = Number(pair[0])
const lat = Number(pair[1])
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
return [lng, lat]
}
/**
* Antimeridian-aware longitude extent.
*
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
*
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
* and wraps correctly.
*/
function lngExtent(longitudes) {
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
for (const lng of longitudes) {
if (lng < aMin) aMin = lng
if (lng > aMax) aMax = lng
const shifted = lng < 0 ? lng + 360 : lng
if (shifted < bMin) bMin = shifted
if (shifted > bMax) bMax = shifted
}
if (!Number.isFinite(aMin)) return null
const spanA = aMax - aMin
const spanB = bMax - bMin
if (spanB < spanA) {
const minRaw = bMin > 180 ? bMin - 360 : bMin
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
}
return { minLng: aMin, maxLng: aMax, span: spanA }
}
/**
* Bounding box of the selected rows.
*
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
* row array per slug, so a 2,107-row selection stays one linear pass.
*
* @returns {null | { kind:'point', center:[lng,lat] }
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
* null when NOTHING is selected or no selected row has usable coordinates —
* the caller must then leave the camera alone.
*/
export function selectionBounds(selectedSlugs, rowStore, readPair = DEFAULT_COORD_READ) {
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
const lngs = []
let minLat = Infinity, maxLat = -Infinity
let first = null
let count = 0
for (const slug of selectedSlugs) {
const c = coordsOf(rowStore.get(slug), readPair)
if (!c) continue // missing/null/invalid coords — skip the row
if (count === 0) first = c
lngs.push(c[0])
if (c[1] < minLat) minLat = c[1]
if (c[1] > maxLat) maxLat = c[1]
count++
}
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
if (count === 0) return null
if (count === 1) return { kind: 'point', center: first }
const ext = lngExtent(lngs)
if (!ext) return { kind: 'point', center: first }
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
}
return {
kind: 'bounds',
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
span: ext.span,
}
}
/**
* Wire selection -> camera. Call once from the map panel's setup.
*
* @param {Object} o
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
* @param {Function} o.getMap - () => the live map instance (null before ready)
* @param {Function} o.getRowStore - () => Map<slug, row>
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
* @returns {Function} teardown — clears the pending timer and stops the watcher
*/
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true,
// `coords` is the one data-shape assumption this file makes. Default = the fields
// research-dashboard has always used, so a caller that passes nothing is byte-for-byte unchanged.
coords = DEFAULT_COORD_READ }) {
if (!bus || !bus.selectionVersion) return () => {}
let timer = null
function moveCamera() {
timer = null
const map = getMap && getMap()
if (!map || !isEnabled()) return
// ★ `coords` IS PASSED HERE, and leaving it out was a real bug I nearly shipped: the option was
// accepted in the signature and never reached selectionBounds, so a caller could set it, type-
// check clean, build clean, and watch it do nothing. That is the presence-vs-reachability trap
// this tree has paid for before (a defineProps on a component the custom element never binds).
// An option that cannot be observed to work is worse than no option: it reads as supported.
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore(), coords)
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
if (!target) return
// Reduced motion: same destination, zero traversal.
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
try {
if (target.kind === 'point') {
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
return
}
if (target.span > WIDE_SPAN_DEG) {
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
// camera swing the wrong way around the antimeridian — the visible "snap".
mapFly.flyTo(
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
(target.bounds[0][1] + target.bounds[1][1]) / 2],
{ zoom: WORLD_ZOOM, duration, essential: true },
)
return
}
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
})
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
}
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
// this callback ONCE — and the debounce collapses even that into a single camera move.
const stop = watch(() => bus.selectionVersion.value, () => {
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
})
return function teardown() {
if (timer) { clearTimeout(timer); timer = null }
try { stop() } catch (_) {}
}
}

View file

@ -0,0 +1,48 @@
/**
* highlightRanges(value, indices) — wrap each matched index range in
* <span class="search-highlight">, escaping every segment around it.
*
* WHY THIS IS SHARED. Two plugins built the same construction independently, line for line apart from
* variable names: regions-dashboard/src/UupgsList.vue and people-groups-grid/people-groups-grid.vue.
* Both consume the same Fuse-compatible shim (library/components/search/fuzzy.js), both read
* `match.indices`, both wrap with the same class, and both append the tail only on the last range.
* RULE 2 is satisfied by a NAMED second consumer, not by a guess that one might appear.
*
* The construction is dx-life's (their UupgsList.vue:336-348) and is ported rather than invented: walk
* the ranges in order, escape the gap before each hit, wrap the hit, and append the remaining tail once
* at the end. Indices come from the SEARCHED corpus (which may be accent-stripped) and are applied to
* the ORIGINAL string, which is why the caller passes both rather than re-deriving one from the other.
*
* ★ ESCAPING COMES FROM library/utils/html.js, AND THAT IS A DELIBERATE CHANGE FOR BOTH CALLERS.
* Each plugin had hand-rolled its own escapeHtml, and the three differed in ways worth naming:
* library/utils/html.js escapes & < > " ' AND backtick, and returns '' for null/undefined
* UupgsList escapes & < > " ' (no backtick), guards null via String(v ?? '')
* people-groups-grid escapes & < > " ' (no backtick), and has NO NULL GUARD AT ALL —
* `s.replace(...)` on null/undefined THROWS
* Both deltas move in the safe direction: one more character escaped, and a throw removed. Neither
* changes the output for any value that renders today. Stated here because "I swapped your escaper"
* is exactly the kind of quiet substitution that should never be discovered by a reader later.
*
* @param {string} value the ORIGINAL text to render
* @param {Array<[number, number]>} indices inclusive [start, end] pairs, in order
* @returns {string} HTML: escaped text with <span class="search-highlight"> around each range
*/
import { escapeHtml } from '../utils/html.js'
export function highlightRanges(value, indices) {
const original = String(value ?? '')
if (!Array.isArray(indices) || indices.length === 0) return escapeHtml(original)
let cursor = 0
let out = ''
indices.forEach((idx, i) => {
const [start, end] = idx
const isLast = i === indices.length - 1
out += escapeHtml(original.slice(cursor, start))
+ '<span class="search-highlight">' + escapeHtml(original.slice(start, end + 1)) + '</span>'
+ (isLast ? escapeHtml(original.slice(end + 1)) : '')
cursor = end + 1
})
return out
}
export default highlightRanges

View file

@ -92,6 +92,8 @@
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs", "check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
"check:embed-contract": "node internal/check-gospel-embed-contract.mjs", "check:embed-contract": "node internal/check-gospel-embed-contract.mjs",
"check:engine-contract": "node internal/check-gospel-engine-contract.mjs", "check:engine-contract": "node internal/check-gospel-engine-contract.mjs",
"check:link-coverage": "node internal/check-link-verification-coverage.mjs",
"check:link-coverage:poison": "node internal/check-link-verification-coverage.mjs --poison",
"check:engine-contract:poison": "node internal/check-gospel-engine-contract.mjs --poison", "check:engine-contract:poison": "node internal/check-gospel-engine-contract.mjs --poison",
"check:embed-contract:poison": "node internal/check-gospel-embed-contract.mjs --poison", "check:embed-contract:poison": "node internal/check-gospel-embed-contract.mjs --poison",
"check:map-attribution": "node internal/check-map-attribution.mjs", "check:map-attribution": "node internal/check-map-attribution.mjs",
@ -105,6 +107,7 @@
"check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs", "check:i18n-never-translated": "node internal/check-i18n-never-translated.mjs",
"check:i18n-parity": "node internal/check-i18n-parity.mjs", "check:i18n-parity": "node internal/check-i18n-parity.mjs",
"check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs", "check:i18n-treeshake": "node internal/check-i18n-treeshake.mjs",
"check:kit-options-reachable": "node plugin-designs/research-dashboard/scripts/check-kit-options-reachable.mjs",
"check:kpi-unit": "node internal/check-kpi-unit-once.mjs", "check:kpi-unit": "node internal/check-kpi-unit-once.mjs",
"check:label-joins": "node internal/check-label-joins.mjs", "check:label-joins": "node internal/check-label-joins.mjs",
"check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs", "check:language-code-link": "node plugin-designs/doxa-research-map/scripts/check-language-code-link.mjs",
@ -210,6 +213,7 @@
"check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test", "check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test",
"check:tab-radius": "node internal/check-tab-radius-token.mjs", "check:tab-radius": "node internal/check-tab-radius-token.mjs",
"check:tab-radius:self-test": "node internal/check-tab-radius-token.mjs --self-test", "check:tab-radius:self-test": "node internal/check-tab-radius-token.mjs --self-test",
"check:text-highlight": "node plugin-designs/regions-dashboard/scripts/check-text-highlight.mjs",
"check:tabs-localized": "node internal/check-tabs-localized.mjs", "check:tabs-localized": "node internal/check-tabs-localized.mjs",
"check:tauri": "node internal/check-tauri-config.mjs", "check:tauri": "node internal/check-tauri-config.mjs",
"check:template-literal": "node internal/template-literal-check.mjs", "check:template-literal": "node internal/template-literal-check.mjs",
@ -225,7 +229,7 @@
"dev": "bun x vite", "dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs", "fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.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 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 check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence", "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 check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight",
"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", "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",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs", "internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs", "library-index": "node internal/generate-library-index.mjs",
@ -331,6 +335,7 @@
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.", "check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.", "check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.", "check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
"check:kit-options-reachable": "every option a shared kit composable ACCEPTS must be honoured somewhere in its body. Written because I nearly shipped one: kit landing 3 gave useSelectionCamera a `coords` option so a dashboard whose rows spell their coordinate differently could say so, and it was accepted in the signature and never passed to selectionBounds - a caller could set it, type-check clean, build clean and watch it do nothing. That is the worst shape a missing feature takes, because it READS as supported and nobody files a bug against a knob they assume they misused; this tree already paid for it once with a defineProps on a component the custom element never binds. It matters more for the kit than for one dashboard: the whole promise of SPEC-DASHBOARD-KIT is that a config produces a working dashboard, and a parameter that is merely present turns that promise into a guess. Strips comments from BOTH the signature and the body before searching - the first version was satisfied by the comment beside the fixed call site, and the second read comment prose as option names. Not a type checker: it catches a completely unused option, which is the failure invisible from outside.",
"check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.", "check:language-code-link": "the map's language selection reaches the grid as a locale-stable CODE, or narrows nothing at all. rega measured Languages > Language > \"Arabic\" taking the grid 2,107 -> 0 in BOTH locales: the emitter looked for a row whose language LABEL equalled the selected name, but a language tier's label is derived by splitting the API label at the comma, and no row reads \"Arabic\" - they read \"Arabic, Sudanese\", \"Arabic, Levantine\", 22 codes in all. Lifts the emitter's match rule and the composite's gridLanguage() from the shipping source (never re-typed) and runs them against a committed fixture of the 864 distinct language label/value pairs: the emitter's rule must agree with the legend's own three-arm filter on every (label, tier) pair, every tier must select at least one row, a tier covering one code must emit it, a tier covering several must emit NONE and leave the axis unfiltered. No network, no browser; --refresh-fixture re-takes the fixture.",
"check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.", "check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.",
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.", "check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
@ -394,11 +399,13 @@
"check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.", "check:translator-contacts": "the credit line LINKS the translating organisation now, and a contact URL is a link this map stands behind. Reads ALLOWED_CONTACT out of internal/verify-gospel-map.mjs rather than restating it (two copies of an allowlist drift, and the drifting copy is the one nobody reads), then asserts every cr.u is on that list, was recorded `verified` by a real fetch, is never also serving a RESOURCE (wycliffe.org is somewhere to write to, not a publisher this map reads text from), and is actually RENDERED as an anchor \u2014 the data can carry 838 URLs while the template shows none. Static, sub-second. Self-test: `bun run check:translator-contacts:poison`.",
"check:embed-contract": "the map's URL is a PUBLIC INTERFACE: a partner embeds ?codes=<iso3 list>, ?country=<ISO3> or ?view=zero in an iframe and never touches it again. A refactor breaks those links SILENTLY \u2014 the leaf still mounts, renders and logs no errors, it just shows the whole world to a church that asked for its forty groups. Asserts through the reader's eyes (the partner-list notice's two numbers), with both numbers and the probe codes derived from the roster the leaf itself reads, never typed in. Browser gate; needs no server of its own. Self-test: `bun run check:embed-contract:poison`.", "check:embed-contract": "the map's URL is a PUBLIC INTERFACE: a partner embeds ?codes=<iso3 list>, ?country=<ISO3> or ?view=zero in an iframe and never touches it again. A refactor breaks those links SILENTLY \u2014 the leaf still mounts, renders and logs no errors, it just shows the whole world to a church that asked for its forty groups. Asserts through the reader's eyes (the partner-list notice's two numbers), with both numbers and the probe codes derived from the roster the leaf itself reads, never typed in. Browser gate; needs no server of its own. Self-test: `bun run check:embed-contract:poison`.",
"check:engine-contract": "the map must use the engine it was ASKED for or fail VISIBLY \u2014 never a substitute. resources-map read `engineGlobal(provider) || window.mapboxgl`; engineGlobal already defaults to mapbox, so that could only fire for provider 'maplibre' and then returned MAPBOX \u2014 a keyless page silently handed the engine that needs a token, which the profile then does not give it, so its tiles 401. Invisible to every other gate: the leaf mounts, renders and logs nothing. Six runtime measurements (2 providers x engine present / blocked / both blocked, CDN blocked by request routing the way a proxy or an offline kiosk does) plus a static arm on the source, comments stripped so the fix's own quotation of the deleted line is not read as the defect. Mapbox arms inject a dummy MAP_TOKEN, because without one the element never mounts on a static tree and that is the environment, not the product. Engine attribution is read off the canvas class, not a debug handle. Browser gate. Self-test: `bun run check:engine-contract:poison`.", "check:engine-contract": "the map must use the engine it was ASKED for or fail VISIBLY \u2014 never a substitute. resources-map read `engineGlobal(provider) || window.mapboxgl`; engineGlobal already defaults to mapbox, so that could only fire for provider 'maplibre' and then returned MAPBOX \u2014 a keyless page silently handed the engine that needs a token, which the profile then does not give it, so its tiles 401. Invisible to every other gate: the leaf mounts, renders and logs nothing. Six runtime measurements (2 providers x engine present / blocked / both blocked, CDN blocked by request routing the way a proxy or an offline kiosk does) plus a static arm on the source, comments stripped so the fix's own quotation of the deleted line is not read as the defect. Mapbox arms inject a dummy MAP_TOKEN, because without one the element never mounts on a static tree and that is the environment, not the product. Engine attribution is read off the canvas class, not a debug handle. Browser gate. Self-test: `bun run check:engine-contract:poison`.",
"check:link-coverage": "rule 4 of the brief — 'verify a URL resolves before shipping it' — as a gate instead of a habit. verify-gospel-links.mjs does the verifying; nothing asserted the record COVERS what is shipped, so the honest state of the corpus was a number someone had to go and measure. DISCOVERS the URLs by recursing the pin index for any http string rather than naming fields: every field list in this bundle has gone stale (ga and se shipped unscreened, the SUN block and the access table were each added after the fact), and the recursion found 16 links my own hand-rolled measurement had missed. Resolves each against THREE records — the verification table, translator-org-links.json (contacts, fetched by their own script) and sun-deaf-index.json (proved by content against a catch-all control, which is stronger than a status code) — and fails on any unrecorded link, any shipped link recorded BROKEN, and any 'unknown' on a target a status code CAN prove. The status-blind list holds one URL SHAPE, not a host: jesusfilm.org/watch/jesus/<slug> discriminates and 2,049 links depend on that, while /watch/?languageId= does not. Static, sub-second. Self-test: `bun run check:link-coverage:poison`.",
"check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.", "check:snapshot-guard": "every gospel-resources catalogue fetcher must write through scripts/_snapshot-guard.mjs, which refuses a write that loses more than a tenth of the previous snapshot. Written after a rate-limited FCBH run silently replaced 1,666 video scopes with 1,103. Static, sub-second. Self-test: `bun run check:snapshot-guard:poison` injects a raw write and the check must fail on it.",
"check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the agent's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.", "check:spa-tab-order": "the cap-5 SPA must open on Big Picture and keep the agent's order (Big Picture, UPG-100, Prayer Progress, Gospel Resources) — asserted as PRESENCE + tab-1 + RELATIVE order on the RENDERED rail, because the rail gained three sections and now renders 12 rows with others between them. ⚠ NOT WAVE-WIRED: it FAILS rather than skips when the SPA is unreachable, deliberately (\"a gate that cannot reach its subject has not passed, it has not run\") — correct standalone, but gates:post runs on checkouts with no server. Needs `bun run serve` on 4173.",
"check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.", "check:stale-sweep-claim": "a caveat that has stopped being true is a wrong number. The gospel surfaces carry a paragraph saying which pools were searched; while the GRN sweep ran they truthfully said part of it was unread, the sweep finished, and the sentence stayed for four more commits. The DATA decides here: undecided gap codes mean the claim is allowed, all-decided means it must go, so it re-arms itself for the next slow pool. Does NOT flag a claim rendered under a v-if on the pending count — that form expires on its own. Self-test: `--poison`. Static, sub-second.",
"check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.", "check:surfaces-read-the-tier": "a surface may READ the tier the pin index computed, never re-derive 'this group has nothing' from the raw resource fields. That hand-rolled field list went stale every time a source was added: 7 codes drawn as having nothing while the map showed them a link, then 28 when Scripture Earth landed. Static, sub-second. Self-test: `bun run check:surfaces-read-the-tier:poison` re-injects the old formula into every surface and each must be caught.",
"check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.", "check:tabs-localized": "a chosen locale must CHANGE the rendered tab text. check:i18n-parity compares catalogs to each OTHER, so it cannot tell a shipped translation from a dead one; tabs.json is reached only via a DYNAMIC key (SemanticTreeLegend.vue:40), invisible to any grep. Renders it and reads the result.",
"check:text-highlight": "useTextHighlight (library/composables) matches the inline search-highlight construction it replaced in regions-dashboard UupgsList and people-groups-grid across six range shapes, and pins the two deliberate escaping deltas (backtick escaped; null returns '' instead of throwing) with controls proving each is real.",
"check:tree-weight": "the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.", "check:tree-weight": "the tree we publish has a weight and nothing was watching it. A publish was ALREADY BLOCKED once at a 74MB asset tree (Forgejo rejected the pack); it has since grown to 132MB unnoticed. Reports the SPLIT, because a bare total invites deleting the wrong half: the 176 city posters are the heavy half AND the half that cannot be regenerated (they need OSM tiles). Raw bytes, not packed — see publish-html.mjs for why a raw saving is not a shipped one.",
"check:upg-map-data": "the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.", "check:upg-map-data": "the UPG-100 map must carry OUR polygons/zones/pins, not just a basemap — reads the counter the profile itself writes. Pass live URLs to audit what is published.",
"check:url-resolver": "cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)", "check:url-resolver": "cap-5 resolve() must not prefix a rooted URL (the dev-surface 404 class)",

View file

@ -30,7 +30,7 @@
* answers. A slow or unreachable API costs the black layer, never the map. * answers. A slow or unreachable API costs the black layer, never the map.
*/ */
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue' import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js' import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js'
import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js' import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js'
import { useMapData } from '@map/composables/useMapData.js' import { useMapData } from '@map/composables/useMapData.js'
@ -108,18 +108,26 @@ const EN = { title: 'All People Groups', locations: '{n} group–country locatio
frontier: 'Frontier — nothing started', unreached: 'Unreached — work beginning', reached: 'Reached — church is there', frontier: 'Frontier — nothing started', unreached: 'Unreached — work beginning', reached: 'Reached — church is there',
hint: 'Click a row to see all of them · click again for everything', loading: 'Loading every people group…', hint: 'Click a row to see all of them · click again for everything', loading: 'Loading every people group…',
loadError: 'The map could not be loaded.', pray: 'Pray for them →', population: 'pop {n}' } loadError: 'The map could not be loaded.', pray: 'Pray for them →', population: 'pop {n}' }
let i18n = null // C4 wave 3d: the guard is library/composables/useOptionalI18n now — this site needs more than `t`,
try { i18n = useI18n() } catch (_) { /* no i18n on this host: English below */ } // so it takes the {t, te, locale} form rather than useOptionalT.
function tr(key, vars) { // ★ EN STAYS THE FALLBACK, passed in. It is an inline English map, not the catalogue, so routing this
const full = 'allPeopleGroups.' + key // to tStatic would change what a bare host renders. gospel-plugin and exp-shepherd each CHOSE the
// vue-i18n treats {n} as ITS placeholder: pass the values to t(), or it blanks them out before any // locale-honouring default for their own bundles; all-people-groups has no owner to ask
// later substitution sees them (the legend read "group–country locations" with no number). // (studio agreed to treat it as unowned), so the conservative reading wins: preserve what it renders.
if (i18n && i18n.te && i18n.te(full)) return i18n.t(full, vars || {}) // The keys arrive BARE here ('hint', 'pray'), and qualify() adds the 'allPeopleGroups.' prefix, which
let v = EN[key] // is exactly what the hand-written `const full = 'allPeopleGroups.' + key` did.
if (vars) for (const k in vars) v = String(v).split('{' + k + '}').join(String(vars[k])) // ★ ONE EQUIVALENCE WORTH STATING: the old code asked te() first and only then t(). The helper calls
return v // t() and falls back when it returns the key unchanged. Same outcome both ways — present key: the
} // translation; absent key: EN — and it no longer depends on te() being implemented.
const uiLocale = computed(() => (i18n && i18n.locale && i18n.locale.value) || 'en') const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups', {
fallback: (full) => EN[String(full).replace(/^allPeopleGroups\./, '')]
})
// locale arrives as a REF when the host installed i18n and a plain string when it did not
// (PosterWall.vue records the same variance), so unwrap both shapes.
const uiLocale = computed(() => {
const l = i18nLocale
return (l && typeof l === 'object' && 'value' in l ? l.value : l) || 'en'
})
const fmt = (n) => { try { return Number(n).toLocaleString(uiLocale.value) } catch (_) { return Number(n).toLocaleString() } } const fmt = (n) => { try { return Number(n).toLocaleString(uiLocale.value) } catch (_) { return Number(n).toLocaleString() } }
// ─── The four categories (the agent's palette) ────────────────────────────── // ─── The four categories (the agent's palette) ──────────────────────────────

View file

@ -30,6 +30,7 @@ import { writeSnapshot } from './_snapshot-guard.mjs'
const HERE = dirname(fileURLToPath(import.meta.url)) const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..') const REPO = resolve(HERE, '../../..')
const OUT = resolve(REPO, 'data/tables/gospel-by-province.json') const OUT = resolve(REPO, 'data/tables/gospel-by-province.json')
const OUT_GROUPS = resolve(REPO, 'data/tables/gospel-by-province-groups.json')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8')) const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const BOUND = (f) => JSON.parse(readFileSync(resolve(REPO, 'public/dx-maps/assets/boundaries', f), 'utf8')) const BOUND = (f) => JSON.parse(readFileSync(resolve(REPO, 'public/dx-maps/assets/boundaries', f), 'utf8'))
@ -110,6 +111,7 @@ const COUNTRIES = [
const rows = [] const rows = []
const unassigned = [] const unassigned = []
const assignments = []
let provincesAvailable = 0 let provincesAvailable = 0
for (const c of COUNTRIES) { for (const c of COUNTRIES) {
const feats = BOUND(c.file).features.map((f) => ({ f, b: bboxOf(f), name: f.properties.shapeName })) const feats = BOUND(c.file).features.map((f) => ({ f, b: bboxOf(f), name: f.properties.shapeName }))
@ -124,6 +126,20 @@ for (const c of COUNTRIES) {
if (featHas(cand.f, x, y)) { hit = cand.name; break } if (featHas(cand.f, x, y)) { hit = cand.name; break }
} }
if (!hit) { unassigned.push({ country: c.country, iso3: c.iso3, people_group: r.name, lat: r.lat, lng: r.lng }); continue } if (!hit) { unassigned.push({ country: c.country, iso3: c.iso3, people_group: r.name, lat: r.lat, lng: r.lng }); continue }
/* THE PER-GROUP ROW, published beside the aggregate so no other build has to repeat this
point-in-polygon. gospel-internet was running the identical ray-casting over the identical
two geometry files and refusing to write unless its counts matched these; a second
implementation of the same geometry is a second place for it to drift. */
assignments.push({
people_group: r.name,
country: c.country,
iso3: c.iso3,
province: hit,
iso639_3: r.iso639_3 || '',
population: r.population || 0,
tier: tiers[r.iso639_3] ?? r.tier ?? 0,
approximate: true,
})
if (!by.has(hit)) by.set(hit, []) if (!by.has(hit)) by.set(hit, [])
by.get(hit).push(r) by.get(hit).push(r)
} }
@ -212,6 +228,49 @@ if (!assigned || assigned < 900) {
console.error(' geometry or the coordinates changed shape, not that the answer moved.') console.error(' geometry or the coordinates changed shape, not that the answer moved.')
process.exitCode = 1 process.exitCode = 1
} else { } else {
/* ═══ THE TWO FILES MUST AGREE, ASSERTED HERE ════════════════════════════════════════════
* The aggregate says a province holds N groups; the per-group file lists them. Published
* separately (the map fetches only the aggregate, lazily, and has no use for 1,005 rows), which
* means they CAN disagree — so they are reconciled before either is written. gospel-internet
* built its own copy of this point-in-polygon and refused to write unless its counts matched
* ours; it should not have to, and it should not have to trust us either.
*/
const perProvince = new Map()
for (const a of assignments) perProvince.set(`${a.iso3}|${a.province}`, (perProvince.get(`${a.iso3}|${a.province}`) || 0) + 1)
const disagree = rows.filter((r) => (perProvince.get(`${r.iso3}|${r.province}`) || 0) !== r.groups)
if (disagree.length || assignments.length !== assigned) {
console.error(`GOSPEL-BY-PROVINCE: REFUSING TO WRITE — the per-group file and the aggregate disagree on ${disagree.length} province(s); ${assignments.length} assignments vs ${assigned} counted.`)
process.exit(1)
}
writeSnapshot(
OUT_GROUPS,
`${JSON.stringify({
meta: {
title: 'Which province each people group falls in — India and China',
why: 'Published so no other build has to repeat this point-in-polygon. gospel-internet ran ' +
'the identical ray-casting over the identical geometry and could only check itself against ' +
'our aggregates; a second implementation of the same geometry is a second place for it to drift.',
generated: new Date().toISOString().slice(0, 10),
grain: 'one row per PLACED people group; the four that fall outside every polygon are in the aggregate file\'s `unassigned`',
rows: assignments.length,
agrees_with: 'gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ',
approximate: 'Every row is approximate. The coordinate marks where a PEOPLE is found, not a ' +
'settlement, and near a border it can fall on the wrong side. No surface may present a ' +
'province as a fact about a group.',
},
columns: [
{ title: 'People group', field: 'people_group', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Province', field: 'province', type: 'string' },
{ title: 'Language code', field: 'iso639_3', type: 'string' },
{ title: 'People', field: 'population', type: 'number' },
{ title: 'Tier', field: 'tier', type: 'number' },
],
rows: assignments,
}, null, 1)}\n`,
(j) => (j.rows || []).length,
'province assignment(s)',
)
writeSnapshot( writeSnapshot(
OUT, OUT,
`${JSON.stringify({ `${JSON.stringify({
@ -225,6 +284,7 @@ if (!assigned || assigned < 900) {
provinces_available: provincesAvailable, provinces_available: provincesAvailable,
groups_assigned: assigned, groups_assigned: assigned,
groups_unassigned: unassigned.length, groups_unassigned: unassigned.length,
per_group_file: 'gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time',
india_note: 'India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet ' + india_note: 'India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet ' +
'India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is ' + 'India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is ' +
'the UPG-100 list\'s curated 34-unit partition, and eleven groups came out with no ' + 'the UPG-100 list\'s curated 34-unit partition, and eleven groups came out with no ' +

View file

@ -31,19 +31,32 @@ const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Geck
const JF = '1_jf-0-0' const JF = '1_jf-0-0'
/** /**
* `jos` IS SKIPPED, AND THE REASON IS UPSTREAM. ISO 639-3 jos is Jordanian Sign Language. * ═══ TWO ARCLIGHT LANGUAGES, ONE ISO CODE — RESOLVED, WITH THE EVIDENCE ═════════════════════
* Arclight's roster files iso3 "jos" under languageId 139080, whose own name field reads
* "Lebanese Sign Language" — a different language (lbs). Meanwhile the JESUS Film DOES exist
* at languageId 7138, named Jordanian Sign Language, which is the id the pin index carries.
* *
* So the ISO join — the thing this whole map is built on — disagrees with the publisher's own * This code was SKIPPED, and the note here said resolving it meant deciding whether the ISO code
* label for this one code, and the fallback below resolved 139080 to Magdalena: the wrong film * or the publisher's name was authoritative — "a data question for someone who can check it".
* AND the wrong language, shown to three people groups. Left exactly as it was rather than * It has now been checked, against Arclight's own API rather than against an opinion:
* guessed at. Resolving it means deciding whether the code or the name is authoritative here, *
* and that is a data question for someone who can check it against Ethnologue, not something * media-languages?iso3=jos returns TWO ids
* to settle inside a fetcher. * 7138 name "Jordanian Sign Language" JESUS film -> arc.gt/g8wmw
* 139080 name "Lebanese Sign Language" (the id the roster file lists under jos)
* arc.gt/g8wmw redirects to .../watch/jesus.html/jordanian-sign-language.html — the publisher's
* own slug names the language, and a bogus arc.gt code 404s, so it discriminates.
*
* ⚠ AND THE OLD NOTE WAS WRONG ON A FACT. It called Lebanese Sign Language "a different language
* (lbs)". ISO 639-3 lbs is LIBYAN Sign Language; Lebanese Sign Language has no code of its own.
* So this is not a mis-filing to be corrected upstream — it is two Levantine varieties sharing one
* code, which ISO does not separate. Nothing here can be "given back" to a Lebanese code, because
* there is none, and joining 139080 to our lbs would be a NAME match on the wrong country.
*
* THE RULE, therefore: where several Arclight ids share an ISO code, take the one whose own NAME
* agrees with the language this map names for that code, and record the other. The code stays
* authoritative for the JOIN; the name only breaks a tie the code cannot. Our three jos groups are
* Jordanian, so they get the Jordanian film and not the Lebanese variety.
*/ */
const SKIP = new Set(['jos']) const OVERRIDE = {
jos: { id: 7138, why: 'two Arclight ids carry iso3 jos (7138 Jordanian, 139080 Lebanese); 7138 is the one whose name agrees with this map\'s language for the code, and its shareUrl slug says jordanian-sign-language' },
}
const DELAY = Number(process.env.DELAY_MS || 250) const DELAY = Number(process.env.DELAY_MS || 250)
const arc = JSON.parse(readFileSync(ARC, 'utf8')).codes const arc = JSON.parse(readFileSync(ARC, 'utf8')).codes
@ -54,10 +67,12 @@ let fetched = 0, cached = 0, missing = 0
for (let i = 0; i < entries.length; i++) { for (let i = 0; i < entries.length; i++) {
const [code, v] = entries[i] const [code, v] = entries[i]
if (SKIP.has(code)) { cached++; continue }
if (out[code] !== undefined) { cached++; continue } if (out[code] !== undefined) { cached++; continue }
/* An override replaces the ROSTER's id, not the result: the shareUrl is still fetched from the
API for that id, so a wrong override cannot invent a link. */
const id = OVERRIDE[code] ? OVERRIDE[code].id : v.id
try { try {
const r = await fetch(`https://api.arclight.org/v2/media-components/${JF}/languages/${v.id}`, { headers: { 'User-Agent': UA } }) const r = await fetch(`https://api.arclight.org/v2/media-components/${JF}/languages/${id}`, { headers: { 'User-Agent': UA } })
if (r.ok) { if (r.ok) {
const d = await r.json() const d = await r.json()
// https, always: the API returns http:// and a plain-http link in a field context // https, always: the API returns http:// and a plain-http link in a field context
@ -98,12 +113,12 @@ for (let i = 0; i < entries.length; i++) {
* than falling back to a URL nobody can check. */ * than falling back to a URL nobody can check. */
let alt = null let alt = null
try { try {
const c = await fetch(`https://api.arclight.org/v2/media-components?languageIds=${v.id}&limit=25`, { headers: { 'User-Agent': UA } }) const c = await fetch(`https://api.arclight.org/v2/media-components?languageIds=${id}&limit=25`, { headers: { 'User-Agent': UA } })
if (c.ok) { if (c.ok) {
const cj = await c.json() const cj = await c.json()
const feature = (cj._embedded?.mediaComponents || []).find((m) => m.subType === 'featureFilm') const feature = (cj._embedded?.mediaComponents || []).find((m) => m.subType === 'featureFilm')
if (feature) { if (feature) {
const fr = await fetch(`https://api.arclight.org/v2/media-components/${feature.mediaComponentId}/languages/${v.id}`, { headers: { 'User-Agent': UA } }) const fr = await fetch(`https://api.arclight.org/v2/media-components/${feature.mediaComponentId}/languages/${id}`, { headers: { 'User-Agent': UA } })
if (fr.ok) { if (fr.ok) {
const fd = await fr.json() const fd = await fr.json()
const fu = String(fd.shareUrl || '').replace(/^http:\/\//, 'https://') const fu = String(fd.shareUrl || '').replace(/^http:\/\//, 'https://')
@ -130,7 +145,18 @@ for (let i = 0; i < entries.length; i++) {
writeSnapshot(OUT, JSON.stringify({ writeSnapshot(OUT, JSON.stringify({
_note: 'Per-language JESUS Film Project deep links (shareUrl) from Arclight media-components. A STRING is the JESUS Film (component 1_jf-0-0). An OBJECT {u,t} is a different JESUS Film Project feature in that language — the JESUS component has no entry for it — and t is the real title, which the surfaces must show instead of "JESUS Film". null means no feature exists and the film is dropped rather than linked to the retired ?languageId= URL, which nothing can verify.', _note: 'Per-language JESUS Film Project deep links (shareUrl) from Arclight media-components. A STRING is the JESUS Film (component 1_jf-0-0). An OBJECT {u,t} is a different JESUS Film Project feature in that language — the JESUS component has no entry for it — and t is the real title, which the surfaces must show instead of "JESUS Film". null means no feature exists and the film is dropped rather than linked to the retired ?languageId= URL, which nothing can verify.',
generated: new Date().toISOString().slice(0, 10), generated: new Date().toISOString().slice(0, 10),
stats: { languages: entries.length, withShareUrl: Object.keys(out).length, missing }, /* COUNTED, NOT NAMED. withShareUrl used to be Object.keys(out).length, which counts the nulls
and the alternative-feature objects too — so a file holding one null reported every language
as having a shareUrl, and the previous run reported 2,087 of them while jos was absent
entirely. Each state is now counted as what it is. */
stats: {
languages: entries.length,
recorded: Object.keys(out).length,
jesusFilm: Object.values(out).filter((v) => typeof v === 'string').length,
otherFeature: Object.values(out).filter((v) => v && typeof v === 'object').length,
noFeature: Object.values(out).filter((v) => v === null).length,
notAnswered: missing,
},
codes: out, codes: out,
}), (j) => Object.keys(j.codes || {}).length, 'Arclight shareUrl codes') }), (j) => Object.keys(j.codes || {}).length, 'Arclight shareUrl codes')
console.log(`DONE languages ${entries.length} shareUrl ${Object.keys(out).length} missing ${missing}`) console.log(`DONE languages ${entries.length} JESUS film ${Object.values(out).filter((v) => typeof v === 'string').length} other feature ${Object.values(out).filter((v) => v && typeof v === 'object').length} no feature ${Object.values(out).filter((v) => v === null).length} not answered ${missing}`)

View file

@ -118,6 +118,28 @@ const bump = (k) => { changed[k] = (changed[k] || 0) + 1 }
const any = hasAny(c) const any = hasAny(c)
if (r.any_resource_found !== any) { r.any_resource_found = any; bump('any_resource_found') } if (r.any_resource_found !== any) { r.any_resource_found = any; bump('any_resource_found') }
if (r.no_resource_found !== !any) { r.no_resource_found = !any; bump('no_resource_found') } if (r.no_resource_found !== !any) { r.no_resource_found = !any; bump('no_resource_found') }
/* ═══ THE URLS, AND THIS TABLE IS SHIPPED ═════════════════════════════════════════════
* The booleans were being refreshed while the LINKS beside them were not, and this table
* is the map's "Resources by People Group" tab — a reader clicks these. Measured before
* this pass: 2,294 URL cells, of which 1,412 were the retired
* jesusfilm.org/watch/?languageId=<id> form. That form is IGNORED by the site — a real id
* and a bogus one render the same generic library page — which is why the map migrated off
* it onto Arclight shareUrls. The migration never reached here, so the tables page kept
* offering links that go nowhere in particular, for a year's worth of builds.
*
* The index is authoritative for these two fields and its URLs are the verified ones, so
* they are copied across. Only where the index HAS one: a curated URL is never blanked by
* a pool that happens to have no entry. */
const sUrl = (codes[c] || {}).s, fUrl = (codes[c] || {}).f
if ('scripture_url' in r && sUrl && r.scripture_url !== sUrl) {
if (/[?&]languageId=/.test(r.scripture_url || '')) bump('retired_form_replaced')
r.scripture_url = sUrl; bump('scripture_url')
}
if ('film_url' in r && fUrl && r.film_url !== fUrl) {
if (/[?&]languageId=/.test(r.film_url || '')) bump('retired_form_replaced')
r.film_url = fUrl; bump('film_url')
}
} }
// THE POOL LIST IS A CLAIM TOO. This table still told readers GRN was "not included: // THE POOL LIST IS A CLAIM TOO. This table still told readers GRN was "not included:
// requires an API key" — false on both halves. GRN is included, 583 codes of it, and it // requires an API key" — false on both halves. GRN is included, 583 codes of it, and it

View file

@ -39,7 +39,7 @@
* IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF. * IDEA-POSTER-EXPORT-404-ON-DROPPED-FOLDER-TRADEOFF.
*/ */
import { computed, inject, ref } from 'vue' import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useOptionalT } from '@map/composables/useOptionalT.js'
// The page's people-group LIST (library/lists/): every people-group fetch below keeps only its groups. // 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 { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import PosterWall from '@map/components/poster/PosterWall.vue' import PosterWall from '@map/components/poster/PosterWall.vue'
@ -163,8 +163,13 @@ async function fetchListRecords(list) {
// Guarded like every other useI18n() here: it throws on a host without vue-i18n. // Guarded like every other useI18n() here: it throws on a host without vue-i18n.
const humanizeKey = (k) => String(k).split('.').pop() const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()) .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k) // C4 wave 3c: the guard is library/composables/useOptionalT.js now. humanizeKey STAYS as the fallback,
try { t = useI18n().t } catch (_) { /* no i18n on this host */ } // passed in, so a bare host still reads a title-cased last segment rather than a dotted key. No
// namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
// ★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the bare useI18n().t, so on
// a host WITH i18n a missing key rendered the raw dotted key. It now falls through to humanizeKey, the
// same fallback the bare host already got. Nothing that resolves today changes.
const t = useOptionalT('', { fallback: (k) => humanizeKey(k) })
const intro = computed(() => t('messages.upg100WallIntro')) const intro = computed(() => t('messages.upg100WallIntro'))
const heading = computed(() => t('messages.upg100WallHeading')) const heading = computed(() => t('messages.upg100WallHeading'))
const wallTag = computed(() => t('messages.upg100WallTag')) const wallTag = computed(() => t('messages.upg100WallTag'))

View file

@ -34,7 +34,7 @@
* whole MFE (both panes) is what gets shared/embedded. * whole MFE (both panes) is what gets shared/embedded.
*/ */
import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, inject, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n' import { useOptionalT } from '@map/composables/useOptionalT.js'
import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js' import { buildPaneColumn, PANE_GRID_STYLES } from '@map/layouts/pane-wrapper/pane-wrapper.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import ViewSelect from '@map/components/ViewSelect.vue' import ViewSelect from '@map/components/ViewSelect.vue'
@ -50,8 +50,13 @@ import ShareButton from '@map/components/map-controls/ShareButton.vue'
// humanises the key so a bare host shows "Select View", never a blank or a raw dotted key. // humanises the key so a bare host shows "Select View", never a blank or a raw dotted key.
const humanizeKey = (k) => String(k).split('.').pop() const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()) .replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k) // C4 wave 3c: the guard is library/composables/useOptionalT.js now. humanizeKey STAYS as the fallback,
try { t = useI18n().t } catch (_) { /* no i18n on this host */ } // passed in, so a bare host still reads a title-cased last segment rather than a dotted key. No
// namespace: callers pass FULL keys here, exactly as the raw useI18n().t they used to get.
// ★ ONE BEHAVIOUR GAIN, stated rather than smuggled: the old i18n path was the bare useI18n().t, so on
// a host WITH i18n a missing key rendered the raw dotted key. It now falls through to humanizeKey, the
// same fallback the bare host already got. Nothing that resolves today changes.
const t = useOptionalT('', { fallback: (k) => humanizeKey(k) })
const cfg = inject('profileConfig', ref(null)) const cfg = inject('profileConfig', ref(null))
const tk = inject('mapboxToken', ref('')) const tk = inject('mapboxToken', ref(''))

View file

@ -42,6 +42,7 @@
import { ref, computed, watch, onMounted, getCurrentInstance } from 'vue' import { ref, computed, watch, onMounted, getCurrentInstance } from 'vue'
// SHARED search primitives (one source of truth for BOTH cap-2 grid + cap-1 dashboard). // SHARED search primitives (one source of truth for BOTH cap-2 grid + cap-1 dashboard).
import Fuse from '@map/components/search/fuzzy.js' import Fuse from '@map/components/search/fuzzy.js'
import { highlightRanges } from '@map/composables/useTextHighlight.js'
import SearchBar from '@map/components/search/SearchBar.vue' import SearchBar from '@map/components/search/SearchBar.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue' import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import { resolveAssetsBase } from '@map/utils/embedBase.js' import { resolveAssetsBase } from '@map/utils/embedBase.js'
@ -474,10 +475,6 @@ const visibleFilterCount = computed(() => Object.keys(activeFilters.value).lengt
// ref is always empty there and only the prop is real. // ref is always empty there and only the prop is real.
const isNarrowed = computed(() => activeFilterCount.value > 0 || !!(effSearchTerm.value || '').trim()) const isNarrowed = computed(() => activeFilterCount.value > 0 || !!(effSearchTerm.value || '').trim())
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function debounce(fn, ms = 500) { function debounce(fn, ms = 500) {
let timeout let timeout
@ -731,18 +728,9 @@ function filterUUPGs() {
value = newItem[String(matchKey)] value = newItem[String(matchKey)]
} }
if (value && typeof value === 'string') { if (value && typeof value === 'string') {
let currentIndex = 0 // The same construction regions-dashboard's UupgsList had, character for character apart from
let highlightedValue = '' // variable names, which is what earned it a place in library/ under RULE 2. One fact, one copy.
match.indices.forEach((idx, i) => { const highlightedValue = highlightRanges(value, match.indices)
const start = idx[0]
const end = idx[1]
const isLast = match.indices.length - 1 === i
highlightedValue
+= escapeHtml(value.slice(currentIndex, start))
+ `<span class="search-highlight">${escapeHtml(value.slice(start, end + 1))}</span>`
+ (isLast ? escapeHtml(value.slice(end + 1)) : '')
currentIndex = end + 1
})
if (String(matchKey).includes('imb_alternate_name')) { if (String(matchKey).includes('imb_alternate_name')) {
newItem.matches.push({ key: t.value.alternate_name ?? 'Also known as', label: highlightedValue }) newItem.matches.push({ key: t.value.alternate_name ?? 'Also known as', label: highlightedValue })
} else if (String(matchKey).includes('.')) { } else if (String(matchKey).includes('.')) {

View file

@ -0,0 +1,93 @@
#!/usr/bin/env node
/* check-text-highlight.mjs — `bun run check:text-highlight`. Source-only, headless, ~0.1s.
*
* library/composables/useTextHighlight.js was extracted because TWO plugins had built the same
* search-highlight construction independently, line for line apart from variable names:
* regions-dashboard/src/UupgsList.vue and people-groups-grid/people-groups-grid.vue. This asserts the
* shared version reproduces the construction it replaced, and pins the two escaping deltas that the
* extraction deliberately introduces — so neither is discovered later by a reader wondering why their
* output changed.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { highlightRanges } from '../../../library/composables/useTextHighlight.js'
import { codeOnly } from './strip-code.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..', '..', '..')
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
// ── the OLD inline construction, verbatim in shape from UupgsList before the extraction ──────────
const oldEscape = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
const oldHighlight = (original, indices) => {
let cursor = 0, out = ''
indices.forEach((idx, i) => {
const [start, end] = idx
const isLast = i === indices.length - 1
out += oldEscape(original.slice(cursor, start))
+ '<span class="search-highlight">' + oldEscape(original.slice(start, end + 1)) + '</span>'
+ (isLast ? oldEscape(original.slice(end + 1)) : '')
cursor = end + 1
})
return out
}
// ── 1. EQUIVALENCE on every range shape, for text with no special characters ─────────────────────
const CASES = [
['one range in the middle', 'Cameroon', [[2, 4]]],
['range at the very start', 'Cameroon', [[0, 2]]],
['range at the very end', 'Cameroon', [[5, 7]]],
['two ranges', 'Cameroon Republic', [[0, 2], [9, 12]]],
['three ranges, adjacent-ish', 'abcdefghij', [[0, 0], [2, 3], [8, 9]]],
['whole string', 'Chad', [[0, 3]]],
]
for (const [label, value, indices] of CASES) {
ok(oldHighlight(value, indices) === highlightRanges(value, indices),
`${label}: shared output is byte-identical to the construction it replaced`)
}
// ── 2. THE TWO DELTAS, asserted so they are documented behaviour rather than a surprise ──────────
ok(highlightRanges('a`b', [[0, 0]]).includes('&#96;') || highlightRanges('a`b', [[0, 0]]).includes('&#x60;'),
'DELTA 1: a backtick IS now escaped (library/utils/html.js escapes it; neither plugin copy did)')
ok(!oldHighlight('a`b', [[0, 0]]).includes('&#96;'),
'CONTROL: and the old copy did NOT escape it, so delta 1 is a real difference and not a no-op')
ok(highlightRanges(null, [[0, 1]]) === '' || typeof highlightRanges(null, [[0, 1]]) === 'string',
'DELTA 2: a null value returns a string rather than throwing (people-groups-grid\'s copy has no null guard)')
const pggEscape = (s) => s.replace(/&/g, '&amp;') // their shape: no guard at all
let threw = false
try { pggEscape(null) } catch (_) { threw = true }
ok(threw, 'CONTROL: people-groups-grid\'s escaper really does throw on null, so delta 2 removes a real crash')
// ── 3. escaping still happens on EVERY segment, which is the security-relevant part ──────────────
const hostile = '<img onerror=x>'
const outH = highlightRanges(hostile, [[0, 3]])
ok(!outH.includes('<img'), 'the hit itself is escaped, not injected as markup')
ok(!/<(?!\/?span)/.test(outH), 'the ONLY unescaped tags in the output are the span wrappers')
ok(highlightRanges('<b>x</b>', []).includes('&lt;b&gt;'),
'with NO ranges the whole value is still escaped (the early return does not skip escaping)')
// ── 4. NEG CONTROLS: the comparison must be capable of failing ───────────────────────────────────
const broken = (v) => String(v) // an "escaper" that escapes nothing
ok(broken('<b>') !== highlightRanges('<b>', []),
'NEG CONTROL: an implementation that escaped nothing would FAIL this gate')
ok(highlightRanges('Cameroon', [[2, 4]]) !== highlightRanges('Cameroon', [[0, 2]]),
'NEG CONTROL: the output actually depends on the indices')
// ── 5. the consumers, in source ──────────────────────────────────────────────────────────────────
const upg = codeOnly(readFileSync(resolve(ROOT, 'plugin-designs/regions-dashboard/src/UupgsList.vue'), 'utf8'))
ok(/highlightRanges/.test(upg), 'UupgsList routes through highlightRanges()')
ok(!/function escapeHtml\(/.test(upg), 'and no longer hand-rolls escapeHtml (its only callers were inside highlight())')
// people-groups-grid is the SECOND consumer that justified the extraction under RULE 2. studio agreed
// to treat it as unowned and asked for it to be cut, so it is migrated now and asserted here.
const pgg = codeOnly(readFileSync(resolve(ROOT, 'plugin-designs/people-groups-grid/people-groups-grid.vue'), 'utf8'))
ok(/highlightRanges/.test(pgg), 'people-groups-grid routes through highlightRanges() too -- the duplicate is gone')
ok(!/function escapeHtml\(/.test(pgg),
'and its own escapeHtml is gone: after the loop moved, it had ZERO callers left')
ok(!/currentIndex = end \+ 1/.test(pgg),
'the hand-rolled range walk is really removed, not merely shadowed by the import')
console.log(fails ? `\nTEXT-HIGHLIGHT: ${fails} FAIL(s)` : '\nTEXT-HIGHLIGHT: the shared highlighter matches what it replaced, and both escaping deltas are pinned')
process.exit(fails ? 1 : 0)

View file

@ -63,6 +63,7 @@ import { useRegionsI18n } from './useRegionsI18n.js'
// would not; the shortfall is always on the fuzzy side. // would not; the shortfall is always on the fuzzy side.
// Also unimplemented: useExtendedSearch's operator syntax (^prefix, !exclude, 'literal, |or). // Also unimplemented: useExtendedSearch's operator syntax (^prefix, !exclude, 'literal, |or).
import Fuse from '@map/components/search/fuzzy.js' import Fuse from '@map/components/search/fuzzy.js'
import { highlightRanges } from '@map/composables/useTextHighlight.js'
// DX-life's spacing/type scales under THEIR token names. Prepended to this // DX-life's spacing/type scales under THEIR token names. Prepended to this
// component's shadow CSS so every var() below resolves inside the shadow root. // component's shadow CSS so every var() below resolves inside the shadow root.
import { DXLIFE_TOKENS } from '../dxlife-tokens.js' import { DXLIFE_TOKENS } from '../dxlife-tokens.js'
@ -189,10 +190,6 @@ const activeChips = computed(() =>
function stripDiacritics(v) { function stripDiacritics(v) {
return String(v ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '') return String(v ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '')
} }
function escapeHtml(v) {
return String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
// Their key list, verbatim (UupgsList.vue:307-315). // Their key list, verbatim (UupgsList.vue:307-315).
const SEARCH_KEYS = ['name', 'imb_alternate_name', 'country_code.label', 'rop1.label', const SEARCH_KEYS = ['name', 'imb_alternate_name', 'country_code.label', 'rop1.label',
@ -228,19 +225,7 @@ const preFiltered = computed(() => rows.value.filter((p) => {
* (UupgsList.vue:336-348): escape the gap, wrap the hit, and append the tail only on the last * (UupgsList.vue:336-348): escape the gap, wrap the hit, and append the tail only on the last
* range. Indices come from the stripped corpus and are applied to the original string. * range. Indices come from the stripped corpus and are applied to the original string.
*/ */
function highlight(original, indices) { const highlight = (original, indices) => highlightRanges(original, indices)
let cursor = 0
let out = ''
indices.forEach((idx, i) => {
const [start, end] = idx
const isLast = i === indices.length - 1
out += escapeHtml(original.slice(cursor, start))
+ '<span class="search-highlight">' + escapeHtml(original.slice(start, end + 1)) + '</span>'
+ (isLast ? escapeHtml(original.slice(end + 1)) : '')
cursor = end + 1
})
return out
}
const filtered = computed(() => { const filtered = computed(() => {
const term = searchTerm.value.trim() const term = searchTerm.value.trim()

View file

@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* check-kit-options-reachable.mjs — an option a kit composable accepts must actually be USED.
*
* WRITTEN BECAUSE I NEARLY SHIPPED ONE. Kit landing 3 gave useSelectionCamera a `coords` option so
* a dashboard whose rows spell their coordinate differently could say so. It was accepted in the
* destructured signature and never passed on to selectionBounds — so a caller could set it,
* type-check clean, build clean, and watch it do nothing at all.
*
* THAT IS THE WORST SHAPE A MISSING FEATURE CAN TAKE, and this tree has paid for it before: a
* defineProps on a component the custom element never binds, documented at length in
* research-dashboard.vue. An option that cannot be observed to work is worse than no option,
* because it READS as supported. Nobody files a bug against a knob they assume they misused.
*
* It matters more for the kit than it did for one dashboard. The whole promise of SPEC-DASHBOARD-KIT
* is "a config produces a working dashboard", and every landing adds parameters whose only job is to
* be honoured. A parameter that is merely present turns that promise into a guess.
*
* WHAT IT CHECKS: for each shared composable below, every name destructured from the options object
* must appear somewhere in the function body other than its own declaration. Renamed and defaulted
* forms are handled; a name used only inside its own default expression does not count.
*
* NOT A TYPE CHECKER. It cannot tell a wrongly-used option from a rightly-used one — only a
* completely unused one, which is the failure that is invisible from outside.
*
* NEGATIVE-TESTED: deleting the `coords` argument from the selectionBounds call fails it naming the
* option and the composable; deleting the option itself passes (an option that does not exist makes
* no promise).
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'KIT-OPTIONS-REACHABLE'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
// The kit's shared composables, in extraction order. Each new landing adds its file here.
const FILES = [
'library/composables/useSelectionBus.js',
'library/composables/useDashboardLayout.js',
'library/composables/useSelectionCamera.js',
'library/composables/useCsvExport.js',
]
const fails = []
const notes = []
let checked = 0
for (const rel of FILES) {
const p = join(ROOT, rel)
if (!existsSync(p)) { notes.push(`${rel}: not present yet (its landing has not happened) — skipped`); continue }
const src = readFileSync(p, 'utf8')
// Every exported function whose single parameter is a destructured options object.
for (const m of src.matchAll(/export function (\w+)\(\s*\{([\s\S]*?)\}\s*\)\s*\{/g)) {
const [, fn, params] = m
// The body: from the end of the signature to the end of the file is enough — a name used
// anywhere after the signature is used. Narrower slicing risks a false accusation.
// ★ COMMENTS STRIPPED, AND THIS IS THE FOURTH TIME I HAVE WRITTEN THIS HOLE. The first version
// read the raw body, and the comment I had just written next to the fixed call site says
// "`coords` IS PASSED HERE" — so the mutant that removed the actual argument left the gate
// green. Every check I write that searches source for a token has to strip comments first,
// because the comment explaining the code is the most likely place for that token to also
// appear. A check its own documentation can satisfy is not a check.
const body = src.slice(m.index + m[0].length)
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
// ★ COMMENTS OUT OF THE SIGNATURE FIRST, THEN SPLIT. Splitting the raw text on commas cut
// through the PROSE of the comments documenting each option, and the fragments came back as
// option names — the gate solemnly reported that useCsvExport accepts `and` and never uses it.
// A parser that reads its own documentation as data produces findings nobody can act on, which
// is a different way of being unusable from the comment-satisfies-the-assertion hole two lines
// up. Both are the same root cause: comments are text, and every text search has to say so.
const paramSrc = params
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
// Split the destructuring at top-level commas only, so a default like `() => true` survives.
const parts = []
let depth = 0, cur = ''
for (const ch of paramSrc) {
if ('([{'.includes(ch)) depth++
else if (')]}'.includes(ch)) depth--
if (ch === ',' && depth === 0) { parts.push(cur); cur = '' } else cur += ch
}
parts.push(cur)
for (const raw of parts) {
// strip comments, then read the local binding name: `a`, `a = d`, `a: b`, `a: b = d`
const decl = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '').trim()
if (!decl) continue
const nameM = /^([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?/.exec(decl)
if (!nameM) continue
const local = nameM[2] || nameM[1]
checked++
if (new RegExp(`(?<![\\w$.])${local.replace('$', '\\$')}(?![\\w$])`).test(body)) continue
fails.push(`${rel}: ${fn}() accepts \`${local}\` and never uses it. A caller can set it, type-check clean, build clean, and watch it do nothing — which reads as supported. Either honour it or remove it.`)
}
}
}
console.log(` · ${checked} option(s) across ${FILES.filter((f) => existsSync(join(ROOT, f))).length} shared composable(s)`)
for (const n of notes) console.log(' · ' + n)
if (fails.length) {
console.error(`\n${TAG}: FAIL`)
for (const f of fails) console.error(' ✗ ' + f)
process.exit(1)
}
console.log(`${TAG}: PASS — every option the kit accepts is honoured somewhere.`)

View file

@ -14,7 +14,7 @@
import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js' import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
import { useCsvExport } from '../../composables/useCsvExport.js' import { useCsvExport } from '@map/composables/useCsvExport.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js' import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
import { tStatic } from '@map/i18n/index.js' import { tStatic } from '@map/i18n/index.js'

View file

@ -21,7 +21,7 @@ import { tStatic } from '@map/i18n/index.js'
// what shipped rather than to a key name. // what shipped rather than to a key name.
const T = (key, en) => tStatic(key) || en const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useCsvExport } from '../../composables/useCsvExport.js' import { useCsvExport } from '@map/composables/useCsvExport.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js' import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
import { lbl } from '../../composables/useDashboardAggregations.js' import { lbl } from '../../composables/useDashboardAggregations.js'

View file

@ -19,7 +19,7 @@ import { tStatic } from '@map/i18n/index.js'
const T = (key, en) => tStatic(key) || en const T = (key, en) => tStatic(key) || en
// Table selection -> map camera. The table never touches the map: it commits to the bus, // Table selection -> map camera. The table never touches the map: it commits to the bus,
// this composable watches bus.selectionVersion and flies ONCE per settled change. // this composable watches bus.selectionVersion and flies ONCE per settled change.
import { useSelectionCamera } from '../../composables/useSelectionCamera.js' import { useSelectionCamera } from '@map/composables/useSelectionCamera.js'
// ── Map controls — REUSED from the shared library, not hand-rolled. MapToolbar is a // ── Map controls — REUSED from the shared library, not hand-rolled. MapToolbar is a
// layout-only positioned column; each button binds :map explicitly so it can only ever // layout-only positioned column; each button binds :map explicitly so it can only ever
// drive THIS panel's instance (the multi-map contract in map-controls/README.md). // drive THIS panel's instance (the multi-map contract in map-controls/README.md).

View file

@ -1,244 +1,14 @@
/** /**
* useCsvExport.js — RDASH phase 4: export-any-selection to CSV (native Blob, 0-dep). * useCsvExport.js — RE-EXPORT SHIM (dashboard-kit landing 3).
* *
* Priority model (plan §INTERACTION): selectionSet (explicit picks) > filterSet result (in scope) > * The real thing now lives at library/composables/useCsvExport.js. Same one-cycle contract as
* full dataset. Serializes the FULL stored record per row (flattening {value,label} -> label), * landings 1 and 2: every importer keeps working unchanged while the move is verified on its own,
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection * and this file is deleted in the next landing after a measured check that nothing still imports it.
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
* export touches no rendered DOM/styles.
* *
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ── * EXPORT_FIELDS travels with it. Re-exporting only the composable would have been a silent break:
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was * the field list is imported separately by the gate that checks the CSV's shape.
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
* table / charts render. The detail endpoint exposes 67.
* *
* So the wide record is fetched LAZILY, here, on the first export click — not at mount: * It re-exports; it does not duplicate. A second copy would drift silently while both halves kept
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY * passing their gates.
* bundle that shares that file, to serve one button;
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
* for all rows (verified live: 2,107 rows x 67 keys).
* The result is cached for the session; if the fetch fails we still export the loaded record
* (24 fields) rather than producing nothing — degraded, never broken.
*/ */
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js' export { useCsvExport, EXPORT_FIELDS } from '@map/composables/useCsvExport.js'
import { inject } from 'vue'
import sourcesConfig from '@map/api/sources.json'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
/**
* The full field set for the export, in a stable, human-readable order: identity first, then
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
*/
export const EXPORT_FIELDS = [
// identity
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
'joshua_project_id', 'imb_affinity_code',
// place
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
'imb_subregion', 'location_description',
// people / language
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
'work_in_local_language', 'imb_is_indigenous',
// religion
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
// population / believers
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
'imb_evangelical_level', 'imb_congregation_existing',
// prayer + engagement
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
'disciple_and_church_multiplication',
// scripture + media resources
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
// priority indices
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
// media / description
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
]
/**
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
*/
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => { const a = ACC[k]; return a ? a(r) === filterSet[k] : true }) }) {
// slug -> wide API record, filled by the lazy all-fields fetch below.
let wideStore = null
let widePromise = null
// The map's people-group LIST (library/lists/). Every caller runs in setup, where inject works.
let profileConfig = dataOpts.profileConfig || null
if (!profileConfig) { try { profileConfig = inject('profileConfig', null) } catch (_) { /* outside setup */ } }
/**
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
*/
function fetchWideRows() {
if (widePromise) return widePromise
widePromise = (async () => {
const sourceId = dataOpts.source || sourcesConfig.activeSource
const src = sourcesConfig?.sources?.[sourceId] || {}
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const lang = src.queryParams?.lang || 'en'
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
const json = await res.json()
// Only the groups of this page's people-group LIST (library/lists/) are exported.
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) { const k = r && (r.slug || r.id); if (k) map.set(k, r) }
wideStore = map
return map
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
return widePromise
}
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
function widen(r) {
if (!wideStore) return r
const w = wideStore.get(r && (r.slug || r.id))
return w ? { ...r, ...w } : r
}
function pickRows() {
if (selectionSet && selectionSet.size) {
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
}
const keys = Object.keys(filterSet)
if (keys.length) {
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
// the reader is looking at, and a private copy of the rule is how an export starts
// disagreeing with the screen it was taken from.
return (rows.value || []).filter((r) => rowMatches(r, FACET_ACC))
}
return rows.value || []
}
function scopeLabel() {
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
return Object.keys(filterSet).length ? 'filter result' : 'all'
}
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
function cell(v) {
if (v === null || v === undefined) return v
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
// URL rather than dumping raw JSON braces into the cell.
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
return v
}
function flatten(r) {
const o = {}
for (const k of Object.keys(r)) o[k] = cell(r[k])
return o
}
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
function esc(v) {
let s = v === null || v === undefined ? '' : String(v)
if (/^[=+\-@]/.test(s)) s = "'" + s
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
return s
}
function buildCsv() {
const picked = pickRows().map(widen).map(flatten)
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
// people → engagement → resources), then any key the API returned that the list doesn't name.
// Union across rows, because a null-valued field can be absent on an individual record.
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
const cols = [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
// columns below are built from the API record via flatten()/cell() and are untouched, because
// a consumer's formulas and downstream joins key on whatever those columns already emit.
const chips = Object.entries(filterSet)
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : v}`)
.join(' | ') || 'none'
const provenance = `# Research Dashboard export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
// ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV".
// the agent-reported 2026-09-22: "this is me exporting the CSV directly without making any edits...
// it does look a little bit glitched out."
//
// MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT
// and must not be "fixed" on suspicion:
// line 0 ... 1 column "# Research Dashboard export — scope: ..."
// line 1 ... N columns slug,name,religion,...
// rows ... N columns each, ZERO with a wrong column count
// embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so
// esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The
// file is comma-separated, not tab-separated — the tabs in the agent's paste were his terminal
// rendering, not the bytes.
//
// The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header.
// So the scope sentence became the column names, the real header became data row 1, and a 1-column
// line above a 68-column table is exactly what "glitched" looks like on screen.
//
// Moving it BELOW the data keeps the provenance — which is deliberate and load-bearing, since it
// records WHICH FILTERS produced the file, the difference between a dataset and an anecdote —
// while making line 1 the header. A trailing 1-column line lands as a final row with one populated
// cell: obvious to a human, harmless to a formula, and it cannot destroy the column mapping.
const lines = [cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(',')), provenance]
return lines.join('\r\n')
}
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
function exportCsv() {
const csv = buildCsv()
const rows = pickRows().length
// The header is now line 1 — the '#' provenance comment moved to the END of the file so that a
// spreadsheet reads the real column row first (see buildCsv). This index had to move WITH it:
// reading line 2 would have counted the columns of DATA ROW 1, which is usually the same number,
// so it would have looked correct and been wrong — the quiet kind of breakage a positional change
// leaves behind.
const fields = (csv.split('\r\n')[0] || '').split(',').length
try { if (typeof window !== 'undefined') window.__rdashLastCsv = csv } catch (_) { /* verify hook */ }
let clipboard = false, downloaded = false
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
// 2) native file download — works when NOT in a download-blocked sandbox
try {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = 'research-dashboard.csv'; a.style.display = 'none'
document.body.appendChild(a); a.click(); downloaded = true
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) { /* download blocked — clipboard above already has it */ }
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
try { window.__rdashExportStatus = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
}
/**
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
*
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
* first click on a cold cache resolves the wide rows and then writes: the download path always
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
* settles in a microtask, activation survives, and clipboard works as before.
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
*/
async function exportCsvAsync() {
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
return exportCsv()
}
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
}

View file

@ -27,7 +27,7 @@ import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/
import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from '@map/composables/useSelectionBus.js' import { createSelectionBus } from '@map/composables/useSelectionBus.js'
import { useDashboardData } from './useDashboardData.js' import { useDashboardData } from './useDashboardData.js'
import { useCsvExport } from './useCsvExport.js' import { useCsvExport } from '@map/composables/useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js' import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js' import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json' import sourcesConfig from '@map/api/sources.json'

View file

@ -1,235 +1,11 @@
/** /**
* useSelectionCamera.js — "select rows in the table, the map zooms to that selection." * useSelectionCamera.js — RE-EXPORT SHIM (dashboard-kit landing 3).
* *
* Subscribes to the selection bus and drives the map camera ONCE per committed selection * The real thing now lives at library/composables/useSelectionCamera.js. Same one-cycle contract as landings 1
* change. The table never talks to the map: it mutates `bus.selectionSet` and bumps * and 2: every importer keeps working unchanged while the move is verified on its own, and this
* `bus.selectionVersion`; this composable watches that version. That is exactly the * file is deleted in the next landing after a measured check that nothing still imports it.
* decoupling the bus exists for — the table panel owns no camera code, and this file owns
* no table code.
* *
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the agent is photosensitive) ────────────── * It re-exports; it does not duplicate. A second copy would drift silently while both halves kept
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler * passing their gates.
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
*
* 1. ONE animation per committed selection change — never per frame, never per row.
* `selectionVersion` is bumped once per BATCH (useSelectionBus's performance contract),
* so selecting 2,107 rows emits ONE version bump, not 2,107.
* 2. DEBOUNCED (CAMERA_DEBOUNCE_MS). Rapid shift+click range-dragging bumps the version
* many times in a few hundred ms; only the final state moves the camera. Each new bump
* CANCELS the pending move, so intermediate states are never animated.
* 3. REDUCED MOTION — when the user asks for less motion we JUMP (duration 0) instead of
* easing. Same destination, zero traversal, no optical flow across the viewport.
* 4. NOTHING here runs in a render path. The bbox is computed inside the debounced
* callback, i.e. at most once per settled selection, never inside a computed/watcher
* that the map redraw depends on.
*
* ── COST ──────────────────────────────────────────────────────────────────────────────
* One O(n) pass over the selected rows per settled change (n = selection size), using the
* slug-keyed rowStore for O(1) lookups rather than re-scanning all 2,107 rows per slug.
* Selecting everything is a single ~2,107-element numeric pass — sub-millisecond — and it
* happens once, after the debounce, off the render path.
*/ */
import { watch } from 'vue' export { useSelectionCamera, selectionBounds, CAMERA_POLICY } from '@map/composables/useSelectionCamera.js'
// ── Camera policy constants ───────────────────────────────────────────────────────────
// Debounce window. Long enough to swallow a shift+click range drag or a "select all"
// cascade, short enough that a single checkbox click still feels immediate.
const CAMERA_DEBOUNCE_MS = 150
// One eased move. Matches the panel's existing fitBounds duration so the selection camera
// and the filter camera feel like the same gesture.
const FLY_DURATION_MS = 1200
// Viewport padding so selected pins never sit flush against the panel edge.
const FIT_PADDING_PX = 70
// Ceiling for a MULTI-row fit. Without it, two rows in the same city fit at street level.
const FIT_MAX_ZOOM = 5.5
// A SINGLE selected row has a degenerate (zero-area) bbox — fitBounds would resolve it at
// maxZoom, slamming the camera to street level for a people-group whose coordinate is a
// country-scale centroid. Fly to the point at a deliberately regional zoom instead.
const SINGLE_ROW_ZOOM = 6
// Degenerate-bbox guard for the multi-row path: several rows can share one coordinate (or
// sit within metres of each other), which is a zero-area bbox by another route. Below this
// span we treat the cluster as a single point and use SINGLE_ROW_ZOOM.
const DEGENERATE_SPAN_DEG = 0.01
// Past this longitude span a selection effectively wraps the globe; framing it literally
// makes the camera animate the wrong way around the antimeridian. Ease to a world overview
// instead. Tuned to match the LEGEND_WIDE_SPAN_DEG threshold in @map/composables/useMapFly.js.
const WIDE_SPAN_DEG = 110
const WORLD_ZOOM = 1.45 // the panel's own initial zoom — a stable, familiar overview
/** True when the user has asked the OS for reduced motion. Read live (never cached): the
* preference can be toggled mid-session, and a stale `false` would animate at them. */
function prefersReducedMotion() {
try {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
} catch (_) { return false }
}
/**
* Numeric coordinate pair for a row, or null when unusable.
*
* The live API returns coordinates as STRINGS ("5.68736400", verified against 2,107 live
* records on pray.doxa.life), so every read must go through Number() — a raw string compare
* would order "9.5" above "78.2" lexically and silently produce a wrong bbox.
*
* Number.isFinite (not truthiness): `Number(r.latitude) && …` rejects a legitimate 0°
* coordinate. We accept 0 per-axis and reject only the exact 0,0 pair — the null-island
* sentinel that a missing geocode degrades into. Flying there would show the camera an
* empty patch of the Gulf of Guinea.
*/
function coordsOf(row) {
if (!row) return null
const lng = Number(row.longitude)
const lat = Number(row.latitude)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
if (lng === 0 && lat === 0) return null // null island — a missing geocode, not a place
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null // out-of-range guard
return [lng, lat]
}
/**
* Antimeridian-aware longitude extent.
*
* A Pacific selection holds longitudes near -176 and +178 (both present in the live data:
* the full range is -176.18 .. +178.48). Naive min/max calls that a ~355° span and frames
* the whole globe — or worse, animates the long way round. Re-projecting into [0,360) makes
* the same set contiguous with a ~6° span. We compute both and keep the tighter one.
*
* Returns { minLng, maxLng, span }; maxLng may exceed 180, which Mapbox's fitBounds accepts
* and wraps correctly.
*/
function lngExtent(longitudes) {
let aMin = Infinity, aMax = -Infinity // raw [-180, 180]
let bMin = Infinity, bMax = -Infinity // shifted [0, 360)
for (const lng of longitudes) {
if (lng < aMin) aMin = lng
if (lng > aMax) aMax = lng
const shifted = lng < 0 ? lng + 360 : lng
if (shifted < bMin) bMin = shifted
if (shifted > bMax) bMax = shifted
}
if (!Number.isFinite(aMin)) return null
const spanA = aMax - aMin
const spanB = bMax - bMin
if (spanB < spanA) {
const minRaw = bMin > 180 ? bMin - 360 : bMin
return { minLng: minRaw, maxLng: minRaw + spanB, span: spanB }
}
return { minLng: aMin, maxLng: aMax, span: spanA }
}
/**
* Bounding box of the selected rows.
*
* Resolves each slug through the slug-keyed rowStore (O(1)) instead of filtering the full
* row array per slug, so a 2,107-row selection stays one linear pass.
*
* @returns {null | { kind:'point', center:[lng,lat] }
* | { kind:'bounds', bounds:[[w,s],[e,n]], span:number }}
* null when NOTHING is selected or no selected row has usable coordinates —
* the caller must then leave the camera alone.
*/
export function selectionBounds(selectedSlugs, rowStore) {
if (!selectedSlugs || !selectedSlugs.size || !rowStore) return null
const lngs = []
let minLat = Infinity, maxLat = -Infinity
let first = null
let count = 0
for (const slug of selectedSlugs) {
const c = coordsOf(rowStore.get(slug))
if (!c) continue // missing/null/invalid coords — skip the row
if (count === 0) first = c
lngs.push(c[0])
if (c[1] < minLat) minLat = c[1]
if (c[1] > maxLat) maxLat = c[1]
count++
}
// Every selected row lacked coordinates (or the selection is empty) — no camera move.
if (count === 0) return null
if (count === 1) return { kind: 'point', center: first }
const ext = lngExtent(lngs)
if (!ext) return { kind: 'point', center: first }
// Co-located cluster: a zero-area bbox by another route. fitBounds would hit maxZoom.
if (ext.span < DEGENERATE_SPAN_DEG && (maxLat - minLat) < DEGENERATE_SPAN_DEG) {
return { kind: 'point', center: [(ext.minLng + ext.maxLng) / 2, (minLat + maxLat) / 2] }
}
return {
kind: 'bounds',
bounds: [[ext.minLng, minLat], [ext.maxLng, maxLat]],
span: ext.span,
}
}
/**
* Wire selection -> camera. Call once from the map panel's setup.
*
* @param {Object} o
* @param {Object} o.bus - the selection bus (selectionSet + selectionVersion)
* @param {Function} o.getMap - () => the live map instance (null before ready)
* @param {Function} o.getRowStore - () => Map<slug, row>
* @param {Object} o.mapFly - a useMapFly instance (shared library helper)
* @param {Function} [o.isEnabled] - () => boolean; skip while false (e.g. map not ready)
* @returns {Function} teardown — clears the pending timer and stops the watcher
*/
export function useSelectionCamera({ bus, getMap, getRowStore, mapFly, isEnabled = () => true }) {
if (!bus || !bus.selectionVersion) return () => {}
let timer = null
function moveCamera() {
timer = null
const map = getMap && getMap()
if (!map || !isEnabled()) return
const target = selectionBounds(bus.selectionSet, getRowStore && getRowStore())
// EDGE CASE — selection cleared, or no selected row has coordinates: hold position.
// Deliberately NOT a reset-to-world: an unrequested snap-back is both jarring and, for
// a photosensitive user, motion they did not ask for. Deselecting is not a navigation.
if (!target) return
// Reduced motion: same destination, zero traversal.
const duration = prefersReducedMotion() ? 0 : FLY_DURATION_MS
try {
if (target.kind === 'point') {
// Single row (or a co-located cluster). fitBounds on a degenerate bbox resolves at
// maxZoom; a fixed regional zoom is the honest framing for a country-scale centroid.
mapFly.flyTo(target.center, { zoom: SINGLE_ROW_ZOOM, duration, essential: true })
return
}
if (target.span > WIDE_SPAN_DEG) {
// Globe-spanning selection (e.g. "select all"). Framing it literally makes the
// camera swing the wrong way around the antimeridian — the visible "snap".
mapFly.flyTo(
[(target.bounds[0][0] + target.bounds[1][0]) / 2,
(target.bounds[0][1] + target.bounds[1][1]) / 2],
{ zoom: WORLD_ZOOM, duration, essential: true },
)
return
}
mapFly.fitBounds(target.bounds, FIT_PADDING_PX, {
maxZoom: FIT_MAX_ZOOM, duration, essential: true,
})
} catch (_) { /* fitBounds throws on degenerate bounds — never break the dashboard */ }
}
// ONE watcher on ONE reactive handle. `selectionVersion` is bumped once per committed
// batch (see useSelectionBus's performance contract), so a 2,107-row "select all" runs
// this callback ONCE — and the debounce collapses even that into a single camera move.
const stop = watch(() => bus.selectionVersion.value, () => {
if (timer) clearTimeout(timer) // a newer selection supersedes the pending move
timer = setTimeout(moveCamera, CAMERA_DEBOUNCE_MS)
})
return function teardown() {
if (timer) { clearTimeout(timer); timer = null }
try { stop() } catch (_) {}
}
}

View file

@ -89,7 +89,10 @@ const { createSelectionBus } = await import(BUS)
{ {
const PANELS = [ const PANELS = [
'plugin-designs/research-dashboard/src/composables/useDashboardShell.js', 'plugin-designs/research-dashboard/src/composables/useDashboardShell.js',
'plugin-designs/research-dashboard/src/composables/useCsvExport.js', // Follows the file (kit landing 3). Left at the plugin path this would have read the
// re-export shim, found no predicate to object to, and PASSED VACUOUSLY — a gate going quiet
// about the thing it guards, which is worse than one that fails.
'library/composables/useCsvExport.js',
'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue', 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue',
'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue', 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue', 'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue',

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. --> <!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD" crossorigin="anonymous"></script> <script src="../all-people-groups.js" integrity="sha384-wUgdpbqOtm89VOftSc0LWMjeu25MGgwf24IVMQcJZA/dVukVC5pffNDdVdChfI5g" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -59,7 +59,7 @@
"meta": { "meta": {
"title": "The Deaf Gap", "title": "The Deaf Gap",
"grain": "one row per Deaf people group", "grain": "one row per Deaf people group",
"generated": "2026-09-25", "generated": "2026-09-27",
"measured": "150 sign languages · 234 people groups · 84,159,050 people. 136 languages / 183 groups / 55,199,840 people with NOTHING found.", "measured": "150 sign languages · 234 people groups · 84,159,050 people. 136 languages / 183 groups / 55,199,840 people with NOTHING found.",
"bridge_rule": "A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.", "bridge_rule": "A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.",
"compounding_problem": "40 of these languages carry PRIVATE-USE (qaa-qtz) codes outside ISO 639-3. No resource pool indexes a private-use code, so those groups cannot be matched to a resource even if one exists.", "compounding_problem": "40 of these languages carry PRIVATE-USE (qaa-qtz) codes outside ISO 639-3. No resource pool indexes a private-use code, so those groups cannot be matched to a resource even if one exists.",
@ -2621,7 +2621,7 @@
"iso639_3": "jos", "iso639_3": "jos",
"countries": "SYR", "countries": "SYR",
"population": 221000, "population": 221000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138", "signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true, "has_signed_film": true,
"has_any_resource": true, "has_any_resource": true,
"code_unmatchable": false, "code_unmatchable": false,
@ -2660,7 +2660,7 @@
"iso639_3": "jos", "iso639_3": "jos",
"countries": "JOR", "countries": "JOR",
"population": 116000, "population": 116000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138", "signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true, "has_signed_film": true,
"has_any_resource": true, "has_any_resource": true,
"code_unmatchable": false, "code_unmatchable": false,
@ -2738,7 +2738,7 @@
"iso639_3": "jos", "iso639_3": "jos",
"countries": "LBN", "countries": "LBN",
"population": 51000, "population": 51000,
"signed_film": "https://www.jesusfilm.org/watch/?languageId=7138", "signed_film": "https://arc.gt/g8wmw",
"has_signed_film": true, "has_signed_film": true,
"has_any_resource": true, "has_any_resource": true,
"code_unmatchable": false, "code_unmatchable": false,

View file

@ -3,7 +3,7 @@
"title": "Doxa people groups — what the gospel is available in, and what is missing", "title": "Doxa people groups — what the gospel is available in, and what is missing",
"grain": "one row per people group on Doxa's roster", "grain": "one row per people group on Doxa's roster",
"source": "pray.doxa.life roster joined to the resource index by ISO 639-3", "source": "pray.doxa.life roster joined to the resource index by ISO 639-3",
"generated": "2026-09-26", "generated": "2026-09-27",
"groups": 2107, "groups": 2107,
"by_access": { "by_access": {
"nothing found": 369, "nothing found": 369,
@ -19768,7 +19768,7 @@
"translated_by": "", "translated_by": "",
"translated_year": "", "translated_year": "",
"scripture_url": "", "scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138", "film_url": "https://arc.gt/g8wmw",
"audio_url": "", "audio_url": "",
"bible_video_url": "", "bible_video_url": "",
"stories_url": "", "stories_url": "",
@ -20048,7 +20048,7 @@
"translated_by": "", "translated_by": "",
"translated_year": "", "translated_year": "",
"scripture_url": "", "scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138", "film_url": "https://arc.gt/g8wmw",
"audio_url": "", "audio_url": "",
"bible_video_url": "", "bible_video_url": "",
"stories_url": "", "stories_url": "",
@ -21928,7 +21928,7 @@
"translated_by": "", "translated_by": "",
"translated_year": "", "translated_year": "",
"scripture_url": "", "scripture_url": "",
"film_url": "https://www.jesusfilm.org/watch/?languageId=7138", "film_url": "https://arc.gt/g8wmw",
"audio_url": "", "audio_url": "",
"bible_video_url": "", "bible_video_url": "",
"stories_url": "", "stories_url": "",

View file

@ -9,7 +9,7 @@
"bridge_strength_note": "MEASURED: 24% of aligned rows rest on a bridge shared by only 2-3 groups. bridge_strength grades this (strong >=8 groups, moderate >=4, weak <4). A WEAK bridge is a hypothesis, not a route — e.g. \"Gawri\" was offered as the bridge for 10.3M Pakistanis where the real lingua franca is Urdu, which no unengaged group here claims as primary and which this method therefore cannot see. Filter on bridge_strength before acting.", "bridge_strength_note": "MEASURED: 24% of aligned rows rest on a bridge shared by only 2-3 groups. bridge_strength grades this (strong >=8 groups, moderate >=4, weak <4). A WEAK bridge is a hypothesis, not a route — e.g. \"Gawri\" was offered as the bridge for 10.3M Pakistanis where the real lingua franca is Urdu, which no unengaged group here claims as primary and which this method therefore cannot see. Filter on bridge_strength before acting.",
"bridge_caveat": "A bridge candidate is measured from THIS corpus (a language primary to >1 DX group in the country). It is NOT proof the group speaks it, and it is NOT a national trade language — a lingua franca no unengaged group claims as primary is invisible here.", "bridge_caveat": "A bridge candidate is measured from THIS corpus (a language primary to >1 DX group in the country). It is NOT proof the group speaks it, and it is NOT a national trade language — a lingua franca no unengaged group claims as primary is invisible here.",
"pools_caveat": "Resource status is from eBible + JESUS Film. GRN and FCBH audio are key-blocked and unchecked — absence is never proof.", "pools_caveat": "Resource status is from eBible + JESUS Film. GRN and FCBH audio are key-blocked and unchecked — absence is never proof.",
"resource_flags_refreshed": "2026-09-25", "resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json — the same index the map draws. Curated bridge, internet and channel columns are untouched." "resource_flags_source": "data/tables/people-group-pins.json — the same index the map draws. Curated bridge, internet and channel columns are untouched."
}, },
"columns": [ "columns": [

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,12 @@
"meta": { "meta": {
"title": "Gospel access by province — India and China", "title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.", "why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26", "generated": "2026-09-27",
"provinces_with_groups": 46, "provinces_with_groups": 46,
"provinces_available": 48, "provinces_available": 48,
"groups_assigned": 1005, "groups_assigned": 1005,
"groups_unassigned": 4, "groups_unassigned": 4,
"per_group_file": "gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time",
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.", "india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.", "unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.", "china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",

View file

@ -1,7 +1,7 @@
{ {
"meta": { "meta": {
"title": "Gospel resource link verification", "title": "Gospel resource link verification",
"generated": "2026-09-25T09:56:59.860Z", "generated": "2026-09-27T01:21:18.374Z",
"method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.", "method": "HEAD, redirects followed. verified = HTTP 200. broken = 404/410. unknown = unreachable, an ambiguous status, or a URL shape that cannot be checked by fetch.",
"scope": "Every URL in data/tables/people-group-pins.json, deduplicated.", "scope": "Every URL in data/tables/people-group-pins.json, deduplicated.",
"states": { "states": {
@ -96,12 +96,12 @@
] ]
}, },
"summary": { "summary": {
"records": 8524, "records": 8525,
"checkedThisPass": 8, "checkedThisPass": 1,
"carriedForward": 74, "carriedForward": 74,
"pruned": 0, "pruned": 0,
"broken": 74, "broken": 74,
"verified": 8445, "verified": 8446,
"unknown": 5 "unknown": 5
}, },
"byHost": { "byHost": {
@ -123,7 +123,7 @@
"broken": 1 "broken": 1
}, },
"arc.gt": { "arc.gt": {
"verified": 33, "verified": 34,
"broken": 1 "broken": 1
}, },
"zume.training": { "zume.training": {
@ -42766,6 +42766,11 @@
"state": "verified", "state": "verified",
"detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)", "detail": "5fish redirected to its language page https://fivefish.org/15537?language=Pashai%2C%20Southeast (the site answers 200 for any path)",
"at": "2026-09-25T09:56:59.503Z" "at": "2026-09-25T09:56:59.503Z"
},
"https://arc.gt/g8wmw": {
"state": "verified",
"detail": "HTTP 200 (via https://www.jesusfilm.org/watch/jesus.html/jordanian-sign-language.html)",
"at": "2026-09-27T01:21:17.988Z"
} }
} }
} }

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@
"honest_limit": "This is NOT the same as a national trade language. It finds languages widely used AMONG THE UNENGAGED GROUPS TRACKED HERE, and will MISS a lingua franca that no unengaged group claims as primary (e.g. Hindi in India). Named bridge_candidates, not trade_languages, for that reason.", "honest_limit": "This is NOT the same as a national trade language. It finds languages widely used AMONG THE UNENGAGED GROUPS TRACKED HERE, and will MISS a lingua franca that no unengaged group claims as primary (e.g. Hindi in India). Named bridge_candidates, not trade_languages, for that reason.",
"why_not_a_list": "No open machine-readable list of national trade languages was available; bridge candidates are derived from this corpus." "why_not_a_list": "No open machine-readable list of national trade languages was available; bridge candidates are derived from this corpus."
}, },
"resource_flags_refreshed": "2026-09-25", "resource_flags_refreshed": "2026-09-27",
"resource_flags_source": "data/tables/people-group-pins.json. prevalence_rank, bridge_candidate, groups_speaking and full_bible are curated and untouched." "resource_flags_source": "data/tables/people-group-pins.json. prevalence_rank, bridge_candidate, groups_speaking and full_bible are curated and untouched."
}, },
"columns": [ "columns": [

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. --> <!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script> <script src="../../big-picture.js" integrity="sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. --> <!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script> <script src="../../big-picture.js" integrity="sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. --> <!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script> <script src="../big-picture.js" integrity="sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. --> <!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../../dashboard-plus-grid.js" integrity="sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4" crossorigin="anonymous"></script> <script src="../../dashboard-plus-grid.js" integrity="sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. --> <!-- Bundle IIFE — registers the <dashboard-plus-grid> custom element. -->
<script src="../dashboard-plus-grid.js" integrity="sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4" crossorigin="anonymous"></script> <script src="../dashboard-plus-grid.js" integrity="sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -12,7 +12,7 @@
"bundle": "all-people-groups", "bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js", "scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js", "output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD", "integrity": "sha384-wUgdpbqOtm89VOftSc0LWMjeu25MGgwf24IVMQcJZA/dVukVC5pffNDdVdChfI5g",
"elements": [ "elements": [
"all-people-groups" "all-people-groups"
], ],
@ -28,7 +28,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js", "scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-wUgdpbqOtm89VOftSc0LWMjeu25MGgwf24IVMQcJZA/dVukVC5pffNDdVdChfI5g\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
}, },
"bundle": "all-people-groups" "bundle": "all-people-groups"
} }
@ -38,7 +38,7 @@
"bundle": "big-picture", "bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js", "scriptUrl": "./big-picture/big-picture.js",
"output": "dx-maps/big-picture/big-picture.js", "output": "dx-maps/big-picture/big-picture.js",
"integrity": "sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm", "integrity": "sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf",
"elements": [ "elements": [
"bp-big-picture", "bp-big-picture",
"bp-research-header", "bp-research-header",
@ -60,7 +60,7 @@
"scriptUrl": "./big-picture/big-picture.js", "scriptUrl": "./big-picture/big-picture.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
}, },
"instances": [ "instances": [
{ {
@ -214,7 +214,7 @@
"bundle": "dashboard-plus-grid", "bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js", "scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js", "output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4", "integrity": "sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC",
"elements": [ "elements": [
"dashboard-plus-grid" "dashboard-plus-grid"
], ],
@ -234,7 +234,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js", "scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
}, },
"instances": [ "instances": [
{ {
@ -663,7 +663,7 @@
"bundle": "my-upg-100-list", "bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js", "output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V", "integrity": "sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj",
"elements": [ "elements": [
"my-upg-100-list-map" "my-upg-100-list-map"
], ],
@ -683,7 +683,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
} }
}, },
{ {
@ -697,7 +697,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
} }
}, },
{ {
@ -711,7 +711,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
} }
} }
] ]
@ -720,7 +720,7 @@
"bundle": "people-groups-grid", "bundle": "people-groups-grid",
"scriptUrl": "./people-groups-grid/people-groups-grid.js", "scriptUrl": "./people-groups-grid/people-groups-grid.js",
"output": "dx-maps/people-groups-grid/people-groups-grid.js", "output": "dx-maps/people-groups-grid/people-groups-grid.js",
"integrity": "sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25", "integrity": "sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC",
"elements": [ "elements": [
"people-groups-grid" "people-groups-grid"
], ],
@ -736,7 +736,7 @@
"scriptUrl": "./people-groups-grid/people-groups-grid.js", "scriptUrl": "./people-groups-grid/people-groups-grid.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
}, },
"instances": [ "instances": [
{ {
@ -887,7 +887,7 @@
"bundle": "regions-dashboard", "bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js", "output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ", "integrity": "sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2",
"elements": [ "elements": [
"regions-dashboard" "regions-dashboard"
], ],
@ -907,7 +907,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
} }
}, },
{ {
@ -921,7 +921,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
} }
}, },
{ {
@ -935,7 +935,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
} }
} }
] ]
@ -944,7 +944,7 @@
"bundle": "research-dashboard", "bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js", "scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js", "output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-9uLizaH0Q8GRKMZllin/7KWx1zwWp5foHO8uBaxv6gR4DSaw5KYNtJknesc2+g/v", "integrity": "sha384-dCJvlRSHcExS1S4hjM2xu09V0J6PF3/+PbpgHEPmJI+8zjohwSoPyaBf2539z43l",
"elements": [ "elements": [
"research-dashboard-map" "research-dashboard-map"
], ],
@ -960,7 +960,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js", "scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-9uLizaH0Q8GRKMZllin/7KWx1zwWp5foHO8uBaxv6gR4DSaw5KYNtJknesc2+g/v\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-dCJvlRSHcExS1S4hjM2xu09V0J6PF3/+PbpgHEPmJI+8zjohwSoPyaBf2539z43l\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
}, },
"bundle": "research-dashboard" "bundle": "research-dashboard"
} }

View file

@ -12,7 +12,7 @@
"bundle": "all-people-groups", "bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js", "scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js", "output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD", "integrity": "sha384-wUgdpbqOtm89VOftSc0LWMjeu25MGgwf24IVMQcJZA/dVukVC5pffNDdVdChfI5g",
"elements": [ "elements": [
"all-people-groups" "all-people-groups"
], ],
@ -28,7 +28,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js", "scriptUrl": "./all-people-groups/all-people-groups.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./all-people-groups/all-people-groups/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./all-people-groups/all-people-groups.js\" integrity=\"sha384-wUgdpbqOtm89VOftSc0LWMjeu25MGgwf24IVMQcJZA/dVukVC5pffNDdVdChfI5g\" crossorigin=\"anonymous\"></script>\n<all-people-groups profile-config='{\"profile\":\"all-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></all-people-groups>"
}, },
"bundle": "all-people-groups" "bundle": "all-people-groups"
} }
@ -38,7 +38,7 @@
"bundle": "big-picture", "bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js", "scriptUrl": "./big-picture/big-picture.js",
"output": "dx-maps/big-picture/big-picture.js", "output": "dx-maps/big-picture/big-picture.js",
"integrity": "sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm", "integrity": "sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf",
"elements": [ "elements": [
"bp-big-picture", "bp-big-picture",
"bp-research-header", "bp-research-header",
@ -60,7 +60,7 @@
"scriptUrl": "./big-picture/big-picture.js", "scriptUrl": "./big-picture/big-picture.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./big-picture/big-picture/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
}, },
"instances": [ "instances": [
{ {
@ -214,7 +214,7 @@
"bundle": "dashboard-plus-grid", "bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js", "scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js", "output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4", "integrity": "sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC",
"elements": [ "elements": [
"dashboard-plus-grid" "dashboard-plus-grid"
], ],
@ -234,7 +234,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js", "scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./dashboard-plus-grid/dashboard-plus-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./dashboard-plus-grid/dashboard-plus-grid.js\" integrity=\"sha384-loz2bnoioFdiQTWwjGA2O85kOboqZorN717h+a0zneEDSrv3uAD972Ivk6htt1gC\" crossorigin=\"anonymous\"></script>\n<dashboard-plus-grid profile-config='{\"profile\":\"dashboard-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></dashboard-plus-grid>"
}, },
"instances": [ "instances": [
{ {
@ -663,7 +663,7 @@
"bundle": "my-upg-100-list", "bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js", "output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V", "integrity": "sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj",
"elements": [ "elements": [
"my-upg-100-list-map" "my-upg-100-list-map"
], ],
@ -683,7 +683,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
} }
}, },
{ {
@ -697,7 +697,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
} }
}, },
{ {
@ -711,7 +711,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js", "scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj\" crossorigin=\"anonymous\"></script>\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
} }
} }
] ]
@ -720,7 +720,7 @@
"bundle": "people-groups-grid", "bundle": "people-groups-grid",
"scriptUrl": "./people-groups-grid/people-groups-grid.js", "scriptUrl": "./people-groups-grid/people-groups-grid.js",
"output": "dx-maps/people-groups-grid/people-groups-grid.js", "output": "dx-maps/people-groups-grid/people-groups-grid.js",
"integrity": "sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25", "integrity": "sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC",
"elements": [ "elements": [
"people-groups-grid" "people-groups-grid"
], ],
@ -736,7 +736,7 @@
"scriptUrl": "./people-groups-grid/people-groups-grid.js", "scriptUrl": "./people-groups-grid/people-groups-grid.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./people-groups-grid/people-groups-grid/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
}, },
"instances": [ "instances": [
{ {
@ -887,7 +887,7 @@
"bundle": "regions-dashboard", "bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js", "output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ", "integrity": "sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2",
"elements": [ "elements": [
"regions-dashboard" "regions-dashboard"
], ],
@ -907,7 +907,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
} }
}, },
{ {
@ -921,7 +921,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>" "script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
} }
}, },
{ {
@ -935,7 +935,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js", "scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
} }
} }
] ]
@ -944,7 +944,7 @@
"bundle": "research-dashboard", "bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js", "scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js", "output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-9uLizaH0Q8GRKMZllin/7KWx1zwWp5foHO8uBaxv6gR4DSaw5KYNtJknesc2+g/v", "integrity": "sha384-dCJvlRSHcExS1S4hjM2xu09V0J6PF3/+PbpgHEPmJI+8zjohwSoPyaBf2539z43l",
"elements": [ "elements": [
"research-dashboard-map" "research-dashboard-map"
], ],
@ -960,7 +960,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js", "scriptUrl": "./research-dashboard/research-dashboard.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./research-dashboard/research-dashboard/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-9uLizaH0Q8GRKMZllin/7KWx1zwWp5foHO8uBaxv6gR4DSaw5KYNtJknesc2+g/v\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>" "script": "<!-- 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=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-dashboard/research-dashboard.js\" integrity=\"sha384-dCJvlRSHcExS1S4hjM2xu09V0J6PF3/+PbpgHEPmJI+8zjohwSoPyaBf2539z43l\" crossorigin=\"anonymous\"></script>\n<research-dashboard-map profile-config='{\"profile\":\"research-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-dashboard-map>"
}, },
"bundle": "research-dashboard" "bundle": "research-dashboard"
} }

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. --> <!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V" crossorigin="anonymous"></script> <script src="../my-upg-100-list.js" integrity="sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. --> <!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V" crossorigin="anonymous"></script> <script src="../my-upg-100-list.js" integrity="sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. --> <!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V" crossorigin="anonymous"></script> <script src="../my-upg-100-list.js" integrity="sha384-jSw2cGOaBfg2LS3+44IHUnI7SJnl9mqRZNNDLjPlLhA95uSlv8CYlVNtuFZE8wrj" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. --> <!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25" crossorigin="anonymous"></script> <script src="../../people-groups-grid.js" integrity="sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. --> <!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../people-groups-grid.js" integrity="sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25" crossorigin="anonymous"></script> <script src="../people-groups-grid.js" integrity="sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <people-groups-grid> custom element. --> <!-- Bundle IIFE — registers the <people-groups-grid> custom element. -->
<script src="../../people-groups-grid.js" integrity="sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25" crossorigin="anonymous"></script> <script src="../../people-groups-grid.js" integrity="sha384-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. --> <!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ" crossorigin="anonymous"></script> <script src="../regions-dashboard.js" integrity="sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. --> <!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ" crossorigin="anonymous"></script> <script src="../regions-dashboard.js" integrity="sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -47,7 +47,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. --> <!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ" crossorigin="anonymous"></script> <script src="../regions-dashboard.js" integrity="sha384-jPzA1tgpyXKF32whK/PChM00zMGVmD6lTF36H28intnDnm33WE08194L81M66f+2" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <research-dashboard-map> custom element. --> <!-- Bundle IIFE — registers the <research-dashboard-map> custom element. -->
<script src="../research-dashboard.js" integrity="sha384-9uLizaH0Q8GRKMZllin/7KWx1zwWp5foHO8uBaxv6gR4DSaw5KYNtJknesc2+g/v" crossorigin="anonymous"></script> <script src="../research-dashboard.js" integrity="sha384-dCJvlRSHcExS1S4hjM2xu09V0J6PF3/+PbpgHEPmJI+8zjohwSoPyaBf2539z43l" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -28,11 +28,11 @@
"from the api, no problem \u2014 if they want to set a mapbox sk they can'. This is token-ladder", "from the api, no problem \u2014 if they want to set a mapbox sk they can'. This is token-ladder",
"RUNG 3 (see internal/generate-tree.mjs:595): rungs 1-3 are used DIRECTLY with no network call,", "RUNG 3 (see internal/generate-tree.mjs:595): rungs 1-3 are used DIRECTLY with no network call,",
"so baking it makes every map work out of the box on ANY host \u2014 including one with no", "so baking it makes every map work out of the box on ANY host \u2014 including one with no",
"/api/maps/token endpoint and no CORS on the fallback, which is exactly why nuxtbase.app maps", "/api/maps/token endpoint and no CORS on the fallback, which is exactly why maps on the live test host",
"were blank. SOURCE OF TRUTH: https://doxa.life/api/maps/token (see", "were blank. SOURCE OF TRUTH: https://doxa.life/api/maps/token (see",
"internal-tracker/01-ideation-research/REFERENCE-mapbox-token-endpoint.md). Re-fetch from there rather", "internal-tracker/01-ideation-research/REFERENCE-mapbox-token-endpoint.md). Re-fetch from there rather",
"than copying this literal around. A pk. is a PUBLIC key protected by URL restrictions, not by", "than copying this literal around. A pk. is a PUBLIC key protected by URL restrictions, not by",
"secrecy \u2014 never put an sk. here; check:public-hygiene still fails the build on sk./tk." "secrecy \u2014 never put an sk. here; check:public-hygiene still fails the build on sk./tk."
], ],
"//tokenUrl-2026-09-22": "EMPTIED 2026-09-22. It pointed at https://doxa.life/api/maps/token, which serves a URL-RESTRICTED token \u2014 tiles 403 from every origin except doxa.life, so the basemap rendered as an empty void while pins drew fine (the agent's exact report: 'it's loading the pins, but it's not loading the actual underlying map'). That endpoint sits ABOVE the baked token in the ladder, so leaving it set would fetch the broken token and overwrite the good one. Measured: the baked token = 200 from no-Referer, nuxtbase.app AND localhost; the domain-restricted token = 403 from all three." "//tokenUrl-2026-09-22": "EMPTIED 2026-09-22. It pointed at https://doxa.life/api/maps/token, which serves a URL-RESTRICTED token \u2014 tiles 403 from every origin except doxa.life, so the basemap rendered as an empty void while pins drew fine (the agent's exact report: 'it's loading the pins, but it's not loading the actual underlying map'). That endpoint sits ABOVE the baked token in the ladder, so leaving it set would fetch the broken token and overwrite the good one. Measured: the baked token = 200 from no-Referer, the live test host AND localhost; the domain-restricted token = 403 from all three."
} }