Update from source 835ec1ac
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-26 22:25:05 +00:00
parent 471832876c
commit c74e191dbd
139 changed files with 2463 additions and 1324 deletions

View file

@ -49,9 +49,9 @@ the Tauri origin** — exactly the surprise `capacitor.config.json` documents fo
**The fix is a response header on the token host, not a change in `tauri.conf.json`.**
Measured 2026-09-21: `https://doxa.life/api/maps/token` → **200**;
`https://nuxtbase.app/api/maps/token` → **404**. A desktop build pointed at nuxtbase would launch
the test host's `/api/maps/token` → **404**. A desktop build pointed at that host would launch
with blank basemaps even though the window opens and the element mounts correctly. Separately, the
Mapbox token is **URL-restricted**: with a `nuxtbase.app` Referer the style document returns 200 but
Mapbox token is **URL-restricted**: with the test host as Referer the style document returns 200 but
every **tile 403s**, while the same token with a `doxa.life` Referer returns 200. Verify the token
path before blaming the shell.

View file

@ -28,7 +28,7 @@ Then in wp-admin (http://localhost:8888/wp-admin, admin/password):
1. Plugins → activate **Doxa Maps Embed**.
2. Settings → **Doxa Maps** → set the catalog base to a real served tree, e.g.
`https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / doxa-base Forgejo host).
`https://doxa.life/js/doxa-maps` (or your own CDN / GitHub Pages / a Forgejo host you run).
3. Create a page and add the shortcode:
```

View file

@ -52,7 +52,7 @@ $out = do_shortcode_call('doxa_map', [
'bundle' => $target['bundle'],
'element' => $prof['element'],
'profile' => $prof['id'] ?? $prof['name'] ?? $target['bundle'],
'base' => 'https://nuxtbase.app/plugin-assets/'.$tree,
'base' => 'https://cdn.example.org/plugin-assets/'.$tree,
'integrity' => $integrity,
]);
global $SCRIPTS, $INLINE;
@ -76,7 +76,7 @@ $cfg = json_decode($json, true);
$assert('profile-config is valid JSON', is_array($cfg), $json ? substr($json,0,60) : 'no match');
// 3. the script src equals the manifest's catalog path for this bundle
$src = reset($SCRIPTS)['src'] ?? '';
$want = "https://nuxtbase.app/plugin-assets/{$tree}/{$target['bundle']}/{$target['bundle']}.js";
$want = "https://cdn.example.org/plugin-assets/{$tree}/{$target['bundle']}/{$target['bundle']}.js";
$assert('script src matches catalog path', $src === $want, $src);
// 4. SRI: the filter must add integrity to the emitted <script> tag
$handle = array_key_first($SCRIPTS);
@ -86,7 +86,7 @@ $assert('crossorigin anonymous present', str_contains($tag, 'crossorigin="anonym
// 5. a BAD integrity must be REJECTED (negative test — the format regex must actually fire)
$SCRIPTS = [];
$out2 = do_shortcode_call('doxa_map', ['bundle'=>$target['bundle'],'element'=>$prof['element'],
'profile'=>$prof['id']??$target['bundle'],'base'=>'https://nuxtbase.app/plugin-assets/'.$tree,
'profile'=>$prof['id']??$target['bundle'],'base'=>'https://cdn.example.org/plugin-assets/'.$tree,
'integrity'=>'not-a-valid-hash!!']);
$h2 = array_key_first($SCRIPTS);
$tag2 = apply_filters('script_loader_tag', "<script src='x' id='$h2-js'></script>", $h2, 'x');

View file

@ -9,7 +9,7 @@ again, so every parameter below is a promise kept by a gate
style="width:100%;height:600px;border:0" title="Gospel access"></iframe>
```
## The four addresses
## The five addresses
| Address | What the reader gets |
|---|---|
@ -17,6 +17,7 @@ again, so every parameter below is a promise kept by a gate
| `?country=<ISO3>` | That country's sheet open, framed on its groups. The country's own name also works (`?country=India`). |
| `?code=<iso3>` | The people group's sheet, with its resources. `&name=` and `&country=` disambiguate when a language has many groups. |
| `?view=zero` | The groups with **nothing found** — the question this map exists for. `?view=gap` is the same view. |
| `?tab=province&province=<name>` | On the data tables: that state's row — groups, people, what has nothing, the local way in, and who nobody is praying for. Accents are folded, so `Arunachal Pradesh` finds `Arunāchal Pradesh`. |
`?provider=maplibre` is not this map's parameter but the whole fleet's: it selects the keyless
engine, and no map ever substitutes a different one (`bun run check:engine-contract`).

View file

@ -147,6 +147,8 @@ and a change here can break other maps.
`import … from '@map/composables/useMapSelection.js'`
- **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile.
`import … from '@map/composables/useMapTheme.js'`
- **useOptionalT** — ★ NO <area>.<key>-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such
`import … from '@map/composables/useOptionalT.js'`
- **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares.
`import … from '@map/composables/usePosterPrints.js'`
- **usePplrInstance** — usePplrInstance.js — per-map "instance store" mediator.

View file

@ -29,6 +29,14 @@ check('three groups on one point find each other', colocatedWith(idx, rows[0]).s
check('a GeoJSON feature is matched to rows on the same point', colocatedWith(idx, rows[2]).sort().join() === 'Godda,Koraga', colocatedWith(idx, rows[2]))
check('a lone point shares with nobody', colocatedWith(idx, rows[3]).length === 0, colocatedWith(idx, rows[3]))
check('a row without a position is ignored', colocatedWith(idx, rows[4]).length === 0, colocatedWith(idx, rows[4]))
// A click hands over the RENDERED feature: the engine rebuilt its geometry from tile coordinates, so it is
// near the roster point but not equal to it: ~0.001 degree at low zoom (rega, row 46, a shared point that never matched).
const rendered = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [75.2486, 12.8431] } }
check('a rendered (tile-rounded) feature still finds its co-located groups', colocatedWith(idx, rendered).sort().join() === 'Godda,Maila', colocatedWith(idx, rendered))
// The same people in two countries: the nearest roster row wins, not the first.
const twin = buildColocationIndex([...rows, { name: 'Koraga', latitude: 40, longitude: 40 }, { name: 'Other', latitude: 40, longitude: 40 }])
const renderedFar = { type: 'Feature', properties: { name: 'Koraga' }, geometry: { type: 'Point', coordinates: [40.0007, 39.9994] } }
check('a repeated name resolves to the nearest roster point', colocatedWith(twin, renderedFar).join() === 'Other', colocatedWith(twin, renderedFar))
const { createServer } = await import('vite')
const { default: vue } = await import('@vitejs/plugin-vue')

View file

@ -108,7 +108,43 @@ try {
if (!c.mounted) fails.push('?country=ZZZ: the leaf did not mount — a bad parameter must never break the embed')
else console.log(' ok ?country=ZZZ'.padEnd(34) + 'unknown country ignored, leaf still renders')
/* 4 — the view that already existed, kept honest */
/* 4 — a province deep-links into the TABLE, diacritics folded. The map writes "Arunāchal
Pradesh" (geoBoundaries' shapeName); a partner or a reader types the plain ASCII, and a link
that only works for whoever copied it off the map is not an address. */
const prov = JSON.parse(readFileSync(resolve(PUBLIC, TREE_NAME, 'assets/tables/gospel-by-province.json'), 'utf8'))
const accented = (prov.rows || []).map((r) => r.province).find((n) => /[^\x00-\x7F]/.test(n))
if (!accented) fails.push('no accented province name in the table — the diacritic arm would pass vacuously')
else {
const plain = accented.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
const tbl = `${BASE}/${TREE_NAME}/gospel-resources/data-tables/index.html`
const readTable = async (query) => {
await page.goto(`${tbl}${query}`, { waitUntil: 'domcontentloaded', timeout: 60000 })
await page.waitForTimeout(12000)
return page.evaluate((tag) => {
/* Every leaf in this bundle is served through the SAME custom element; the profile is what
differs. Guessing a per-leaf tag name here returned null and read as "0 rows". */
const root = document.querySelector(tag)?.shadowRoot || document
return {
rows: root.querySelectorAll('.tabulator-row').length,
/* NOT sliced. The first version cut this to 300 characters and then asked whether the
province name was in it: the row was on screen, 400 characters down, and the gate
reported the product broken. A slice belongs in the MESSAGE, never in the predicate. */
text: (root.textContent || '').replace(/\s+/g, ' ').trim(),
}
}, TAG)
}
const hit = await readTable(`?tab=province&province=${encodeURIComponent(plain)}`)
if (hit.rows !== 1) fails.push(`?province=${plain}: ${hit.rows} row(s) rendered, expected exactly 1 — the diacritic fold is not working`)
else if (!hit.text.includes(accented)) fails.push(`?province=${plain}: the row rendered but "${accented}" is not on screen`)
else console.log(` ok ?tab=province`.padEnd(34) + `"${plain}" found "${accented}", 1 row`)
const miss = await readTable('?tab=province&province=Nowhereshire')
if (miss.rows !== 0) fails.push(`?province=Nowhereshire: ${miss.rows} row(s) — an unknown province must match nothing, not everything`)
else if (!/\b0\b/.test(miss.text)) fails.push('?province=Nowhereshire: nothing on screen tells the reader the filter matched nothing')
else console.log(' ok ?tab=province (unknown)'.padEnd(34) + 'matches nothing and says so')
}
/* 5 — the view that already existed, kept honest */
const d = await read(page, '?view=zero')
if (!d.mounted) fails.push('?view=zero: the leaf did not mount')
else if (!d.text.length) fails.push('?view=zero: the leaf rendered nothing')
@ -125,7 +161,7 @@ try {
for (const f of fails) console.error(` ${f}`)
process.exit(1)
}
console.log(`EMBED-CONTRACT: PASS — ${matched} of ${total} pins for a three-code link, a country opens from its ISO3 alone, an unknown one is ignored, and ?view=zero still renders.`)
console.log(`EMBED-CONTRACT: PASS — ${matched} of ${total} pins for a three-code link; a country opens from its ISO3 alone and an unknown one is ignored; a province deep-links into the table with its diacritics folded and an unknown one matches nothing; ?view=zero still renders.`)
} finally {
try { await browser?.close() } catch (_) {}
try { srv?.close?.() } catch (_) {}

View file

@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* check-legend-logical-insets.mjs — the legend components position themselves with LOGICAL insets.
*
* WHY: the shared legend (.stl-panel, its reopen button, the detail buttons, the tree-guide hairlines) used
* `left: 8px`. MapToolbar uses `inset-inline-end`, which mirrors under dir="rtl"; a physical `left` does not.
* So in Arabic the controls moved left onto a legend that stayed left, on three production maps at once
* (Simple Research, Prayer Progress, UPG-100; rega's survey at 52eeb0db). The fix is inset-inline-start /
* inset-inline-end; this gate keeps a physical left/right from coming back.
*
* WHAT IT READS: every CSS declaration in library/components/legends/*.vue (comments stripped). A
* declaration `left:` / `right:` / `padding-left|right:` / `margin-left|right:` fails unless it is:
* - a centring or full-width rule, which is symmetric under RTL (`left:50%`, `left:0` beside `right:0`);
* - a reset to auto (`left:auto`), which undoes rather than places;
* - listed in ALLOWED below with the reason it may stay physical.
* Inline :style bindings computed in JS (the progress bar's popover) are not CSS declarations and are not read.
*
* --self-test poisons a copy of one rule and asserts the gate notices: a gate that cannot fail proves nothing.
*/
import { readFileSync, readdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const DIR = join(ROOT, 'library/components/legends')
// file -> substrings of declarations that may stay physical, each with its reason.
const ALLOWED = {
'LegendMobile.vue': [
['.mapboxgl-ctrl-attrib.mapboxgl-compact{margin-right', "the engine's own attribution control, which the engine places"],
],
'LegendProgressBar.vue': [
['.lpb-pop{', 'the popover is placed by JS (popLeft) over the bar segment it describes'],
],
}
const PHYSICAL = /(^|[;{\s])((?:padding|margin)-(?:left|right)|left|right)\s*:\s*([^;}]+)/g
function findings(file, src) {
const out = []
const styles = [...src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>|`([^`]*\{[^`]*\}[^`]*)`/g)].map((m) => m[1] ?? m[2])
const allowed = ALLOWED[file] || []
for (const css of styles) {
const clean = css.replace(/\/\*[\s\S]*?\*\//g, '')
for (const rule of clean.split('}')) {
const [sel = '', body = ''] = rule.split('{')
const decl = sel.trim() + '{' + body
for (const m of body.matchAll(PHYSICAL)) {
const prop = m[2], val = m[3].trim().replace(/!important$/, '').trim()
if (val === 'auto') continue
if ((prop === 'left' || prop === 'right') && /^50%$/.test(val)) continue
if (prop === 'left' && val === '0' && /right\s*:\s*0\b/.test(body)) continue
if (prop === 'right' && val === '0' && /left\s*:\s*0\b/.test(body)) continue
if (allowed.some(([s]) => decl.replace(/\s+/g, '').includes(s.replace(/\s+/g, '')))) continue
out.push(`${file}: ${sel.trim().replace(/\s+/g, ' ').slice(0, 70)} { ${prop}: ${val} }`)
}
}
}
return out
}
function run(read) {
const all = []
for (const f of readdirSync(DIR).filter((f) => f.endsWith('.vue')).sort()) all.push(...findings(f, read(f)))
return all
}
const readReal = (f) => readFileSync(join(DIR, f), 'utf8')
if (process.argv.includes('--self-test')) {
const poisoned = (f) => {
const s = readReal(f)
return f === 'SemanticTreeLegend.vue' ? s.replace('inset-inline-start: 8px; top: var(--stl-top', 'left: 8px; top: var(--stl-top') : s
}
const hit = run(poisoned)
if (!hit.some((h) => h.includes('.stl-panel') && h.includes('left: 8px'))) {
console.error('LEGEND-LOGICAL-INSETS self-test: FAIL — a poisoned `.stl-panel { left: 8px }` was not caught')
process.exit(1)
}
console.log('LEGEND-LOGICAL-INSETS self-test: PASS — a physical `left: 8px` on .stl-panel is caught')
process.exit(0)
}
const bad = run(readReal)
if (bad.length) {
console.error(`LEGEND-LOGICAL-INSETS: FAIL — ${bad.length} physical left/right declaration(s) in the legend components:`)
for (const b of bad) console.error(' ✗ ' + b)
console.error('\n A physical inset does not mirror under dir="rtl" while the map controls (inset-inline-end) do,')
console.error(' so in Arabic they overlap. Use inset-inline-start / inset-inline-end (padding-/margin-inline-*),')
console.error(' or add the rule to ALLOWED in this file with the reason it must stay physical.')
process.exit(1)
}
console.log('LEGEND-LOGICAL-INSETS: PASS — every legend inset is logical, symmetric, or allowed with a reason.')

View file

@ -0,0 +1,238 @@
#!/usr/bin/env node
/* check-optional-t-equivalence.mjs — `bun run check:optional-t-equivalence`. Source-only, headless.
EVERY LATER C4 WAVE EXTENDS THIS FILE (dx-shepherd, REGC4G): a wave that migrates a site without
adding its row here is a wave whose behaviour nobody re-checks. Sections 1-6 are the helper's own
contract; section 7 is the per-site register. */
// C4: does useOptionalT() behave like the twenty hand-written guarded translators it replaces?
//
// Same condition the shepherd set for C2 and C3: old pattern vs new, every branch, negative-tested.
// The old shape, verbatim from usePplrT.js before this migration:
// const enLookup = (key) => String(key).replace(/^pplr\./,'').split('.').reduce(...enPplr)
// const fill = (str, params) => params ? str.replace(/\{(\w+)\}/g, ...) : str
// try { const i18n = useI18n(); return (k,p) => { const v = i18n.t(k,p||{}); return v===k ? fill(enLookup(k) ?? k, p) : v } }
// catch (_) { return (k,p) => fill(enLookup(k) ?? String(k), p) }
//
// ★ WHY THIS CANNOT JUST IMPORT THE MODULE. useOptionalT.js imports vue-i18n and @map/i18n/index.js,
// and that index calls `import.meta.glob` — a Vite transform plain Node cannot run. So the source is
// EXTRACTED AND EXECUTED with tStatic and useI18n stubbed, the same approach check-provider-for.mjs
// uses for engineGlobal, and for the same reason: re-typing the function into the gate would test the
// copy rather than the thing that ships.
import { readFileSync } from 'node:fs'
import { codeOnly } from '../plugin-designs/regions-dashboard/scripts/strip-code.mjs'
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const src = readFileSync(new URL('../library/composables/useOptionalT.js', import.meta.url), 'utf8')
const body = src
.replace(/^import .*$/gm, '') // drop the two un-runnable imports
.replace(/^export default .*$/gm, '') // and the default re-export (it is not a declaration)
.replace(/^export /gm, '')
const make = (tStatic, useI18n) => new Function('tStatic', 'useI18n',
body + '\nreturn { useOptionalT, useOptionalI18n, fillParams, qualify };')(tStatic, useI18n)
// The English catalogue the old code imported, as a stub tree: messages.en.pplr equivalent.
const EN = { greeting: 'Hello {name}', nested: { deep: 'Deep value' } }
const tStatic = (path) => String(path).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
const throwingI18n = () => { throw new Error('Need to install with `app.use` function') }
const hostI18n = (table) => () => ({ t: (k) => (k in table ? table[k] : k) })
// ── 1. no i18n on the host — the case seven bundles are actually in ─────────────────────────────
{
const { useOptionalT } = make(tStatic, throwingI18n)
const t = useOptionalT('pplr')
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: falls back to the catalogue AND interpolates')
ok(t('nested.deep') === 'Deep value', 'bare host: resolves a nested key')
ok(t('missing.key') === 'missing.key', 'bare host: a missing key renders as the KEY, never blank')
ok(t('pplr.greeting', { name: 'Ada' }) === 'Hello Ada', 'bare host: an already-qualified key is not double-prefixed')
}
// ── 2. host HAS i18n and has the string — the host must win ─────────────────────────────────────
{
const { useOptionalT } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
ok(useOptionalT('pplr')('greeting') === 'Bonjour', 'host i18n wins when it has the key -- a host managing locale keeps control')
}
// ── 3. host HAS i18n but LACKS the string: i18n.t returns the key unchanged ─────────────────────
{
const { useOptionalT } = make(tStatic, hostI18n({}))
const t = useOptionalT('pplr')
ok(t('greeting', { name: 'Ada' }) === 'Hello Ada',
'host i18n present but missing the key falls through to the catalogue -- the case every old copy handled with `v === key`')
}
// ── 4. the pure helpers ────────────────────────────────────────────────────────────────────────
{
const { fillParams, qualify } = make(tStatic, throwingI18n)
ok(fillParams('a {x} b', { x: 1 }) === 'a 1 b', 'fillParams interpolates')
ok(fillParams('a {x} b', {}) === 'a {x} b', 'fillParams leaves an unknown placeholder visible rather than blanking it')
ok(fillParams('a {x} b', { x: null }) === 'a {x} b',
'fillParams leaves the placeholder for a NULL value -- a key-presence test would render the string "null" into the sentence')
ok(fillParams('a {x} b', { x: 0 }) === 'a 0 b',
'but 0 is a real value and must still substitute (a truthiness test would drop it)')
ok(fillParams('plain') === 'plain', 'fillParams with no params is a no-op')
ok(qualify('pplr', 'x') === 'pplr.x' && qualify('pplr', 'pplr.x') === 'pplr.x', 'qualify prefixes once, never twice')
}
// ── 5. EQUIVALENCE with the old hand-written implementation, branch for branch ──────────────────
{
const fill = (s, p) => (p ? String(s).replace(/\{(\w+)\}/g, (m, k) => (k in p ? p[k] : m)) : s)
const enLookup = (key) => String(key).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN)
const oldBare = (k, p) => fill(enLookup(k) ?? String(k), p)
const { useOptionalT } = make(tStatic, throwingI18n)
const neu = useOptionalT('pplr')
for (const [k, p] of [['greeting', { name: 'Ada' }], ['nested.deep', null], ['missing.key', null], ['greeting', null]]) {
ok(oldBare(k, p) === neu(k, p), `EQUIVALENT on the bare-host path for ${JSON.stringify(k)}: both give ${JSON.stringify(oldBare(k, p))}`)
}
}
// ── 6. NEG CONTROLS ────────────────────────────────────────────────────────────────────────────
{
const { useOptionalT } = make(() => undefined, throwingI18n)
ok(useOptionalT('pplr')('greeting') === 'greeting',
'NEG CONTROL: with a catalogue that knows nothing, the key comes back -- so the passes above were really reading the catalogue')
const { useOptionalT: u2 } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
ok(u2('pplr')('greeting') !== 'Hello Ada', 'NEG CONTROL: the host path and the catalogue path are distinguishable')
}
ok(/import \{ tStatic \}/.test(src), 'CONTROL: the shipped module really does route through tStatic (not a catalogue import of its own)')
// ★ codeOnly, because this assertion failed on its OWN DOCUMENTATION: useOptionalT.js's header QUOTES
// '@map/i18n/locales/en/pplr.json' as the import it removed, and a whole-file grep counted the prose as
// the thing it forbids. Fifth time this session that a detector has read a comment as code.
ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area English JSON in CODE -- the redundant second copy is gone')
// ── 7. THE PER-SITE REGISTER. One row per migrated site; later waves append here. ───────────────
// Each row asserts the two things a migration can get wrong: that the site actually routes through
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
// catalogue. The second matters more than it looks: routing these through tStatic would change what
// seven bare-host bundles render, and would start translating strings that are English today.
import { readFileSync as rf } from 'node:fs'
const SITES = [
// file, helper it must use, a fragment of the fallback it must still pass
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
['library/components/search/FilterDropdown.vue', 'useOptionalT', 'FALLBACK[key]'],
['library/components/legends/SemanticTreeLegend.vue', 'useOptionalI18n', 'humanizeKey(k)'],
['library/components/poster/PosterWall.vue', 'useOptionalI18n', 'humanizeKey(k)'],
['library/components/hints/PinHint.vue', 'useOptionalI18n', 'PINHINT_FALLBACK[key]'],
['plugin-designs/pplr-data-maps/src/composables/usePplrT.js', 'useOptionalT', null],
['plugin-designs/upg-neighbor/src/composables/useNeighborT.js', 'useOptionalT', null],
// wave 3a — gospel-resources (owner: gospel-plugin). All four fell back to enLookup, i.e. the
// SHIPPED ENGLISH, not the locale-aware catalogue; each passes that enLookup in, so a bare host
// still renders English. Switching them to the catalogue default would make a bare host honour
// ?lang=ar — a real improvement, and gospel-plugin's to make, not this migration's.
// ★ gospel-plugin CHOSE the locale-honouring catalogue fallback (2026-09-26) rather than keeping
// enLookup's English: "most partners will drop a custom element or an iframe and never install
// vue-i18n ... an Arabic ministry site embedding this map serves its readers English while the
// bundle already carries all 11 catalogues. That is a bug, not a decision." So these four take NO
// fallback, and row 7b proves the Arabic actually arrives.
['plugin-designs/gospel-resources/charts.vue', 'useOptionalT', null],
['plugin-designs/gospel-resources/data-tables.vue', 'useOptionalT', null],
['plugin-designs/gospel-resources/resources-list.vue', 'useOptionalT', null],
// ★ resources-map passes NO NAMESPACE: it uses keys in TWO areas (gospel.* and pins.*) and its
// enLookup walks the catalogue ROOT to see both. useOptionalT('gospel', ...) would have qualified
// pins.approximate into gospel.pins.approximate and broken the pins sentence — studio caught that
// as a semantic conflict on main before it landed. The row asserts the '' explicitly so a later
// edit cannot quietly re-add a namespace.
['plugin-designs/gospel-resources/resources-map.vue', "useOptionalT(''", null],
// wave 3b — cities-internet. All three were byte-identical in the guard block; each keeps its own
// enLookup fallback, so a bare host renders the same English. Their local fill() used `k in params`,
// so the shared fillParams also removes a way for them to print the literal "null".
// ★ exp-shepherd, as cities-internet's owner, took the locale-honouring default (2026-09-26): "the
// bundle already carries all 11 catalogues, so a bare host getting English would be a bug". So these
// three pass NO fallback, and their English-only enLookup + per-area catalogue import are deleted.
['plugin-designs/cities-internet/connectivity-list.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/connectivity-map.vue', 'useOptionalT', null],
['plugin-designs/cities-internet/data-tables.vue', 'useOptionalT', null],
]
// ★ 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
// engine's language tag. That is a different fact from translating text: `locale` arrives as a ref OR
// a plain string depending on the host (PosterWall.vue records the same variance), so normalising it
// belongs in a deliberate helper change, not in a translator migration. Asserting EXACTLY 2 means a
// third bare call — a new unguarded translator — still fails this gate.
// ★ DELIBERATELY NOT IN THIS REGISTER, so nobody "completes" C4 by converting it:
// plugin-designs/pplr-data-maps/src/components/SemanticTreeLegend.vue
// It still hand-rolls the guard, and exp-shepherd asked for it to stay that way: the file is carded for
// retirement (RETIRE-PPLR-SEMANTICTREELEGEND) in favour of the library component, so converting it is
// work that gets thrown away. A migration whose value expires before it lands is not worth the diff.
// Two more sites are their owners' to cut, not absent by oversight: country-posters-mfe.vue
// (exp-shepherd, into its single posters landing) and key-cities/cities-map.vue (exp-shepherd, gated).
const ALLOWED_BARE = { 'plugin-designs/gospel-resources/resources-map.vue': 2 }
for (const [file, helper, fallbackFrag] of SITES) {
// ★ STRIP HTML COMMENTS TOO, for .vue files. codeOnly is a JS scanner: it knows /* */ and //, not
// <!-- -->. resources-map.vue carries a long template comment that MENTIONS useI18n(), and counting
// it made this register read 3 bare calls where the code has 2. Same lesson as the CSS-in-a-template
// -literal case: the stripper has to match the LANGUAGE of the text it is reading.
const raw = rf(new URL('../' + file, import.meta.url), 'utf8')
const code = codeOnly(raw.replace(/<!--[\s\S]*?-->/g, ' '))
const short = file.split('/').pop()
ok(helper.includes('(') ? code.includes(helper) : new RegExp(`\\b${helper}\\(`).test(code),
`${short}: routes through ${helper}${helper.includes('(') ? '' : '()'}`)
const bare = (code.match(/useI18n\(/g) || []).length
const allowed = ALLOWED_BARE[file] || 0
ok(bare === allowed, allowed
? `${short}: exactly ${allowed} bare useI18n() left, the documented locale read(s) -- found ${bare}`
: `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`)
if (fallbackFrag) {
ok(code.includes(fallbackFrag),
`${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`)
} else {
// ★ NOT "as it always did" for all of them. The two composables always resolved through the
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
// gate message, which is the one place a reader has no reason to doubt.
const moved = file.includes('gospel-resources')
ok(!/fallback:/.test(code), moved
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
// ── 7b. gospel-plugin's ASKED-FOR ROW, against the REAL catalogue ───────────────────────────────
// Their words: "a BARE host (no vue-i18n) with ?lang=ar must render pins.approximate in Arabic — not
// the key, not gospel.pins.approximate, not English." It covers their conditions 1 and 2 together: a
// namespace would corrupt the key, and an EN-only fallback would serve English to an Arabic reader.
// This reads the SHIPPED ar/ and en/ pins.json rather than a stub, and rebuilds tStatic's own
// resolution order (messages[want] ?? messages.en) over them, so it measures the real catalogue.
{
const arPins = JSON.parse(rf(new URL('../library/i18n/locales/ar/pins.json', import.meta.url), 'utf8'))
const enPins = JSON.parse(rf(new URL('../library/i18n/locales/en/pins.json', import.meta.url), 'utf8'))
const messages = { ar: { pins: arPins }, en: { pins: enPins } }
const realish = (want) => (path) => {
const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc])
const v = walk(want); return typeof v === 'string' ? v : walk('en')
}
const { useOptionalT } = make(realish('ar'), throwingI18n)
const t = useOptionalT('')
const got = t('pins.approximate')
ok(got === arPins.approximate,
'GOSPEL ROW: a BARE host under ar renders pins.approximate in ARABIC from the real catalogue')
ok(got !== 'pins.approximate' && got !== 'gospel.pins.approximate',
'and it is neither the raw key nor a namespace-mangled key')
ok(got !== enPins.approximate,
'and it is NOT the English -- which is exactly what enLookup used to serve every partner host')
// the same call must still reach English when the locale has no entry
const tEnOnly = useOptionalT('')
ok(typeof arPins.approximate === 'string' && typeof enPins.approximate === 'string',
'CONTROL: both catalogues really do carry the key, so the three assertions above are measuring a difference that exists')
const { useOptionalT: uMissing } = make(realish('zz'), throwingI18n)
ok(uMissing('')('pins.approximate') === enPins.approximate,
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
}
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)
const mine = useOptionalT('pplr', { fallback: (k) => (k === 'pplr.greeting' ? 'MINE {name}' : undefined) })
ok(mine('greeting', { name: 'Ada' }) === 'MINE Ada',
'opts.fallback WINS over the catalogue -- that is what keeps each migrated site rendering its own English')
ok(mine('nested.deep') === 'nested.deep',
'and when the site fallback has nothing, the KEY shows (it does NOT silently fall through to the catalogue)')
const { te, locale } = useOptionalI18n('pplr')
ok(te('greeting') === false, 'te() is FALSE on a bare host -- PinHint and SemanticTreeLegend depend on that to use their own defaults')
ok(locale === 'en', 'locale defaults to en on a bare host, as PosterWall had it')
}
{
const { useOptionalI18n } = make(tStatic, hostI18n({ 'pplr.greeting': 'Bonjour' }))
const { te } = useOptionalI18n('pplr')
ok(typeof te === 'function', 'te() is a function when the host HAS i18n')
}
console.log(fails ? `\nC4-OPTIONAL-T: ${fails} FAIL(s)` : '\nC4-OPTIONAL-T: the shared translator matches the hand-written guards on every branch')
process.exit(fails ? 1 : 0)

View file

@ -31,6 +31,21 @@ const DEFAULT_COLOR = '#94a3b8' // soft slate gray (matches Unknown family)
/**
* Family-letter → display name. Used by the legend to render row labels.
*/
// ★ THE NAMES BELOW ARE VALUES, NOT LABELS — do not translate this table.
// It is read in BOTH directions and on BOTH sides of a component boundary: the dashboard derives a
// family NAME from a religion code and puts it on the selection bus, and people-groups-grid runs
// the same derivation per row so the two byte-match. Localizing these strings would break that
// link in every locale but English — silently, because an empty intersection looks like "no
// results" rather than a broken wire. That is the same defect the language link cost us.
// To SHOW a family name to a reader, use religionFamilyLabel() below: the value stays English and
// canonical, the label is projected for display only. Exactly the rule the shareable URL follows,
// where the code is canonical and the slug is display-only.
// Display-only projection (see religionFamilyLabel at the end of this file). tStatic and not
// useI18n: this module is imported by components that mount inside custom elements with no i18n
// plugin installed, where an unguarded useI18n() throws on the way in.
import { tStatic } from '../i18n/index.js'
export const RELIGION_FAMILIES = {
'C': 'Christianity',
'M': 'Islam',
@ -110,3 +125,22 @@ export default {
buildColorExpression,
fallback: DEFAULT_COLOR,
}
/**
* The DISPLAY name for a religion family, for a reader — never for a comparison.
*
* Takes either the family letter ('M') or the canonical English name ('Islam') so a caller can
* pass whichever it is holding, and falls back to the English name when a locale has no entry, so
* a missing key degrades to a readable word rather than to a key on screen.
*
* ★ THE RETURN VALUE MUST NOT BE STORED, COMPARED OR SENT. It is localized, so it differs per
* reader; the moment it lands in a filter, a bus payload or an element attribute, the link it
* crosses works in English and matches nothing in Arabic. If you need a value, use
* RELIGION_FAMILIES.
*/
export function religionFamilyLabel(familyOrName) {
const key = String(familyOrName || '')
const name = RELIGION_FAMILIES[key] || key
if (!name) return ''
return tStatic('religion.family.' + name) || name
}

View file

@ -25,7 +25,11 @@
*
* TASTEFUL BY DESIGN (no dark patterns):
* · SELF-GATING — shows only after a map <canvas> actually exists in this
* custom element's shadow root, so list/table profiles never see it.
* custom element's shadow root, so list/table profiles never see it, AND only
* while that map is on screen. A composite (pane-wrapper) keeps the map pane
* mounted while the reader scrolls to another pane, so "a canvas exists" is not
* "the map is showing": without the second test the pill was clamped into view
* over the UPG-100 poster pane, where there is no map to tap.
* · ONCE PER VISIT — it appears on every page load and stays until the reader closes
* it with the ×. Nothing is remembered between loads: leaving and returning (or a
* reload) shows it again, because the invitation is the point.
@ -44,7 +48,7 @@ import { tStatic } from '@map/i18n/index.js'
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in and
// takes the subtree with it. The English stays as the fallback.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
// ★ i18n IS OPTIONAL HERE, NOT REQUIRED. useI18n() THROWS ("Need to install with `app.use`
@ -57,9 +61,9 @@ import { useShadowStyles } from '../../composables/useShadowStyles.js'
// has them, the English literal otherwise. `te` becomes a "no, I don't have it" so every caller
// falls through to its own default.
const PINHINT_FALLBACK = { 'messages.pinHint.tapDot': 'Tap a dot to see who still needs prayer' }
let t = (key) => PINHINT_FALLBACK[key] || key
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalI18n now. PINHINT_FALLBACK is passed in unchanged
// and `te` still answers false on a bare host, so every caller keeps its own default.
const { t, te } = useOptionalI18n('', { fallback: (key) => PINHINT_FALLBACK[key] })
const uiStore = inject('uiStore', null)
const profileConfig = inject('profileConfig', null)
const isDark = computed(() => uiStore?.theme === 'dark')
@ -91,6 +95,10 @@ const label = computed(() => {
})
const visible = ref(false)
// Whether the map box is actually in view (IntersectionObserver, below). Separate from `visible`,
// which only the reader's × turns off: scrolling away hides the pill, scrolling back shows it again.
const onScreen = ref(true)
let mapIO = null
const hintEl = ref(null)
let watchTimer = null
let movedTo = null // the element we re-parented into, so we can put ourselves back
@ -202,7 +210,7 @@ function sheetFloor(pill) {
function clampIntoView() {
const el = hintEl.value
if (!el) return
if (!el || !onScreen.value) return
try {
// ★ ADJUST `bottom`, NEVER `transform`. My first attempt set el.style.transform and cleared it
// between measurements — both wrong here, and the diagnosis took two failed builds:
@ -292,6 +300,19 @@ onMounted(() => {
if (box && el && el.parentElement !== box) {
try { box.appendChild(el); movedTo = box } catch (_) { /* keep the page anchor */ }
}
// SHOW ONLY WHILE THE MAP IS IN VIEW. The observer's root is the viewport, and it clips by every
// scrolling ancestor, so a map pane scrolled out of a composite's column reads as not intersecting.
// Less than a fifth of the map on screen counts as away: the pill needs map under it.
if (box && typeof IntersectionObserver !== 'undefined') {
try {
mapIO = new IntersectionObserver((entries) => {
const e = entries[entries.length - 1]
const now = !!e && e.isIntersecting && e.intersectionRatio >= 0.2
if (now !== onScreen.value) { onScreen.value = now; if (now) nextTick(clampIntoView) }
}, { threshold: [0, 0.2, 0.5] })
mapIO.observe(box)
} catch (_) { /* no observer: behave as before, shown once a canvas exists */ }
}
syncAttribClearance(root)
clampIntoView()
// The attribution ResizeObserver only fires when THAT control resizes, which need not
@ -318,6 +339,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
if (watchTimer) clearInterval(watchTimer)
if (mapIO) { try { mapIO.disconnect() } catch (_) {} mapIO = null }
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
if (hostRO) { try { hostRO.disconnect() } catch (_) {} hostRO = null }
if (sheetRO) { try { sheetRO.disconnect() } catch (_) {} sheetRO = null } sheetWatched = null
@ -378,7 +400,7 @@ useShadowStyles(`
</script>
<template>
<div v-if="visible" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<div v-if="visible" v-show="onScreen" ref="hintEl" class="pin-hint" :class="{ dark: isDark }" role="status">
<span class="pin-hint-pill">
<span class="pin-hint-dot" aria-hidden="true"></span>
{{ label }}

View file

@ -9,7 +9,7 @@
-->
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const props = defineProps({
/** Names of the OTHER groups on this pin's coordinate; empty = not shared. */
@ -23,8 +23,9 @@ const APPROX_FALLBACK = {
'pins.approximate': 'The dot marks where this people is found, not a town or a boundary.',
'pins.sharedPoint': 'It sits on the same point as {names} — {n} groups share this coordinate, so the position tells you the locality and not which group is where.',
}
let t = (key, vars = {}) => (APPROX_FALLBACK[key] || key).replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m))
try { t = useI18n().t } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalT.js now. The fallback MAP stays exactly as it was,
// passed in, so a bare host renders the same English it rendered before.
const t = useOptionalT('', { fallback: (key) => APPROX_FALLBACK[key] })
const second = computed(() => props.shared.length
? t('pins.sharedPoint', { names: props.shared.join(', '), n: props.shared.length + 1 })
: '')

View file

@ -38,7 +38,7 @@ useShadowStyles(`
sit at exactly the same position+size — no visible jump when the user
clicks a pin and the detail panel swaps in over the language-family legend
(qa: 2026-05-02). */
.legend-container{position:absolute;top:60px;left:8px;width:var(--map-legend-width,380px);max-height:calc(100vh - 80px);background:#fff;border-radius:var(--map-radius,16px);box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 3px rgba(0,0,0,0.08);z-index:1000;overflow:hidden;display:flex;flex-direction:column;transition:width 200ms cubic-bezier(0.2,0,0,1),max-height 200ms cubic-bezier(0.2,0,0,1),min-width 200ms cubic-bezier(0.2,0,0,1),border-radius 200ms linear,box-shadow 200ms linear;will-change:width,max-height;}
.legend-container{position:absolute;top:60px;inset-inline-start:8px;width:var(--map-legend-width,380px);max-height:calc(100vh - 80px);background:#fff;border-radius:var(--map-radius,16px);box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 3px rgba(0,0,0,0.08);z-index:1000;overflow:hidden;display:flex;flex-direction:column;transition:width 200ms cubic-bezier(0.2,0,0,1),max-height 200ms cubic-bezier(0.2,0,0,1),min-width 200ms cubic-bezier(0.2,0,0,1),border-radius 200ms linear,box-shadow 200ms linear;will-change:width,max-height;}
/* ── DESKTOP TYPE SCALE for the LegendRows-based card (the agent, 2026-09-16: larger, more
readable desktop legends). The semantic-tree panel's scale lives in SemanticTreeLegend.vue
(it renders without this card on some maps). Size only, never layout. ── */
@ -52,7 +52,7 @@ useShadowStyles(`
floating shadow, uppercase monospace font. Hover lifts + glows.
Name track auto-sizes to fit title text. */
/* ── Reopen pill (collapsed state) — mirrors STL .stl-reopen ── */
.legend-reopen{position:absolute;left:8px;top:60px;z-index:1000;display:inline-flex;align-items:center;gap:8px;background:#0d1117;border:1px solid #30363d;border-radius:999px;color:#c9d1d9;cursor:pointer;height:36px;padding:0 14px 0 12px;box-shadow:0 4px 14px rgba(0,0,0,0.25);font:600 11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.06em;max-width:320px;transition:color 0.12s,background 0.12s,border-color 0.12s,transform 0.12s,box-shadow 0.12s;}
.legend-reopen{position:absolute;inset-inline-start:8px;top:60px;z-index:1000;display:inline-flex;align-items:center;gap:8px;background:#0d1117;border:1px solid #30363d;border-radius:999px;color:#c9d1d9;cursor:pointer;height:36px;padding:0 14px 0 12px;box-shadow:0 4px 14px rgba(0,0,0,0.25);font:600 11px ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.06em;max-width:320px;transition:color 0.12s,background 0.12s,border-color 0.12s,transform 0.12s,box-shadow 0.12s;}
.legend-reopen:hover{color:#fff;background:#1c2333;border-color:#73A17F;box-shadow:0 6px 20px rgba(59,70,61,0.25);transform:translateY(-1px);}
.legend-reopen-icon{color:#73A17F;flex-shrink:0;}
.legend-reopen-label{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:200px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-transform:none;letter-spacing:0;font:600 12px system-ui,sans-serif;}
@ -78,7 +78,7 @@ useShadowStyles(`
.legend-container:not(.legend-dark) .legend-collapse-caret:hover{background:rgba(59,70,61,0.12);border-color:#3b463d;color:#3b463d;}
/* Detail-mode close button — top-right of the card */
.detail-close-btn{position:absolute;top:10px;right:10px;background:none;border:none;padding:4px;cursor:pointer;color:#888;display:flex;align-items:center;justify-content:center;z-index:20;transition:color 0.2s ease;}
.detail-close-btn{position:absolute;top:10px;inset-inline-end:10px;background:none;border:none;padding:4px;cursor:pointer;color:#888;display:flex;align-items:center;justify-content:center;z-index:20;transition:color 0.2s ease;}
.detail-close-btn:hover{color:#333;}
/* Detail-mode collapse caret — top-left of the card, mirrors the inline
@ -86,14 +86,14 @@ useShadowStyles(`
above the PeopleGroupDetail sticky header (.detail-header z-index:10) so
it is always tappable even when the detail panel scrolls. The button is
only rendered in the detail-mode template branch (see <template> below). */
.detail-collapse-btn{position:absolute;top:10px;left:10px;z-index:20;background:transparent;border:none;color:transparent;cursor:pointer;width:28px;height:28px;padding:0;outline:none;-webkit-tap-highlight-color:transparent;}
.detail-collapse-btn{position:absolute;top:10px;inset-inline-start:10px;z-index:20;background:transparent;border:none;color:transparent;cursor:pointer;width:28px;height:28px;padding:0;outline:none;-webkit-tap-highlight-color:transparent;}
.detail-collapse-btn:focus{outline:none;}
/* Inside the detail panel: leave a 32px left gutter on the title text so the
absolute-positioned .detail-collapse-btn at top:10px/left:10px does not
overlap the people-group name. Targets the .pg-name-lead inside any
.people-group-detail (which itself is rendered as a child of
.legend-content). Right gutter clears the .detail-close-btn the same way. */
.legend-container .people-group-detail .pg-name-lead{padding-left:28px;padding-right:32px;}
.legend-container .people-group-detail .pg-name-lead{padding-inline-start:28px;padding-inline-end:32px;}
/* ── Content — no padding; left/right AND top/bottom are handled by the
LegendRows grid (caret + trailing columns + .lrg-items padding 12px 0).
@ -135,14 +135,14 @@ useShadowStyles(`
.legend-item-clickable:hover{transform:translateX(4px);box-shadow:0 2px 4px rgba(0,0,0,0.1);}
.legend-item-selected{outline:2px solid #333;outline-offset:2px;}
.legend-item-text{flex:1;font-weight:500;color:#333;}
.legend-item-count{font-size:var(--map-font-sm,11px);color:#555;background:rgba(0,0,0,0.08);padding:2px 7px;border-radius:10px;min-width:40px;text-align:right;margin-right:4px;}
.legend-item-population{font-size:var(--map-font-sm,11px);color:#555;background:rgba(0,0,0,0.08);padding:2px 7px;border-radius:10px;min-width:52px;text-align:right;margin-right:4px;}
.legend-item-count{font-size:var(--map-font-sm,11px);color:#555;background:rgba(0,0,0,0.08);padding:2px 7px;border-radius:10px;min-width:40px;text-align:right;margin-inline-end:4px;}
.legend-item-population{font-size:var(--map-font-sm,11px);color:#555;background:rgba(0,0,0,0.08);padding:2px 7px;border-radius:10px;min-width:52px;text-align:right;margin-inline-end:4px;}
.legend-export-btn{background:none;border:none;cursor:pointer;font-size:15px;padding:0 4px;}
.legend-tree-item{display:flex;flex-direction:column;gap:4px;}
.legend-tree-parent{display:flex;align-items:center;gap:6px;}
.legend-tree-caret{width:16px;height:16px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:transform 0.2s;font-size:12px;color:#666;}
.legend-tree-caret.expanded{transform:rotate(90deg);}
.legend-tree-child{margin-left:20px;display:flex;gap:6px;}
.legend-tree-child{margin-inline-start:20px;display:flex;gap:6px;}
.legend-tree-caret-placeholder{width:16px;}
.legend-item-child{font-size:12px;}
@ -151,10 +151,10 @@ useShadowStyles(`
.legend-stats-label{flex:1;min-width:0;padding:0 10px;}
/* Stats column values — 10px right padding aligns with header's last-of-type gap
and gives a clean breathing space against the card's right edge. */
.legend-stats-value{width:58px;text-align:right;flex-shrink:0;font-family:'Courier New',monospace;padding-right:10px;white-space:pre;}
.legend-stats-value{width:58px;text-align:right;flex-shrink:0;font-family:'Courier New',monospace;padding-inline-end:10px;white-space:pre;}
.legend-footer{display:flex;align-items:center;padding:5px 0;border-top:1px solid rgba(0,0,0,0.07);font-size:11px;font-weight:600;color:#555;background:#fff;}
.legend-footer-label{flex:1;padding-left:10px;color:#777;}
.legend-footer-value{width:56px;text-align:right;padding-right:10px;font-family:'Courier New',monospace;}
.legend-footer-label{flex:1;padding-inline-start:10px;color:#777;}
.legend-footer-value{width:56px;text-align:right;padding-inline-end:10px;font-family:'Courier New',monospace;}
/* ── Scrollbar ── */
.legend-content::-webkit-scrollbar{width:4px;}

View file

@ -23,7 +23,7 @@ useShadowStyles(`
mobile chrome (pull tab, collapse/expand caret, touch drag). ── */
/* bottom = 30% (the sheet's open height) + 16px gap. The Mapbox watermark lives bottom-right
and only rises by the collapsed strip, so nothing else needs room under this image. */
.floating-mini-image{position:absolute;left:16px;bottom:calc(30% + 16px);z-index:999;pointer-events:auto;cursor:pointer;}
.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(30% + 16px);z-index:999;pointer-events:auto;cursor:pointer;}
.mini-image{width:120px;height:150px;object-fit:cover;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.25);display:block;}
/* Containment root: clamps absolute children to the map area. */
@ -57,7 +57,7 @@ useShadowStyles(`
/* In the sheet the legend's rows fill the full width: the clear "x" column does not reserve
space on the right — it floats over the row's right edge only when a row is selected. */
.legend-mobile-sheet .stl-row{position:relative;}
.legend-mobile-sheet .stl-row .stl-x-slot{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:auto;}
.legend-mobile-sheet .stl-row .stl-x-slot{position:absolute;inset-inline-end:6px;top:50%;transform:translateY(-50%);width:auto;}
.legend-mobile-sheet .stl-col-hdr .stl-x-slot{display:none;}
/* Sheet shell — no padding; drag strip and caret are absolutely
@ -103,16 +103,16 @@ useShadowStyles(`
on the right; gives the user an always-visible, finger-sized tap target
to collapse the sheet without having to find the 12px drag-strip. z-index
above .sheet-drag-strip (z:2) so the strip's @click doesn't steal taps. */
.detail-collapse-btn-mobile{position:absolute;top:10px;left:12px;background:transparent;border:none;padding:0;cursor:pointer;color:transparent;width:28px;height:28px;z-index:3;outline:none;-webkit-tap-highlight-color:transparent;}
.detail-collapse-btn-mobile{position:absolute;top:10px;inset-inline-start:12px;background:transparent;border:none;padding:0;cursor:pointer;color:transparent;width:28px;height:28px;z-index:3;outline:none;-webkit-tap-highlight-color:transparent;}
.detail-collapse-btn-mobile:focus{outline:none;}
/* Detail-mode close X — top-right corner */
.detail-close-btn{position:absolute;top:10px;right:12px;background:none;border:none;padding:4px;cursor:pointer;color:#666;display:flex;align-items:center;justify-content:center;z-index:3;transition:color 0.2s ease;}
.detail-close-btn{position:absolute;top:10px;inset-inline-end:12px;background:none;border:none;padding:4px;cursor:pointer;color:#666;display:flex;align-items:center;justify-content:center;z-index:3;transition:color 0.2s ease;}
.detail-close-btn:hover{color:#333;}
/* The people-group name lead sits on the same line as that absolute close X
(right:12px, 26px wide): reserve its width so a long name ellipsizes
before the X instead of running underneath it. */
.legend-mobile-sheet .pg-name-lead{padding-right:32px;}
.legend-mobile-sheet .pg-name-lead{padding-inline-end:32px;}
/* Collapsed-detail footer — mimics LegendRows collapsed-state footer
(caret + centered title) but for PeopleGroupDetail mode. Renders only
@ -256,7 +256,7 @@ useShadowStyles(`
keeps its desktop look.
No backticks anywhere in this comment block — see the earlier note. */
.legend-mobile-sheet .lft-row{min-height:44px;column-gap:8px;}
.legend-mobile-sheet .lft-row-child{margin-left:0;}
.legend-mobile-sheet .lft-row-child{margin-inline-start:0;}
.legend-mobile-sheet .lft-item-inner{min-height:36px;padding:6px 8px;}
.legend-mobile-sheet .lft-name{font-size:13px;}
/* Collapsed-state row hiding lives in the unified block above (handles

View file

@ -86,7 +86,7 @@ useShadowStyles(`
/* Popover — imitates .stl-info-pop (same font, colours, radius, border). */
.lpb-pop{position:absolute;top:calc(100% + 6px);left:0;z-index:10;padding:7px 10px;background:#0d1117;border:1px solid #30363d;border-radius:6px;font:11px system-ui;color:#c9d1d9;line-height:1.45;white-space:nowrap;pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,0.5);}
.lpb-light .lpb-pop{background:#f6f8fa;border-color:#d0d7de;color:#1f2328;box-shadow:0 8px 24px rgba(31,35,40,0.18);}
.lpb-pop-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:-1px;box-shadow:0 0 0 1px rgba(0,0,0,0.4);}
.lpb-pop-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-inline-end:6px;vertical-align:-1px;box-shadow:0 0 0 1px rgba(0,0,0,0.4);}
/* Collapsed mobile sheet (LegendMobile.vue) shows only the 48px title strip — the
bar must not take a second header line there. Same-shadow-root rule, owned here
so the sheet needs no knowledge of the bar. */

View file

@ -152,7 +152,7 @@ useShadowStyles(`
not absolute); the pill spans cols 2 to -2 so the trailing column stays
empty as right padding. */
.lrg-row{display:grid;grid-template-columns:subgrid;grid-column:1 / -1;align-items:center;}
.lrg-row-child{margin-left:0;}
.lrg-row-child{margin-inline-start:0;}
/* .lrg-item and .lrg-item-inner are layout-transparent on desktop so the
subgrid stays flat. LegendMobile.vue overrides these at higher specificity

View file

@ -63,7 +63,8 @@ useShadowStyles(`
Pill-shaped wrapper, absolutely positioned ABOVE the legend card. Horizontally
centered over the legend card by anchoring the left edge to the card origin
(10px) + half the legend width, then translating back -50%. ── */
.lt-root{position:absolute;top:14px;left:calc(10px + var(--map-legend-width,340px) / 2);transform:translateX(-50%);z-index:1050;display:flex;gap:8px;align-items:center;flex-wrap:nowrap;background:transparent;pointer-events:auto;}
.lt-root{position:absolute;top:14px;inset-inline-start:calc(10px + var(--map-legend-width,340px) / 2);transform:translateX(-50%);z-index:1050;display:flex;gap:8px;align-items:center;flex-wrap:nowrap;background:transparent;pointer-events:auto;}
.lt-root:dir(rtl){transform:translateX(50%);}
@media(max-width:767px){
/* Mobile uses the bottom-sheet legend; toolbar is hidden on phones. */
.lt-root{display:none;}

View file

@ -19,10 +19,11 @@ import { tStatic } from '@map/i18n/index.js'
// This component's chrome, read out of the catalogue. tStatic and not useI18n: these mount inside
// custom elements that install no i18n plugin, and an unguarded useI18n() throws on the way in.
const T = (key, en) => tStatic(key) || en
import { useI18n } from 'vue-i18n'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useInstance } from '../../composables/usePplrInstance.js'
import LegendProgressBar from './LegendProgressBar.vue'
import ApproxPinNote from './ApproxPinNote.vue'
// .stl-export-backdrop is an invisible click-catcher ("captures the next click"), so when it is
// clipped nothing LOOKS wrong — but inside the cap-5 shell contain:layout on .vtl-content limits
// it to the content pane (measured 1024x793 @ (256,107) at 1280x900), so a click on the rail or
@ -43,9 +44,10 @@ import { useViewportScrim } from '../../utils/viewportScrim.js'
// falls through to its own default — which is what the defaultTab and columnLabel paths expect.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let te = () => false
try { const i18n = useI18n(); t = i18n.t; te = i18n.te } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback -- NOT the
// catalogue -- so a bare host still reads 'Name' for legend.columns.name, and `te` still answers false
// so defaultTab and columnLabel keep falling through to their own defaults.
const { t, te } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded
@ -78,6 +80,10 @@ function tabInfo(tab) {
const props = defineProps({
nodes: { type: Array, default: () => [] },
// APPROXPIN standing note (the agent card BUILD-APPROXIMATE-PIN-NOTICE: every map that shows people-group
// pins says plainly that a dot is a locality). null = decide from the map's own data: shown when the
// injected dataStore holds named Point features. true/false overrides, for a leaf that already says it.
pinNote: { type: Boolean, default: null },
tabs: { type: Array, default: null },
// WHICH GENERATION THE LEGEND OPENS ON. Index into `tabs`; 0 (the top of the
// tree) is the default, which is what every existing profile already gets.
@ -119,6 +125,17 @@ const progressData = computed(() => props.progress ?? unref(injectedProgress) ??
// on the desktop title bar, the mobile sheet title and the collapsed reopen pill.
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark')
const pinDataStore = inject('dataStore', null)
const showPinNote = computed(() => {
if (props.pinNote !== null) return props.pinNote
const sources = pinDataStore?.sources || {}
for (const id in sources) {
const f = sources[id]?.features?.[0]
const p = f?.properties || {}
if (f?.geometry?.type === 'Point' && (p.name || p.peopleName || p.PeopNameInCountry)) return true
}
return false
})
// A click on a bar span selects the matching legend row through the SAME handler
// the row uses (selectNode → emit('select') → the profile's filter/camera path).
function findNodeById(nodes, id) {
@ -616,13 +633,17 @@ useShadowStyles(`
rises into the vacated space instead of keeping a dead gap (the agent, 2026-09-23, UPG-100:
"the search bar is removed, that means the legend needs to be moved up"). The max-height
mirrors it so a raised panel gains the same room at the bottom it gained at the top. */
position: absolute; left: 8px; top: var(--stl-top, 60px); width: var(--map-legend-width, 380px); z-index: 4;
position: absolute; inset-inline-start: 8px; top: var(--stl-top, 60px); width: var(--map-legend-width, 380px); z-index: 4;
display: flex; flex-direction: column;
max-height: calc(100% - var(--stl-top, 60px) - 12px);
transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.2s;
pointer-events: auto;
}
.stl-panel.closed { transform: translateX(-100%); opacity: 0; pointer-events: none; }
/* RTL: the panel sits at the inline START (the right), so it slides off to the right and its collapse
arrow points the other way. Additive :dir(rtl) overrides, as in VerticalTabsLayout. */
.stl-panel.closed:dir(rtl) { transform: translateX(100%); }
.stl-collapse-btn:dir(rtl) svg { transform: rotate(0deg); }
/* Derived from --stl-top for the same reason as the base rule above: a fixed 72px here overflowed
whenever the profile's real offset was larger (UPG-100 sets 58px + a 12px bottom margin = 70px of
required clearance, against 72px only by luck; at --stl-top:8px it wasted 64px of panel height). */
@ -831,31 +852,31 @@ useShadowStyles(`
.stl-trunk { position: relative; flex-shrink: 0; width: 12px; align-self: stretch; }
.stl-trunk-pipe::before {
content: ''; position: absolute;
left: 5px; top: 0; bottom: 0; width: 1px;
inset-inline-start: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::before {
content: ''; position: absolute;
left: 5px; top: 0; bottom: 0; width: 1px;
inset-inline-start: 5px; top: 0; bottom: 0; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-tee::after {
content: ''; position: absolute;
left: 5px; top: 50%; height: 1px; width: 7px;
inset-inline-start: 5px; top: 50%; height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
.stl-trunk-elbow::before {
content: ''; position: absolute;
left: 5px; top: 0; height: 50%; width: 1px;
inset-inline-start: 5px; top: 0; height: 50%; width: 1px;
background: var(--trunk-color, #6e7681);
opacity: 0.45;
}
.stl-trunk-elbow::after {
content: ''; position: absolute;
left: 5px; top: calc(50% - 0.5px); height: 1px; width: 7px;
inset-inline-start: 5px; top: calc(50% - 0.5px); height: 1px; width: 7px;
background: var(--trunk-color, #6e7681);
opacity: 0.55;
}
@ -918,7 +939,7 @@ useShadowStyles(`
.stl-collapse-btn:hover { color: #c9d1d9; background: rgba(59,70,61,0.18); border-color: #73A17F; }
.stl-collapse-btn svg { transition: transform 0.18s; transform: rotate(180deg); }
.stl-reopen {
position: absolute; left: 8px; top: 60px; z-index: 3;
position: absolute; inset-inline-start: 8px; top: 60px; z-index: 3;
display: inline-flex; align-items: center; gap: 8px;
background: #0d1117; border: 1px solid #30363d;
border-radius: 999px;
@ -1032,7 +1053,7 @@ useShadowStyles(`
/* ── Export affordance (top-right of titlebar) ──────────────────────────────
margin-left:auto pins it to the right edge whether the titlebar shows the
plain title (flex-shrink:0) or the flex:1 breadcrumb button. */
.stl-export { margin-left: auto; position: relative; flex-shrink: 0; }
.stl-export { margin-inline-start: auto; position: relative; flex-shrink: 0; }
.stl-export-btn {
display: inline-flex; align-items: center; gap: 5px;
background: rgba(110,118,129,0.15); border: 1px solid #30363d; border-radius: 6px;
@ -1048,7 +1069,7 @@ useShadowStyles(`
/* Backdrop sits below the menu, above the panel — captures the next click. */
.stl-export-backdrop { position: fixed; inset: 0; z-index: 9; }
.stl-export-menu {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 10;
position: absolute; inset-inline-end: 0; top: calc(100% + 6px); z-index: 10;
min-width: 168px;
display: flex; flex-direction: column;
background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
@ -1323,6 +1344,7 @@ useShadowStyles(`
<span class="stl-overflow-hint">{{ T('legend.useGeocoderHint', 'use the geocoder above to find the rest') }}</span>
</div>
</div>
<ApproxPinNote v-if="showPinNote" :dark="stlIsDark" />
</div>
</div>

View file

@ -47,7 +47,7 @@ import { tStatic } from '../../i18n/index.js'
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { useI18n } from 'vue-i18n'
import { useOptionalI18n } from '@map/composables/useOptionalT.js'
// Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell
// (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable
// behind the "modal". See library/utils/viewportScrim.js for the measurements and rejected fixes.
@ -385,9 +385,9 @@ async function exportPoster(item) {
// key shown to a reader.
const humanizeKey = (k) => String(k).split('.').pop()
.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase())
let t = (k) => humanizeKey(k)
let wallLocale = 'en'
try { const i18n = useI18n(); t = i18n.t; wallLocale = i18n.locale } catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback and the
// bare-host locale stays 'en', so nothing this wall prints changes.
const { t, locale: wallLocale } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
// ⚠ ONE exportState, not two. The composable owns a ref of its own; if the HUD
// keeps rendering the wall's while the print actions write to the composable's, a
// card-deck print shows NO progress and its errors vanish silently — the mechanism

View file

@ -24,7 +24,7 @@
* filter-clear { name } — the selection was cleared.
*/
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const props = defineProps({
label: { type: String, required: true },
@ -45,8 +45,9 @@ const props = defineProps({
// in the studio and inside every composite that nests it (measured 2026-09-25: 2 DOM nodes, page
// error on mount). Use the host's translations when it has them, English otherwise.
const FALLBACK = { 'buttons.clearSelection': 'Clear selection', 'buttons.noOptions': 'No options found' }
let t = (key) => FALLBACK[key] || key
try { t = useI18n().t } catch (_) { /* no i18n on this host: keep the English fallback */ }
// C4: the guard is library/composables/useOptionalT.js now; FALLBACK is passed in unchanged, so the
// grid that this component once blanked renders exactly the same strings.
const t = useOptionalT('', { fallback: (key) => FALLBACK[key] })
const emit = defineEmits(['filter-change', 'filter-clear'])

View file

@ -0,0 +1,109 @@
/**
* ★ NO `<area>.<key>`-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such
* literal as a real catalogue key and reports it missing; studio had to reword mine twice because a
* file copy restored them. Describe keys in words here, never in the shape of one.
*
* useOptionalT(namespace) — one guarded translator, for the twenty places that each stored the same
* fact: HOW TO TRANSLATE WHEN THE HOST NEVER INSTALLED vue-i18n.
*
* `useI18n()` THROWS ("Need to install with `app.use` function") on a host without the plugin, and
* seven bundles here install none — big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check,
* people-groups-grid, plugin-studio, stack-demo. An unguarded call in shared code is a blank subtree
* waiting for the first bare host: that is exactly how library FilterDropdown blanked
* people-groups-grid, a production plugin.
*
* ★ WHY THIS DOES NOT TAKE A FALLBACK CATALOGUE, though the curation spec specified
* `(namespace, fallbackCatalogue)`. Every existing copy imports its own English JSON
* (its own per-area English JSON) and hand-walks it after stripping the
* namespace prefix. That is REDUNDANT: `messages` in library/i18n/index.js is keyed
* `messages[locale][area]` where `area` is the JSON's own filename, so tStatic given a fully-qualified key already
* walks the same tree AND already falls back to English (`tryTree(want) ?? tryTree('en')`). The
* second import was a second copy of the same bytes.
*
* ★ AND IT IS NOT A BUNDLE-SIZE REGRESSION, which was the reason to suspect the narrow import existed.
* The build NARROWS library/i18n's catalogue glob per bundle to the areas `internal/i18n-areas.mjs`
* finds in that bundle's source graph (see check:i18n-treeshake). A bundle that references the pplr area gets the
* pplr area and not the other thirty. Verified before writing this, because "the obvious dedup makes
* every bundle carry every locale" would have been a real objection if the glob were still wide.
*
* Order of resolution, and each step earns its place:
* 1. the host's own vue-i18n, when it installed one — so a host that manages locale keeps control;
* 2. `tStatic`, which honours the runtime/page locale and falls back to English;
* 3. the key itself, so a missing string is visible as a key rather than as a blank.
* Step 2 also covers the case i18n IS installed but has no entry: `i18n.t(key)` returns the key
* unchanged, which every copy of this pattern already treated as "not found".
*
* @param {string} namespace the catalogue area name (a bare area, no dotted key) — used only to prefix bare keys
* @returns {(key: string, params?: object) => string}
*/
import { useI18n } from 'vue-i18n'
import { tStatic } from '@map/i18n/index.js'
/** `{n}` interpolation. Exported so a gate can test it without a Vue setup scope. */
export function fillParams(str, params) {
if (!params) return String(str)
// ★ `!= null`, NOT `k in params`. Gospel's interpolate() had it right and my first version did not:
// with { count: null } a key-presence test renders the STRING "null" into the sentence, while this
// leaves `{count}` visible. A visible placeholder is a bug you can see and fix; "null" reads as
// content. Found by comparing against charts.vue before migrating it, not after.
return String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
}
/** Prefix a bare key with its area, so callers may pass a bare key or an already-qualified one. */
export function qualify(namespace, key) {
const k = String(key)
return namespace && !k.startsWith(`${namespace}.`) ? `${namespace}.${k}` : k
}
/**
* ★ `fallback` EXISTS BECAUSE THE FIVE LIBRARY COMPONENTS DO NOT ALL FALL BACK TO THE CATALOGUE, and
* swapping what they fall back to is a PRODUCT change, not a consolidation. Measured:
* ApproxPinNote / FilterDropdown a literal fallback MAP
* SemanticTreeLegend / PosterWall humanizeKey() — a dotted legend column key renders as its last segment, title-cased
* PinHint a one-key fallback map
* Routing those through tStatic would change what seven bare-host bundles render (and, because tStatic
* honours the runtime locale, would start translating strings that are English today). That may well be
* an improvement, but it is a separate decision from "store this fact once", so each site passes the
* fallback it already had and renders byte-identically.
* @param {(key:string, params?:object)=>string} [opts.fallback] used instead of tStatic when the host
* has no i18n, or when its catalogue lacks the key. Omit it to get the catalogue (the default).
*/
export function useOptionalT(namespace, opts = {}) {
const viaFallback = typeof opts.fallback === 'function'
? (key, params) => fillParams(opts.fallback(qualify(namespace, key), params) ?? key, params)
: (key, params) => fillParams(tStatic(qualify(namespace, key)) ?? key, params)
try {
const i18n = useI18n()
return (key, params) => {
const q = qualify(namespace, key)
const v = i18n.t(q, params || {})
return v === q ? viaFallback(key, params) : v
}
} catch (_) {
// No i18n on this host. Not an error: seven bundles deliberately install none.
return viaFallback
}
}
/**
* The same guard, for callers that need more than `t`.
*
* ★ `te` KEEPS ITS EXISTING CONTRACT DELIBERATELY: on a host with no i18n it answers FALSE, never
* "true because the catalogue has it". PinHint and SemanticTreeLegend both record why — "te becomes a
* 'no, I don't have it' so every caller falls through to its own default". Making it consult tStatic
* would silently take those callers off their own defaults, which is the same class of mistake as
* moving a fallback.
*
* @returns {{ t: Function, te: (key:string)=>boolean, locale: any }}
*/
export function useOptionalI18n(namespace, opts = {}) {
const t = useOptionalT(namespace, opts)
try {
const i18n = useI18n()
return { t, te: (key) => i18n.te(qualify(namespace, key)), locale: i18n.locale }
} catch (_) {
return { t, te: () => false, locale: opts.locale ?? 'en' }
}
}
export default useOptionalT

View file

@ -76,5 +76,6 @@
"people_committed": "الأشخاص الملتزمون",
"people_praying": "الأشخاص المصلّون",
"selected_first": "المحدَّدة أولاً",
"selected_first_hint": "رفع الشعوب المحدَّدة إلى الأعلى مع الإبقاء على ترتيب الأعمدة"
"selected_first_hint": "رفع الشعوب المحدَّدة إلى الأعلى مع الإبقاء على ترتيب الأعمدة",
"n_shown": "{n} معروض"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "«لا شيء» تعني غير موجود في المصادر التي بحثنا فيها — وليست دليلًا على عدم وجوده.",
"tapCountry": "اضغط على بلد لترى الموارد الفعلية، شعبًا شعبًا.",
"howNumbersMade": "كيف تُحسب هذه الأرقام",
"isSignLanguage": "هذه لغة إشارة."
"isSignLanguage": "هذه لغة إشارة.",
"wayInSome": "{n} من أصل {total}",
"wayInAll": "كل واحدة من هذه الـ {n}",
"wayInWhat": "لديها مدخل — لغة جسرية، أو SUN لمجموعة صُمّ. اضغط على واحدة لرؤيته."
},
"tables": {
"title": "موارد الإنجيل — البيانات",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "المسيحية",
"Islam": "الإسلام",
"Hinduism": "الهندوسية",
"Buddhism": "البوذية",
"Ethnoreligion": "الديانات العرقية",
"Judaism": "اليهودية",
"Sikhism": "السيخية",
"Unaffiliated": "غير منتمين",
"Other": "أخرى",
"Unknown": "غير معروف"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Verbindlich zugesagte Personen",
"people_praying": "Betende Personen",
"selected_first": "Ausgewählte zuerst",
"selected_first_hint": "Die ausgewählten Volksgruppen nach oben holen, die Spaltensortierung bleibt erhalten"
"selected_first_hint": "Die ausgewählten Volksgruppen nach oben holen, die Spaltensortierung bleibt erhalten",
"n_shown": "{n} angezeigt"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "„Nichts gefunden“ heißt: nicht in den geprüften Quellen — nie ein Beweis, dass es nichts gibt.",
"tapCountry": "Tippen Sie auf ein Land, um die Ressourcen Gruppe für Gruppe zu sehen.",
"howNumbersMade": "Wie diese Zahlen entstehen",
"isSignLanguage": "Dies ist eine Gebärdensprache."
"isSignLanguage": "Dies ist eine Gebärdensprache.",
"wayInSome": "{n} von diesen {total}",
"wayInAll": "Jede dieser {n}",
"wayInWhat": "hat einen Zugang – eine Brückensprache oder SUN für eine gehörlose Gruppe. Tippe auf eine, um ihn zu sehen."
},
"tables": {
"title": "Evangeliums-Ressourcen — Daten",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Christentum",
"Islam": "Islam",
"Hinduism": "Hinduismus",
"Buddhism": "Buddhismus",
"Ethnoreligion": "Ethnische Religionen",
"Judaism": "Judentum",
"Sikhism": "Sikhismus",
"Unaffiliated": "Konfessionslos",
"Other": "Andere",
"Unknown": "Unbekannt"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "People committed",
"people_praying": "People praying",
"selected_first": "Selected first",
"selected_first_hint": "Float the selected people groups to the top, keeping your column sort"
"selected_first_hint": "Float the selected people groups to the top, keeping your column sort",
"n_shown": "{n} shown"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "“None found” means not present in the pools this build checked — never proof that nothing exists.",
"tapCountry": "Tap a country to see the actual resources, group by group.",
"howNumbersMade": "How these numbers are made",
"isSignLanguage": "This is a sign language."
"isSignLanguage": "This is a sign language.",
"wayInSome": "{n} of those {total}",
"wayInAll": "Every one of those {n}",
"wayInWhat": "has a way in — a bridge language, or SUN for a Deaf group. Tap one to see it."
},
"tables": {
"title": "Gospel Resources — Data",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Christianity",
"Islam": "Islam",
"Hinduism": "Hinduism",
"Buddhism": "Buddhism",
"Ethnoreligion": "Ethnoreligion",
"Judaism": "Judaism",
"Sikhism": "Sikhism",
"Unaffiliated": "Unaffiliated",
"Other": "Other",
"Unknown": "Unknown"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Personas comprometidas",
"people_praying": "Personas que oran",
"selected_first": "Los seleccionados primero",
"selected_first_hint": "Llevar arriba los grupos de personas seleccionados, manteniendo el orden de las columnas"
"selected_first_hint": "Llevar arriba los grupos de personas seleccionados, manteniendo el orden de las columnas",
"n_shown": "{n} mostrados"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "«Nada encontrado» significa ausente de las fuentes consultadas, nunca prueba de que no exista.",
"tapCountry": "Toca un país para ver los recursos, pueblo por pueblo.",
"howNumbersMade": "Cómo se obtienen estas cifras",
"isSignLanguage": "Esta es una lengua de señas."
"isSignLanguage": "Esta es una lengua de señas.",
"wayInSome": "{n} de esos {total}",
"wayInAll": "Cada uno de esos {n}",
"wayInWhat": "tiene una vía de acceso: una lengua puente, o SUN para un grupo sordo. Toca uno para verla."
},
"tables": {
"title": "Recursos del evangelio — Datos",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Cristianismo",
"Islam": "Islam",
"Hinduism": "Hinduismo",
"Buddhism": "Budismo",
"Ethnoreligion": "Etnorreligión",
"Judaism": "Judaísmo",
"Sikhism": "Sijismo",
"Unaffiliated": "Sin afiliación",
"Other": "Otra",
"Unknown": "Desconocida"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Personnes engagées",
"people_praying": "Personnes qui prient",
"selected_first": "La sélection en premier",
"selected_first_hint": "Faire remonter les groupes de personnes sélectionnés, en conservant le tri des colonnes"
"selected_first_hint": "Faire remonter les groupes de personnes sélectionnés, en conservant le tri des colonnes",
"n_shown": "{n} affichés"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "« Rien trouvé » signifie absent des sources consultées, jamais la preuve que rien n'existe.",
"tapCountry": "Touchez un pays pour voir les ressources, peuple par peuple.",
"howNumbersMade": "Comment ces chiffres sont établis",
"isSignLanguage": "Il s’agit d’une langue des signes."
"isSignLanguage": "Il s’agit d’une langue des signes.",
"wayInSome": "{n} sur ces {total}",
"wayInAll": "Chacun de ces {n}",
"wayInWhat": "a une porte d’entrée — une langue passerelle, ou SUN pour un groupe sourd. Touchez-en un pour la voir."
},
"tables": {
"title": "Ressources de l'Évangile — Données",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Christianisme",
"Islam": "Islam",
"Hinduism": "Hindouisme",
"Buddhism": "Bouddhisme",
"Ethnoreligion": "Ethnoreligion",
"Judaism": "Judaïsme",
"Sikhism": "Sikhisme",
"Unaffiliated": "Sans appartenance",
"Other": "Autre",
"Unknown": "Inconnue"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "प्रतिबद्ध लोग",
"people_praying": "प्रार्थना करने वाले लोग",
"selected_first": "चयनित पहले",
"selected_first_hint": "चयनित जनसमूहों को ऊपर लाएँ, आपका कॉलम क्रम बनाए रखते हुए"
"selected_first_hint": "चयनित जनसमूहों को ऊपर लाएँ, आपका कॉलम क्रम बनाए रखते हुए",
"n_shown": "{n} दिखाए गए"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "“कुछ नहीं मिला” का अर्थ है जाँचे गए स्रोतों में नहीं — यह प्रमाण नहीं कि कुछ है ही नहीं।",
"tapCountry": "संसाधन देखने के लिए किसी देश पर टैप करें, जनसमूह दर जनसमूह।",
"howNumbersMade": "ये आँकड़े कैसे बनते हैं",
"isSignLanguage": "यह एक सांकेतिक भाषा है।"
"isSignLanguage": "यह एक सांकेतिक भाषा है।",
"wayInSome": "उन {total} में से {n}",
"wayInAll": "उन {n} में से हर एक",
"wayInWhat": "के पास एक रास्ता है — एक सेतु भाषा, या बधिर समूह के लिए SUN। देखने के लिए किसी एक पर टैप करें।"
},
"tables": {
"title": "सुसमाचार संसाधन — डेटा",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "ईसाई धर्म",
"Islam": "इस्लाम",
"Hinduism": "हिंदू धर्म",
"Buddhism": "बौद्ध धर्म",
"Ethnoreligion": "जातीय धर्म",
"Judaism": "यहूदी धर्म",
"Sikhism": "सिख धर्म",
"Unaffiliated": "असंबद्ध",
"Other": "अन्य",
"Unknown": "अज्ञात"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Persone impegnate",
"people_praying": "Persone che pregano",
"selected_first": "Prima i selezionati",
"selected_first_hint": "Porta in alto i gruppi di persone selezionati, mantenendo l’ordinamento delle colonne"
"selected_first_hint": "Porta in alto i gruppi di persone selezionati, mantenendo l’ordinamento delle colonne",
"n_shown": "{n} mostrati"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "«Nulla trovato» significa assente dalle fonti controllate, mai la prova che non esista.",
"tapCountry": "Tocca un paese per vedere le risorse, popolo per popolo.",
"howNumbersMade": "Come si ottengono questi numeri",
"isSignLanguage": "Questa è una lingua dei segni."
"isSignLanguage": "Questa è una lingua dei segni.",
"wayInSome": "{n} di quei {total}",
"wayInAll": "Ognuno di quei {n}",
"wayInWhat": "ha una via d’accesso: una lingua ponte, o SUN per un gruppo sordo. Tocca uno per vederla."
},
"tables": {
"title": "Risorse del Vangelo — Dati",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Cristianesimo",
"Islam": "Islam",
"Hinduism": "Induismo",
"Buddhism": "Buddhismo",
"Ethnoreligion": "Etnoreligione",
"Judaism": "Ebraismo",
"Sikhism": "Sikhismo",
"Unaffiliated": "Senza affiliazione",
"Other": "Altro",
"Unknown": "Sconosciuta"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Pessoas comprometidas",
"people_praying": "Pessoas a orar",
"selected_first": "Os selecionados primeiro",
"selected_first_hint": "Trazer para cima os grupos de pessoas selecionados, mantendo a ordenação das colunas"
"selected_first_hint": "Trazer para cima os grupos de pessoas selecionados, mantendo a ordenação das colunas",
"n_shown": "{n} apresentados"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "«Nada encontrado» significa ausente das fontes verificadas, nunca prova de que não exista.",
"tapCountry": "Toque num país para ver os recursos, povo a povo.",
"howNumbersMade": "Como estes números são obtidos",
"isSignLanguage": "Esta é uma língua de sinais."
"isSignLanguage": "Esta é uma língua de sinais.",
"wayInSome": "{n} desses {total}",
"wayInAll": "Cada um desses {n}",
"wayInWhat": "tem uma porta de entrada — uma língua-ponte, ou SUN para um grupo surdo. Toque num para ver."
},
"tables": {
"title": "Recursos do evangelho — Dados",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Cristianismo",
"Islam": "Islão",
"Hinduism": "Hinduísmo",
"Buddhism": "Budismo",
"Ethnoreligion": "Etnorreligião",
"Judaism": "Judaísmo",
"Sikhism": "Siquismo",
"Unaffiliated": "Sem afiliação",
"Other": "Outra",
"Unknown": "Desconhecida"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Persoane angajate",
"people_praying": "Persoane care se roagă",
"selected_first": "Cele selectate primele",
"selected_first_hint": "Ridică grupurile de oameni selectate în partea de sus, păstrând sortarea coloanelor"
"selected_first_hint": "Ridică grupurile de oameni selectate în partea de sus, păstrând sortarea coloanelor",
"n_shown": "{n} afișate"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "„Nimic găsit” înseamnă absent din sursele verificate, niciodată dovada că nu există.",
"tapCountry": "Atinge o țară pentru a vedea resursele, popor cu popor.",
"howNumbersMade": "Cum se obțin aceste cifre",
"isSignLanguage": "Aceasta este o limbă a semnelor."
"isSignLanguage": "Aceasta este o limbă a semnelor.",
"wayInSome": "{n} din cele {total}",
"wayInAll": "Fiecare dintre cele {n}",
"wayInWhat": "are o cale de acces — o limbă-punte sau SUN pentru un grup de surzi. Atinge unul pentru a o vedea."
},
"tables": {
"title": "Resurse ale Evangheliei — Date",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Creștinism",
"Islam": "Islam",
"Hinduism": "Hinduism",
"Buddhism": "Budism",
"Ethnoreligion": "Etnoreligie",
"Judaism": "Iudaism",
"Sikhism": "Sikhism",
"Unaffiliated": "Fără afiliere",
"Other": "Altele",
"Unknown": "Necunoscută"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "Взявшие обязательство",
"people_praying": "Молящиеся",
"selected_first": "Выбранные сначала",
"selected_first_hint": "Поднять выбранные народы наверх, сохранив сортировку столбцов"
"selected_first_hint": "Поднять выбранные народы наверх, сохранив сортировку столбцов",
"n_shown": "{n} показано"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "«Ничего не найдено» значит: нет в проверенных источниках — но не доказательство отсутствия.",
"tapCountry": "Нажмите на страну, чтобы увидеть ресурсы по каждому народу.",
"howNumbersMade": "Как получены эти цифры",
"isSignLanguage": "Это жестовый язык."
"isSignLanguage": "Это жестовый язык.",
"wayInSome": "{n} из этих {total}",
"wayInAll": "Каждая из этих {n}",
"wayInWhat": "имеет путь — язык-посредник или SUN для группы глухих. Нажмите на одну, чтобы увидеть его."
},
"tables": {
"title": "Ресурсы Евангелия — данные",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "Христианство",
"Islam": "Ислам",
"Hinduism": "Индуизм",
"Buddhism": "Буддизм",
"Ethnoreligion": "Этнорелигия",
"Judaism": "Иудаизм",
"Sikhism": "Сикхизм",
"Unaffiliated": "Не принадлежат",
"Other": "Другое",
"Unknown": "Неизвестно"
}
}

View file

@ -76,5 +76,6 @@
"people_committed": "已承诺的人数",
"people_praying": "祷告的人数",
"selected_first": "所选优先",
"selected_first_hint": "将所选民族置顶,同时保留您的列排序"
"selected_first_hint": "将所选民族置顶,同时保留您的列排序",
"n_shown": "显示 {n} 个"
}

View file

@ -107,7 +107,10 @@
"noneFoundShort": "“一无所获”指未见于本次查询的资源库,并不证明其不存在。",
"tapCountry": "点按国家,逐个族群查看实际资源。",
"howNumbersMade": "这些数字是如何得出的",
"isSignLanguage": "这是一种手语。"
"isSignLanguage": "这是一种手语。",
"wayInSome": "这 {total} 个中有 {n} 个",
"wayInAll": "这 {n} 个中的每一个",
"wayInWhat": "都有切入点——一种桥梁语言,或聋人群体的 SUN。点按其中一个即可查看。"
},
"tables": {
"title": "福音资源 — 数据",

View file

@ -0,0 +1,14 @@
{
"family": {
"Christianity": "基督教",
"Islam": "伊斯兰教",
"Hinduism": "印度教",
"Buddhism": "佛教",
"Ethnoreligion": "民族宗教",
"Judaism": "犹太教",
"Sikhism": "锡克教",
"Unaffiliated": "无宗教归属",
"Other": "其他",
"Unknown": "未知"
}
}

View file

@ -19,24 +19,48 @@ export function pointOf(r) {
}
const nameOf = (r) => { const p = (r && (r.properties || r)) || {}; return p.name || p.peopleName || p.PeopNameInCountry || '' }
/** Build once per roster: Map<coordinate key, names[]>. */
/** Build once per roster: Map<coordinate key, names[]>, plus `byName` (name -> [[lat, lng, key]]). */
export function buildColocationIndex(rows) {
const idx = new Map()
const byName = new Map()
for (const r of rows || []) {
const pt = pointOf(r); const n = nameOf(r)
if (!pt || !n) continue
const k = KEY(pt[0], pt[1])
if (!idx.has(k)) idx.set(k, [])
idx.get(k).push(n)
if (!byName.has(n)) byName.set(n, [])
byName.get(n).push([pt[0], pt[1], k])
}
idx.byName = byName
return idx
}
/**
* The roster key for a selected pin. A click hands over the RENDERED feature, whose geometry the engine
* rebuilt from tile coordinates, so it is close to the roster's point but not equal to it at six decimals
* (rega, row 46: a four-group point never matched). So the pin is resolved to its ROSTER ROW by name, and
* when a name repeats (the same people in several countries) to the nearest of those rows. Only a name the
* roster does not know falls back to the pin's own coordinate.
*/
function keyFor(idx, row) {
const pt = pointOf(row)
const cands = idx.byName && idx.byName.get(nameOf(row))
if (cands && cands.length) {
if (cands.length === 1 || !pt) return cands[0][2]
let best = cands[0], d = Infinity
for (const c of cands) { const dd = (c[0] - pt[0]) ** 2 + (c[1] - pt[1]) ** 2; if (dd < d) { d = dd; best = c } }
return best[2]
}
return pt ? KEY(pt[0], pt[1]) : null
}
/** The OTHER groups on this row's point ([] when the point is not shared). */
export function colocatedWith(idx, row) {
const pt = pointOf(row); if (!idx || !pt) return []
if (!idx) return []
const k = keyFor(idx, row); if (!k) return []
const me = nameOf(row)
const all = idx.get(KEY(pt[0], pt[1])) || []
const all = idx.get(k) || []
const others = all.filter((n) => n !== me)
return all.length > 1 ? [...new Set(others)] : []
}

View file

@ -115,6 +115,9 @@
"check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs",
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs",
"check:legend-logical-insets": "node internal/check-legend-logical-insets.mjs",
"check:legend-logical-insets:self-test": "node internal/check-legend-logical-insets.mjs --self-test",
"check:legend-mirrors": "node plugin-designs/research-dashboard/scripts/check-legend-mirrors.mjs",
"check:legend-source": "node internal/check-legend-matches-source.mjs",
"check:library-index": "node internal/check-library-index.mjs",
"check:link-rot": "node internal/check-link-rot-sample.mjs",
@ -152,6 +155,7 @@
"check:no-silent-engine:self-test": "node internal/check-no-silent-engine-substitution.mjs --self-test",
"check:one-palette": "node internal/check-one-palette.mjs",
"check:one-palette:self-test": "node internal/check-one-palette.mjs --self-test",
"check:optional-t-equivalence": "node internal/check-optional-t-equivalence.mjs",
"check:packet-browser": "node internal/check-packet-browser.mjs",
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check",
"check:packet-render": "node internal/check-packet-render.mjs",
@ -221,7 +225,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed 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",
"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: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",
"library-index": "node internal/generate-library-index.mjs",
@ -332,6 +336,9 @@
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
"check:legend-logical-insets": "the legend components position themselves with LOGICAL insets (inset-inline-start/end, padding-/margin-inline-*). A physical left/right does not mirror under dir=\"rtl\" while the map controls (inset-inline-end) do, so in Arabic the controls overlapped a legend that stayed left, on Simple Research, Prayer Progress and UPG-100 at once. Symmetric rules (left:50%, left:0 with right:0), auto resets and a short ALLOWED list with reasons pass.",
"check:legend-logical-insets:self-test": "poisons .stl-panel back to left:8px and asserts check:legend-logical-insets notices.",
"check:legend-mirrors": "the research-dashboard legends must MIRROR under RTL, and their labels must never become their values. rega's Arabic survey found them on the wrong edge with 70 of 85 letters Latin. Two faults that look like one and fail in opposite directions if fixed carelessly. (1) A physical `right:8px` pins a box to the physical edge in every locale; the logical property plus the auto on the other side is the fix, the pair being what cancels a physical inset inherited from a control container. Asserted on the map legend and on research-map's geocoder column, which exists only to share an edge with the legend beside it. (2) The English was NOT chrome: the religion donut's legend renders a family name DERIVED in English by library/colors/religion.js, the same table people-groups-grid re-derives per row so both ends byte-match. Translating those VALUES would work in English and match nothing in Arabic, silently, because an empty intersection reads as 'no results'. So it asserts the visible text is projected AND that no projection reached a wire: every :data-key/:data-facet binding in the file, not just the legend's, after a mutation test aimed at one silently hit another. No browser.",
"check:legend-source": "the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails.",
"check:library-index": "the DERIVED library menu must not DRIFT from library/. REGENERATES the index into a throwaway tree (the generator, unmodified, over a symlinked library/) and diffs the BYTES — it used to compare mtimes, so `touch` or a rebase turned it red on a byte-identical doc. Fix: bun run library-index",
"check:link-rot": "re-tests a stratified sample of links we already VERIFIED, because nothing else ever re-tests one: a publisher can retire a page and the map keeps offering it, still labelled 'link checked'. THREE BANDS, each measured rather than assumed, each with its controls in band every run: plain STATUS where a status is evidence (jesusfilm.org among them — it does 404 on the /watch/jesus/<slug> shape the map ships, and the old exclusion was inherited from the retired ?languageId= URL); API-MAPPED for live.bible.is, whose page answers 200 for any fileset while /api/bibles/<id> answers 404; and REDIRECT-LANDING for fivefish.org, where a published code lands on its numbered language page and an unknown one is swept to /regions. If a control misbehaves it reports PROVED NOTHING rather than a pass. Only knowgod.com is left out — byte-identical page for any URL; the verifier proves those through Cru's API. ~3 minutes, network.",
@ -357,6 +364,7 @@
"check:no-eval": "no built bundle contains new Function or eval (a strict CSP without 'unsafe-eval' must work), and vue-i18n in JIT mode renders real catalogue messages in en and ar with eval blocked. Headless.",
"check:no-free-identifiers": "every name a .vue script uses is imported, declared, a parameter or a known global (scope-aware, across plugin-designs and library). A free identifier compiles and then throws at runtime in exactly the path nobody clicked.",
"check:no-silent-engine": "A profile must NEVER fall back to a different map engine than its provider asked for. `engineGlobal(provider) || window.mapboxgl` looks defensive and is not: engineGlobal already returns the right global for the requested provider (useMapInstance.js:115 falls back to mapbox's NAME only for an UNKNOWN provider, never a missing engine), so the || fires in exactly one case — the page asked for maplibre and maplibre failed to load — and hands back the TOKEN-GATED engine to a page that asked for the KEYLESS one. Worse, accessToken is gated on !isMaplibre, so the substitute arrives WITHOUT a token: 401s on every tile instead of a legible failure. Found by regions' C3 read 2026-09-26; 2 offenders remained (cities-internet, gospel-resources) after my-upg-100-list was fixed. NOT a duplicate of check:leaf-engine-per-provider / check:provider-switch / check:auto-engine — all three assert the HAPPY path (what loads) and this bug only appears when something does NOT load, so all three were green while it existed. A SOURCE check on purpose: reproducing it live means serving a leaf with MapLibre deliberately blocked, spending a browser slot on what a grep decides. Exits 75 when no file calls engineGlobal — 'found nothing' is not 'found nothing wrong'. Run :self-test to prove the pattern still matches.",
"check:optional-t-equivalence": "C4's shared guarded translator (library/composables/useOptionalT.js) must behave like the twenty hand-written try{useI18n()}catch blocks it replaces: all three host states, and the per-site register asserts each migrated file still passes ITS OWN fallback rather than inheriting the catalogue, because routing them through tStatic would change what seven bare-host bundles render. EVERY LATER C4 WAVE APPENDS A ROW. Extract-and-executes the module source, since useOptionalT imports vue-i18n and an i18n index that calls import.meta.glob.",
"check:packet-browser": "the packet the READER gets — the legend's Export > Prayer packet path, proving the shared renderer runs in a BROWSER. Asserts the same 12 points / 12 wheel labels / page-1 geography / print layer as check:packet-render, which it did not until 2026-09-24. CONDITIONAL: SKIPs without a server over the repo root (python3 -m http.server 4898) and a reachable records API.",
"check:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).",
"check:packet-render:expect-funnel": "check:packet-render with the page-2 FUNNEL declared REQUIRED. Exists because of a real near-miss: a merge resolved with `git checkout --ours` deleted the printPacket call site that supplies the funnel data (60 lines) and EVERY GATE STAYED GREEN — it had to, because the funnel page is OPTIONAL by design (buildPacket omits it when the arg is null), so a packet without page 2 is not malformed, just smaller. Always requiring it would be the wrong fix: the 11 pre-funnel laos packets and any host with no funnel data would go red, and a false red costs as much as a false green. So the CALLER, which knows whether this packet was built with funnel data, declares it — an absence is then either declared (no flag) or a failure (flag passed, page missing), never a silent nothing. Pass it for any packet from a profile that wires packetFunnel(). Browser gate.",

View file

@ -112,7 +112,23 @@ watch(textDir, reflectDir)
// The host's people-group LIST (library/lists/), handed to both children as the only key of their
// profile-config, so nothing else about them changes. Absent = the default list.
const listConfig = computed(() => (config.value.list ? JSON.stringify({ list: config.value.list }) : ''))
// ★ THE LOCALE TRAVELS WITH THE LIST (rega, row 47: big-picture rendered 31 Arabic text nodes
// through profile-config against 79 through ?lang=, and check:host-locale-contract measured 402
// against 888). This handed each child ONLY the list, so the one documented channel a partner has
// for asking a nested element to speak Arabic — its profile-config — arrived empty of any locale.
// setRuntimeLocale above covers everything that reads tStatic, which is why the headline and much
// of the chrome were already right; it does not cover a child that reads its own profile-config.
// Threading it costs nothing and is what ProfileLoader would have done had one existed beneath this
// composite, which is exactly the (C) failure mode: a composite that BAKES its children in has no
// ProfileLoader under it, so it must do that job itself.
// This is ONE identified gap, not a claim to have closed the whole 402/888 difference; rega
// re-measures.
const listConfig = computed(() => {
const cfg = {}
if (config.value.list) cfg.list = config.value.list
if (hostLang.value) cfg.lang = hostLang.value
return Object.keys(cfg).length ? JSON.stringify(cfg) : ''
})
const prayBaseUrl = computed(() => config.value.prayBaseUrl || '')
const languageCode = computed(() => config.value.languageCode || '')

View file

@ -161,7 +161,7 @@ onMounted(loadOptions)
class="rh-count"
role="status"
aria-live="polite"
>{{ count }} shown</span>
>{{ (L('n_shown', '') || tStatic('dashboard.n_shown') || '{n} shown').replace('{n}', count) }}</span>
</div>
<!-- SHARED SearchBar on top (replaces the hand-rolled <input type="search">). -->

View file

@ -34,22 +34,21 @@
import { computed, inject, ref } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import data from './data/connectivity.json'
import { useI18n } from 'vue-i18n'
import enConnectivity from '@map/i18n/locales/en/connectivity.json'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const profileConfig = inject('profileConfig', ref(null))
// ── UI text: library/i18n/locales/<lc>/connectivity.json (the connectivity-map pattern) ──────
// i18n is OPTIONAL: useI18n() throws on a host that never installed it, so English from the
// shipped catalog is the fallback, never a blank or a key. Region and country names are data.
const enLookup = (key) =>
String(key).replace(/^connectivity\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enConnectivity)
const fill = (str, params) => (params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)) : str)
let t = (key, params) => fill(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
t = (key, params) => { const v = i18n.t(key, params || {}); return v === key ? fill(enLookup(key) ?? key, params) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
// C4 wave 3b: the guard is library/composables/useOptionalT.js now, and the fallback resolves through
// tStatic — the RUNTIME LOCALE first, English as the last resort. exp-shepherd's call as this
// plugin's owner (2026-09-26), on the same reasoning gospel-plugin gave: the bundle already carries
// all 11 catalogues, so a partner embedding without vue-i18n being served English is a bug, not a
// decision. The old enLookup was English-only, which is exactly what that served.
// The local fill() is gone with the guard: fillParams applies the same interpolation, except it leaves
// a placeholder for a NULL value where this fill() rendered the string "null".
const t = useOptionalT('connectivity')
// NUMERALS ARE HELD for the agent (dx-shepherd, 2026-09-26): a locale-following format would switch
// 'ar' to Arabic-Indic digits. Until that is decided, numbers keep the browser-default format they
// had before this pass; only the TEXT is translated.

View file

@ -33,8 +33,7 @@
* carrier flag is a NAME-PATTERN HEURISTIC, not a verified classification.
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import enConnectivity from '@map/i18n/locales/en/connectivity.json'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
@ -206,14 +205,14 @@ function paintGroups(map) {
// i18n is OPTIONAL (the gospel-resources pattern): useI18n() throws on a host that never
// installed it, so English from the shipped catalog is the fallback, never a blank or a key.
// Carrier and platform names are data and are not translated.
const enLookup = (key) =>
String(key).replace(/^connectivity\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enConnectivity)
const fill = (str, params) => (params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)) : str)
let t = (key, params) => fill(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
t = (key, params) => { const v = i18n.t(key, params || {}); return v === key ? fill(enLookup(key) ?? key, params) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
// C4 wave 3b: the guard is library/composables/useOptionalT.js now, and the fallback resolves through
// tStatic — the RUNTIME LOCALE first, English as the last resort. exp-shepherd's call as this
// plugin's owner (2026-09-26), on the same reasoning gospel-plugin gave: the bundle already carries
// all 11 catalogues, so a partner embedding without vue-i18n being served English is a bug, not a
// decision. The old enLookup was English-only, which is exactly what that served.
// The local fill() is gone with the guard: fillParams applies the same interpolation, except it leaves
// a placeholder for a NULL value where this fill() rendered the string "null".
const t = useOptionalT('connectivity')
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
const BANDS = [

View file

@ -25,8 +25,7 @@ import { computed, inject, ref, watch } from 'vue'
import DataTable from '@map/components/tables/DataTable.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { useI18n } from 'vue-i18n'
import enConnectivity from '@map/i18n/locales/en/connectivity.json'
import { useOptionalT } from '@map/composables/useOptionalT.js'
const profileConfig = inject('profileConfig', ref(null))
@ -36,14 +35,14 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
// ── UI text: library/i18n/locales/<lc>/connectivity.json (the connectivity-map pattern) ──────
// i18n is OPTIONAL: useI18n() throws on a host that never installed it, so English from the
// shipped catalog is the fallback, never a blank or a key. Region and country names are data.
const enLookup = (key) =>
String(key).replace(/^connectivity\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enConnectivity)
const fill = (str, params) => (params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)) : str)
let t = (key, params) => fill(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
t = (key, params) => { const v = i18n.t(key, params || {}); return v === key ? fill(enLookup(key) ?? key, params) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
// C4 wave 3b: the guard is library/composables/useOptionalT.js now, and the fallback resolves through
// tStatic — the RUNTIME LOCALE first, English as the last resort. exp-shepherd's call as this
// plugin's owner (2026-09-26), on the same reasoning gospel-plugin gave: the bundle already carries
// all 11 catalogues, so a partner embedding without vue-i18n being served English is a bug, not a
// decision. The old enLookup was English-only, which is exactly what that served.
// The local fill() is gone with the guard: fillParams applies the same interpolation, except it leaves
// a placeholder for a NULL value where this fill() rendered the string "null".
const t = useOptionalT('connectivity')
// NUMERALS ARE HELD for the agent (dx-shepherd, 2026-09-26): a locale-following format would switch
// 'ar' to Arabic-Indic digits. Until that is decided, numbers keep the browser-default format they
// had before this pass; only the TEXT is translated.

View file

@ -228,11 +228,19 @@ useShadowStyles(`
400/360/280 px responsive widths the simple-map mfe uses. The
--map-legend-width / --map-btn-size / --map-toolbar-gap / --map-font-md
/ --map-font-sm CSS vars are set by applyContainerScale() below. ── */
/* Geocoder column matches the SemanticTreeLegend's .stl-panel — left:8px,
/* Geocoder column matches the SemanticTreeLegend's .stl-panel — inset-inline-start:8px,
width:380px — so the search bar and the legend stack as one column at
the same x-edge (PPLR alignment, qa: 2026-05-02). */
/* The search pill is exactly as wide as the legend below it: both follow --map-legend-width. */
.mapboxgl-ctrl-top-left { top:12px!important;left:8px!important;width:var(--map-legend-width,380px)!important;max-width:var(--map-legend-width,380px)!important;z-index:1200!important; }
/* ★ LOGICAL, so the search bar and the legend mirror TOGETHER (studio's legend sweep).
This column exists to sit at the same x-edge as SemanticTreeLegend's .stl-panel — that is the
whole point of the alignment recorded above. The legend already moves under ar; this rule
pinned the search bar with a physical left, so in Arabic the two came apart and the bar was
left stranded on the side the legend had vacated. A shared edge that only one of the pair
honours is worse than no alignment: it reads as a broken layout rather than a choice.
inset-inline-end:auto comes with it to cancel the engine's own physical inset on this
container, which would otherwise stretch the column across the map once the start edge moves. */
.mapboxgl-ctrl-top-left { top:12px!important;inset-inline-start:8px!important;inset-inline-end:auto!important;width:var(--map-legend-width,380px)!important;max-width:var(--map-legend-width,380px)!important;z-index:1200!important; }
.mapboxgl-ctrl-top-left .mapboxgl-ctrl { margin:0!important;float:none!important; }
/* Pill geometry + PPLR-matched focus ring (qa: 2026-05-02). border-radius
999px matches PPLR's .pgc-input-wrap; focus-within ring uses PPLR's
@ -2254,6 +2262,34 @@ function emitFilterState() {
// only read `languageCode` are unaffected.
languageCodes: langCodes,
}
// ★ AN EMPTY REPORT AFTER A NON-EMPTY ONE IS AMBIGUOUS, SO IT IS NOT SENT IMMEDIATELY.
// rega, row 43 case 3 at 52eeb0db: coalescing worked — one event per click — and in 3 of 4 runs
// that one event was EMPTY, so the grid stayed at 2,107. The cascade behind a dialect click
// passes through a fully-cleared state: mapStore.selectLanguage() sets selectedDialect = null
// BEFORE it sets the language, and the clear was the last write my watcher saw. The settle timer
// then fired in the gap and read nothing.
//
// MY FIRST FIX WAS A TIMEOUT, AND A TIMEOUT WAS THE WRONG KIND OF ANSWER. 60ms was enough in my
// seat and not under headless swiftshader, which is the same fixed-sleep mistake I have now made
// three times: a duration tuned on one machine is a measurement of that machine. Lengthening it
// would move the failure, not remove it.
//
// So the condition is SEMANTIC. "Everything is empty" immediately after a non-empty report is
// exactly what a mid-cascade read looks like AND exactly what a genuine clear looks like, and
// nothing in the payload tells them apart — but TIME does, cheaply and in the safe direction:
// re-read a few times, and take the first answer that is not empty. A real clear stays empty
// through every re-read and is reported a few tens of ms later, which no reader can perceive; a
// transient fills in and is reported correctly. The retries are bounded so a clear can never be
// swallowed, only delayed.
const isEmpty = !detail.region && !detail.religion && !detail.language && !detail.languageCode
if (isEmpty && _lastEmitWasFilled && _emptyRetries < EMPTY_RETRIES) {
_emptyRetries++
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
return
}
_emptyRetries = 0
_lastEmitWasFilled = !isEmpty
host.dispatchEvent(new CustomEvent('map-filtered', { detail, bubbles: true, composed: true }))
}
@ -2275,7 +2311,34 @@ function emitFilterState() {
// async work); 60ms is far below the 1.2s guard and below what a reader perceives as a delay.
let _emitTimer = null
const EMIT_SETTLE_MS = 60
// How many times an all-empty read may be re-taken before it is believed. Bounded on purpose: a
// genuine clear must always get through, and the worst case here is EMPTY_RETRIES * EMIT_SETTLE_MS
// of delay on a clear — about a fifth of a second, well under what a reader notices, and far below
// the composite's own 1.2s push window so it cannot collide with that.
const EMPTY_RETRIES = 3
let _emptyRetries = 0
// Whether the LAST report actually carried a selection. Only then is an empty read suspicious: on
// first load, or after a clear that has already been reported, empty is simply the truth.
let _lastEmitWasFilled = false
function emitFilterStateSettled() {
// ★ THE ECHO GUARD MUST BE READ WHEN THE CHANGE HAPPENS, NOT WHEN THE TIMER FIRES.
// rega, row 50: a region row click emitted {region:"asia"} and then an EMPTY event, on both
// engines. It passed only because the composite's 1.2s push window happened to swallow the
// second one — a pass that depends on a swallow is not a pass.
//
// THE CAUSE IS MINE, AND IT IS THE COALESCING I ADDED. emitFilterState has always opened with
// `if (applyingExternal) return`: when the HOST pushes state down through externalFilters, the
// store changes, and reporting that back up is an echo of the host's own instruction. That guard
// worked because the emit ran SYNCHRONOUSLY inside the watcher, while the flag was still set.
// Debouncing moved the emit 60ms later — and applyingExternal is released on nextTick, long
// before that — so by the time the emit ran the flag was false and the echo went out. I fixed a
// burst and broke a guard that depended on the burst's timing, which is the cost of changing WHEN
// something runs without re-reading what else depended on when.
//
// So the decision is taken here, at schedule time, where the flag still means what it says: a
// change that arrived from the host is not a change the reader made, and is not reported back.
if (applyingExternal) return
_emptyRetries = 0 // a fresh user action restarts the budget
clearTimeout(_emitTimer)
_emitTimer = setTimeout(emitFilterState, EMIT_SETTLE_MS)
}

View file

@ -49,6 +49,8 @@
* it is asserted directly, not inferred.
* F. a DIALECT reports a NAME and not a tree node id, and the emitter sends ONE event per
* settled selection rather than a burst. Both from rega's row 43 case 3.
* G. that ONE event is not the cascade's transient EMPTY state. Same case, second measurement:
* the coalescing worked and the single event was EMPTY in 3 of 4 runs.
*
* NO NETWORK, NO BROWSER (Playwright is rega's alone). The fixture is committed and was taken from
* the live source; refresh it with --refresh-fixture when the source's language set changes.
@ -255,6 +257,47 @@ if (matchesLanguage) {
}
}
// ── G. an empty report after a filled one is re-taken, and the re-take is BOUNDED ────────────
// rega, row 43 case 3 at 52eeb0db: coalescing landed — one event per click — and in 3 of 4 runs
// that one event was EMPTY, so the grid stayed at 2,107. The cascade behind a dialect click passes
// through a fully-cleared store state (mapStore.selectLanguage sets selectedDialect = null BEFORE
// setting the language), and the settle timer fired inside it.
// The first fix was a timeout, and a timeout was the wrong KIND of answer: 60ms was enough on my
// machine and not under headless swiftshader. Lengthening it moves the failure rather than removing
// it. So the guard is SEMANTIC — an all-empty read straight after a filled one is re-taken — and
// BOUNDED, because a genuine clear must always get through. Both halves are asserted: without the
// guard the transient ships, and without the bound a clear can never be reported at all.
{
const src = emitterSrc
if (!/_lastEmitWasFilled/.test(src) || !/const isEmpty/.test(src)) {
fails.push('G research-map.vue no longer guards an all-empty report. A dialect click passes through a fully-cleared store state, so the settled read can be taken mid-cascade and ship {language:"",languageCode:""} — which the composite correctly reads as "the reader cleared everything".')
} else if (!/_emptyRetries\s*<\s*EMPTY_RETRIES/.test(src)) {
fails.push('G the empty guard is not BOUNDED — without a retry ceiling a genuine clear is deferred for ever and the grid can never be un-narrowed.')
} else if (!/_emptyRetries = 0/.test(src)) {
fails.push('G the retry budget is never reset, so the guard protects only the first cascade of a session.')
} else notes.push('empty guard: a mid-cascade read is re-taken, a real clear still gets through')
}
// ── H. the echo guard survives the coalescing ────────────────────────────────────────────────
// rega, row 50: a region click emitted the selection and then an EMPTY event, passing only because
// the composite's 1.2s push window swallowed the second. emitFilterState has always opened with
// `if (applyingExternal) return` — a change pushed DOWN by the host is not a change to report back
// up — and that worked while the emit ran synchronously inside the watcher. Debouncing moved the
// emit 60ms later while applyingExternal is released on nextTick, so the flag was already false
// when the emit ran and the echo went out. The guard has to be read where the change happens.
{
const src = emitterSrc
const sched = /function emitFilterStateSettled\(\) \{([\s\S]*?)\n\}/.exec(src)
if (!sched) fails.push('H research-map.vue: emitFilterStateSettled() not found — this gate cannot see whether the echo guard survives the debounce.')
// ★ COMMENTS STRIPPED FIRST, and this is the THIRD time the same hole has appeared in my own
// checks. The body below explains the fix in prose that QUOTES the line it is asserting, so a
// naive search matched the comment and stayed green after the code was deleted. A check that its
// own documentation can satisfy is not a check; only executable lines count.
else if (!/if \(applyingExternal\) return/.test(sched[1].split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n'))) {
fails.push('H the settle scheduler does not check applyingExternal. The check inside emitFilterState runs 60ms later, and applyingExternal is released on nextTick — so a change the HOST pushed down is reported back up as if the reader had made it. That is an echo, and it reaches the composite as an empty report.')
} else notes.push('echo guard: read at schedule time, where applyingExternal still means what it says')
}
for (const n of notes) console.log(' · ' + n)
if (fails.length) {
console.error(`\n${TAG}: FAIL`)

View file

@ -45,16 +45,13 @@
* DATA: fetched at runtime from assets/tables/ (people-group-resources.json is 1.2MB;
* inlining it into the IIFE would block first paint for every profile in the bundle).
*/
import { useI18n } from 'vue-i18n'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { computed, inject, ref, watch } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
/* Guarded like every other useI18n() in this bundle — the host may never have installed it,
and the shipped English catalogue is the fallback rather than a broken key. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
/* ★ NUMBERS ARRIVE AS PARAMETERS, NEVER CONCATENATED. The subtitles carry a count, and a
sentence built by gluing a number between two fragments cannot be translated — word order
differs and several of these locales put the number where English does not. The catalogue
@ -62,16 +59,13 @@ const enLookup = (key) =>
THE FALLBACK HAS TO INTERPOLATE TOO. Without it a host that never installed i18n would render
the placeholder literally — "Across all {n} languages" on screen — which is worse than the
English sentence the fallback exists to protect. */
const interpolate = (str, params) =>
params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? params[k] : m)) : str
let tr = (key, params) => interpolate(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
tr = (key, params) => {
const v = params ? i18n.t(key, params) : i18n.t(key)
return v === key ? interpolate(enLookup(key) ?? key, params) : v
}
} catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalT.js now. enLookup STAYS as the fallback, passed
// in, so a bare host renders the same ENGLISH it always did -- routing this to the catalogue default
// would make a bare host honour ?lang=ar, which is gospel-plugin's call and not this migration's.
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback rather than
// enLookup's English. A partner host that never installed vue-i18n now gets the reader's language,
// with English as the last resort. tStatic resolves 'gospel.<key>' against the same catalogue.
const tr = useOptionalT('gospel')
const profileConfig = inject('profileConfig', ref(null))

View file

@ -20,7 +20,7 @@
* the table's own meta so the method travels with the number.
*/
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import DataTable from '@map/components/tables/DataTable.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -35,13 +35,13 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
so a translator called t() would be shadowed inside the one block that needs it most, and
the labels would silently render as whatever the tab object stringifies to. Guarded like
every other useI18n() call in this bundle: the host may never have installed i18n. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
tr = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host: the shipped English catalogue is the fallback */ }
// C4: the guard is library/composables/useOptionalT.js now. enLookup STAYS as the fallback, passed
// in, so a bare host renders the same ENGLISH it always did -- routing this to the catalogue default
// would make a bare host honour ?lang=ar, which is gospel-plugin's call and not this migration's.
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback rather than
// enLookup's English. A partner host that never installed vue-i18n now gets the reader's language,
// with English as the last resort. tStatic resolves 'gospel.<key>' against the same catalogue.
const tr = useOptionalT('gospel')
const TABS = [
// ★ THE DOXA TABLE LEADS, because it is the question the agent actually asked: which of
@ -108,9 +108,15 @@ const params = qp()
const fIso = ref((params.get('iso') || '').trim().toLowerCase())
const fCountry = ref((params.get('country') || '').trim().toUpperCase())
const fLabel = ref((params.get('label') || '').trim())
const hasFilter = computed(() => !!(fIso.value || fCountry.value))
/* A PROVINCE IS A THIRD KEY, and it is a NAME rather than a code: geoBoundaries' shapeName is
what the province table joins on, so that is what travels in the URL. Matched case- and
diacritic-insensitively — a link typed as ?province=Arunachal Pradesh must still find
"Arunāchal Pradesh", or the deep link works only for whoever copied it off the map. */
const fProvince = ref((params.get('province') || '').trim())
const fold = (v) => String(v || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
const hasFilter = computed(() => !!(fIso.value || fCountry.value || fProvince.value))
function clearFilter() {
fIso.value = ''; fCountry.value = ''; fLabel.value = ''
fIso.value = ''; fCountry.value = ''; fLabel.value = ''; fProvince.value = ''
try { window.history.replaceState({}, '', window.location.pathname) } catch (_) {}
}
@ -127,6 +133,7 @@ if (composedFilter) {
fIso.value = String(f.iso || '').trim().toLowerCase()
fCountry.value = String(f.country || '').trim().toUpperCase()
fLabel.value = String(f.label || '').trim()
fProvince.value = String(f.province || '').trim()
// The links tab is the one that carries URLs; a filter is meaningless on the others.
if (TABS.some((t) => t.id === 'links')) active.value = 'links'
})
@ -156,7 +163,7 @@ const rawTable = computed(() => cache.value[current.value?.file] || null)
// LANGUAGE is spoken in); the gaps table is one row per people-group-IN-COUNTRY and
// carries iso3 directly. Filtering only the links table meant a country click could show
// you what a country HAS and never what it lacks — which is the half a mobiliser came for.
const FILTERABLE = new Set(['doxa', 'links', 'nothing'])
const FILTERABLE = new Set(['doxa', 'links', 'nothing', 'province'])
const table = computed(() => {
const t = rawTable.value
/**
@ -205,6 +212,8 @@ const table = computed(() => {
})
if (!t || !hasFilter.value || !FILTERABLE.has(current.value?.id)) return t ? localise(t) : t
const rows = (t.rows || []).filter((r) => {
/* The province table has no language column, so it answers the province key alone. */
if (fProvince.value) return fold(r.province) === fold(fProvince.value)
if (fIso.value && String(r.iso639_3 || '').toLowerCase() !== fIso.value) return false
if (!fCountry.value) return true
// links: a language spoken in many countries. gaps: one row, one country.
@ -228,7 +237,7 @@ const table = computed(() => {
* fixes the on-screen heading in the same move: it read "every link, and who it reaches"
* over a table showing one country's. */
const title = t.meta?.title
? `${t.meta.title} — ${fLabel.value || fIso.value || fCountry.value}`
? `${t.meta.title} — ${fLabel.value || fProvince.value || fIso.value || fCountry.value}`
: t.meta?.title
/* ═══ COLUMN HEADERS ARE CHROME, BUT THEY LIVE IN THE DATA FILE ══════════════════════
* A reader navigates a table by its headers, so they are UI strings by any fair reading of
@ -360,12 +369,12 @@ useShadowStyles(
<div v-if="hasFilter" class="gt-filter">
<span v-if="FILTERABLE.has(current.id)">
{{ tr('gospel.tables.showing') }} <b>{{ fmt(filterCount) }}</b> {{ tr('gospel.tables.of') }} {{ fmt(totalCount) }}
{{ tr('gospel.tables.for') }} <b>{{ fLabel || fIso || fCountry }}</b>
{{ tr('gospel.tables.for') }} <b>{{ fLabel || fProvince || fIso || fCountry }}</b>
<!-- The explanation stays English and declares its direction, like the map's prose:
it is the caveat that says an empty result is not proof of absence. -->
<template v-if="!filterCount"><span dir="ltr"> — nothing found for this selection in the pools checked, which is not proof that nothing exists.</span></template>
</span>
<span v-else>{{ tr('gospel.tables.filteredTo') }} <b>{{ fLabel || fIso || fCountry }}</b><span dir="ltr"> — this tab is not filtered; only the keyed tables carry a language or country key.</span></span>
<span v-else>{{ tr('gospel.tables.filteredTo') }} <b>{{ fLabel || fProvince || fIso || fCountry }}</b><span dir="ltr"> — this tab is not filtered; only the keyed tables carry a language or country key.</span></span>
<!-- "Show all" WHAT. Stacked under the map in the dashboard there are three controls
with this exact label — two in the map's group lists and this one — and they do
different things. A reader cannot tell which clears the table filter, and neither
@ -378,7 +387,7 @@ useShadowStyles(
<div v-else-if="!table" class="gt-load">Loading {{ current.label }}…</div>
<DataTable
v-else
:key="current.id + ':' + fIso + ':' + fCountry"
:key="current.id + ':' + fIso + ':' + fCountry + ':' + fProvince"
:table="table"
:assets-base="assetsBase"
:is-dark="isDark"

View file

@ -1,6 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { resourceRows } from './gospel-resource-rows.js'
/**
* resources-list.vue — GOSPEL RESOURCES, by language.
@ -44,13 +43,13 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
/* Guarded like every other useI18n() in this bundle — the host may never have installed it,
and the shipped English catalogue is the fallback rather than a broken key. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
tr = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host */ }
// C4: the guard is library/composables/useOptionalT.js now. enLookup STAYS as the fallback, passed
// in, so a bare host renders the same ENGLISH it always did -- routing this to the catalogue default
// would make a bare host honour ?lang=ar, which is gospel-plugin's call and not this migration's.
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback rather than
// enLookup's English. A partner host that never installed vue-i18n now gets the reader's language,
// with English as the last resort. tStatic resolves 'gospel.<key>' against the same catalogue.
const tr = useOptionalT('gospel')
const profileConfig = inject('profileConfig', ref(null))

View file

@ -72,8 +72,8 @@
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js' // vue-i18n stays: two guarded LOCALE reads below
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
import { engineUiLocale } from '@map/composables/useMapInstance.js'
@ -785,21 +785,25 @@ const pinsByIso = computed(() => {
/* Walks from the ROOT of the catalogue, not from its gospel subtree: this leaf now also reads
`pins.*`, the shared people-group-pin wording, and an area-blind lookup would have fallen back
to the key name for every one of those on a host with no i18n. */
const enLookup = (key) =>
String(key).split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES || {})
/* The fallback interpolates too. vue-i18n does it for the translated path; without this the
English arm would render "{names} — {n} groups share…" literally. */
const fill = (str, params) => (params
? String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] == null ? m : String(params[k])))
: str)
let t = (key, params) => fill(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
t = (key, params) => {
const v = params ? i18n.t(key, params) : i18n.t(key)
return v === key ? fill(enLookup(key) ?? key, params) : v
}
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
// C4 wave 3a, RE-CUT on main 4d18a6f3. ★ NO NAMESPACE, and that is the whole point of the re-cut.
// This leaf passes FULL keys in TWO areas -- gospel.* and pins.* (the shared people-group-pin
// wording) -- and enLookup above deliberately walks from the catalogue ROOT to see both. An earlier
// version of this migration used useOptionalT('gospel', ...), whose qualify() would have turned
// pins.approximate into gospel.pins.approximate on the translated path and sent the fallback looking
// under EN_MESSAGES.gospel, where pins.* does not exist. studio caught it as a SEMANTIC conflict
// before it landed; passing '' keeps every key exactly as the caller wrote it.
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback. Their reason is
// the bundle's purpose -- most partners drop an element or an iframe and never install vue-i18n, so
// those hosts got enLookup's ENGLISH while the bundle already shipped all 11 catalogues. "That is a
// bug, not a decision." tStatic honours the runtime locale and keeps English as the LAST resort.
// ★ NO NAMESPACE: this leaf passes FULL keys in two areas (gospel.* and pins.*), so any prefixing
// would ask for gospel.pins.approximate, which no catalogue has. studio caught that before it landed.
const t = useOptionalT('')
/* Whether to show the "these notes are in English" line at all. Read from the same i18n
instance the labels come from, so the two can never disagree. */
const isEnglish = (() => {
@ -2215,6 +2219,18 @@ useShadowStyles(
A group is placed in a province from its own coordinate, which marks where a people is
found rather than a settlement. Near a border that point can fall on the wrong side.
</p>
<!-- The brief's THIRD requirement at the province grain: clicking a place filters the
table to it. Same split as the country and language links — a filter when the tables
are already beneath this map, a link when they are not. -->
<button v-if="composed" type="button" class="gm-tablink"
@click="filterComposedTables({ tab: 'province', province: selectedProvince.province, label: selectedProvince.province })">
{{ selectedProvince.province }} ↓
</button>
<a v-else class="gm-tablink"
:href="tableLink({ tab: 'province', province: selectedProvince.province, label: selectedProvince.province })">
{{ selectedProvince.province }} →
</a>
<!-- ═══ THE MOVE, AT THE GRAIN A TEAM IS SENT TO ══════════════════════════════════════
A country is not where anyone is sent; a state is. Same ranking as the country sheet —
need first, then size — so drilling down never hands the reader a second answer. -->
@ -2395,12 +2411,16 @@ useShadowStyles(
of the 2,107 placed groups sit on a coordinate they share with another group. Where
this one does, it names them, because "approximate" on its own hides the part a
reader would never otherwise discover — that some dots are literally the same dot.
TRANSLATED NOW, in 11 locales, from the shared pins.* area (APPROXPIN). Read through
this leaf's own guarded t() rather than by mounting library ApproxPinNote.vue, which
calls useI18n() unguarded: this bundle is embeddable in hosts that never called
app.use(i18n), where that throws during setup and the shadow boundary swallows it —
the failure that blanked a production plugin once already. Same wording either way;
it is this map's P5 proposal that became the library's. -->
TRANSLATED, in 11 locales, from the shared pins.* area (APPROXPIN) — the same
catalogue library ApproxPinNote.vue reads, so the wording is shared and not copied;
it is this map's own P5 proposal that became the library's.
WHY THE KEYS AND NOT THE COMPONENT, and the reason has CHANGED: it used to be that
the component resolved its catalogue without the guard this leaf needs for hosts that
never installed vue-i18n. C4 wave 2 fixed that, so the component is safe to mount and
this note no longer warns anyone off it. What is left is typography: it carries its
own inline 11px and #5b6470, while every other note in this sheet is .gm-note at 14px
inheriting the sheet's ink. Mounting it here would render one note in a different
size and an off-palette grey. Same sentence, this sheet's voice. -->
<p class="gm-note" :dir="isEnglish ? 'ltr' : null" style="margin:6px 0 0">
{{ t('pins.approximate') }}
<template v-if="sharedWith(selectedGroup).length">

View file

@ -1,17 +1,16 @@
// usePplrT — PPLR's UI text: library/i18n/locales/<lc>/pplr.json, in all eleven locales.
// i18n is OPTIONAL (the connectivity-map pattern): useI18n() throws on a host that never installed
// it, so the English catalog is the fallback, never a blank or a raw key. Call it inside setup().
import { useI18n } from 'vue-i18n'
import enPplr from '@map/i18n/locales/en/pplr.json'
const enLookup = (key) => String(key).replace(/^pplr\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enPplr)
const fill = (str, params) => (params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)) : str)
// i18n is OPTIONAL: useI18n() throws on a host that never installed it, so the catalogue is the
// fallback, never a blank or a raw key. Call it inside setup().
//
// C4: the guard, the English fallback and the {param} fill used to be written out here and in
// useNeighborT.js at Jaccard 0.82 — the same fact stored twice, and eighteen more times elsewhere.
// They now come from library/composables/useOptionalT.js. The local `import enPplr from
// '@map/i18n/locales/en/pplr.json'` went with them: tStatic already walks messages[locale].pplr and
// already falls back to English, so that import was a second copy of the same bytes.
//
// The NAME stays. Every caller keeps `usePplrT()`, so this migration changes no call site.
import { useOptionalT } from '@map/composables/useOptionalT.js'
export function usePplrT() {
try {
const i18n = useI18n()
return (key, params) => { const v = i18n.t(key, params || {}); return v === key ? fill(enLookup(key) ?? key, params) : v }
} catch (_) {
return (key, params) => fill(enLookup(key) ?? String(key), params)
}
return useOptionalT('pplr')
}

View file

@ -21,6 +21,16 @@
//
// Comments are replaced by SPACES, preserving length and line structure, so line numbers reported
// against the result still point at the real file.
// ★ WHAT THIS DOES NOT STRIP, and both gaps have already cost a real measurement:
// - CSS comments inside a JS TEMPLATE LITERAL. A string is copied through verbatim, correctly for
// JS, but useShadowStyles(`...`) contains CSS whose /* */ the BROWSER honours. Reading specificity
// off such a file once gave (0,5,53) because a comment landed inside the selector.
// - HTML comments in a .vue TEMPLATE. `<!-- ... -->` is invisible here, so any identifier quoted in
// one is counted as code. That made a bare-useI18n census read 3 where the code had 2.
// gospel-plugin's generalisation, worth keeping: "any <!-- --> in a template that quotes an identifier
// will keep doing that to you". THE RULE: the stripper must match the LANGUAGE of the text being read.
// For a .vue file, strip HTML comments first, then call this for the script block.
export function codeOnly(src) {
let out = ''
let i = 0

View file

@ -0,0 +1,152 @@
#!/usr/bin/env node
/**
* check-legend-mirrors.mjs — the legends must MIRROR, and their labels must not become values.
*
* rega's Arabic survey on main 52eeb0db: the research-dashboard legends sat on the wrong edge with
* ENGLISH text — 70 of 85 letters Latin — in Big Picture and in the Research Gallery. Two faults
* that look like one, and they fail in opposite directions if you fix them carelessly.
*
* ── 1. A PHYSICAL INSET DOES NOT MIRROR ──────────────────────────────────────────────────────
* `right:8px` pins a box to the physical right edge in every locale, so in Arabic it stays where an
* English reader expects it while everything around it flips. The logical property is the fix, and
* `inset-inline-start:auto` / `inset-inline-end:auto` is part of the pair rather than decoration:
* it cancels a physical inset inherited from a control container, which otherwise stretches the box
* across the map once the other edge moves.
* Two surfaces are asserted because they are the two the survey named, and one of them — the
* research-map geocoder column — exists ONLY to share an edge with the legend beside it. A shared
* edge that one of the pair honours and the other does not is worse than no alignment: it reads as
* a broken layout rather than as a choice.
*
* ── 2. THE LABEL IS PROJECTED; THE VALUE IS NOT ──────────────────────────────────────────────
* The English in that legend was NOT chrome. `s.key` is a facet VALUE, and for the religion donut
* it is a family name DERIVED in English by library/colors/religion.js — the same table
* people-groups-grid re-derives per row so the two ends byte-match. So the obvious fix, translating
* the strings, is the dangerous one: it would work in English and match nothing in Arabic,
* SILENTLY, because an empty intersection reads as "no results". That is the language-link defect
* one facet over, and it cost a card to learn the first time.
* So this gate asserts BOTH halves: that the visible text is projected, AND that the projection did
* not leak into the value — data-key still carries the raw key, and RELIGION_FAMILIES is still the
* English table every consumer compares against.
*
* NEGATIVE-TESTED: a physical inset on either surface fails 1; binding data-key to the projection,
* or dropping the projection from the visible text, fails 2; translating a RELIGION_FAMILIES value
* fails 2 naming the link it would break.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const TAG = 'LEGEND-MIRRORS'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const fails = []
const notes = []
const read = (rel) => { const p = join(ROOT, rel); return existsSync(p) ? readFileSync(p, 'utf8') : null }
// ── 1. logical insets on the two surfaces the survey named ───────────────────────────────────
const SURFACES = [
{ rel: 'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
sel: '.rd-legend-map', want: 'inset-inline-end', what: 'the map legend' },
{ rel: 'plugin-designs/doxa-research-map/research-map.vue',
sel: '.mapboxgl-ctrl-top-left', want: 'inset-inline-start',
what: 'the geocoder column, which exists to share an edge with the legend below it' },
]
for (const { rel, sel, want, what } of SURFACES) {
const src = read(rel)
if (!src) { fails.push(`1 ${rel} is missing — this gate is blind to ${what}.`); continue }
const rule = new RegExp(`\\${sel}\\s*\\{([^}]*)\\}`).exec(src)
if (!rule) { fails.push(`1 ${rel}: no ${sel} rule found — it moved or was renamed, and this gate cannot see ${what}.`); continue }
const body = rule[1]
const physical = [...body.matchAll(/(^|[;{\s])(left|right)\s*:/g)].map((m) => m[2])
if (physical.length) {
fails.push(`1 ${rel}: ${sel} uses the PHYSICAL inset(s) ${[...new Set(physical)].join(', ')} — ${what} will not mirror under ar, so it lands on the edge an English reader expects while everything around it flips.`)
}
if (!body.includes(want)) {
fails.push(`1 ${rel}: ${sel} has no ${want} — ${what} has nothing anchoring it to the reading direction.`)
}
const other = want === 'inset-inline-end' ? 'inset-inline-start' : 'inset-inline-end'
if (!new RegExp(`${other}\\s*:\\s*auto`).test(body)) {
fails.push(`1 ${rel}: ${sel} sets ${want} without ${other}:auto. The pair is what cancels a physical inset inherited from a control container; without it the box stretches across the map once the anchored edge moves.`)
}
}
if (!fails.some((f) => f.startsWith('1'))) notes.push(`insets: ${SURFACES.length} surface(s) anchored to the reading direction, both halves of the pair present`)
// ── 2a. the visible text is projected, the wire is not ───────────────────────────────────────
{
const rel = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue'
const src = read(rel)
if (!src) fails.push(`2 ${rel} is missing.`)
else {
const li = /<span v-for="s in chart\.geometry"[\s\S]*?<\/span>\n/.exec(src)
if (!li) fails.push(`2 ${rel}: the legend item markup moved — this gate is blind to what it renders.`)
else {
const m = li[0]
if (!/class="rd-lt">\{\{ legendLabel\(/.test(m)) {
fails.push(`2 ${rel}: the legend's visible text is not projected through legendLabel() — a derived English facet value renders untranslated in all eleven locales.`)
}
// ★ EVERY data-key IN THE FILE, not just this one. A first version checked only the legend
// item, and a mutation test aimed at it silently hit a DIFFERENT :data-key — the donut
// segment — and the gate stayed green while a projection sat on a wire. There are three
// (segment, legend item, bar), all of them values the bus compares, and a check that guards
// one of three is a check whose own negative test can miss.
const keyBinds = [...src.matchAll(/:data-(?:key|facet)="([^"]*)"/g)]
if (keyBinds.length < 3) fails.push(`2 ${rel}: expected at least 3 :data-key/:data-facet bindings (segment, legend item, bar) and found ${keyBinds.length} — the markup moved and this gate is guarding less than it thinks.`)
for (const b of keyBinds) {
if (/legendLabel\(|religionFamilyLabel\(|tStatic\(/.test(b[1])) {
fails.push(`2 ${rel}: a PROJECTION is bound to a wire attribute (${b[0]}). The value is what the bus and the grid compare; a localized one works in English and matches NOTHING in Arabic, silently, because an empty intersection reads as "no results". Labels are for readers; values cross boundaries.`)
}
}
if (!/:data-key="s\.key"/.test(m)) {
fails.push(`2 ${rel}: the legend item's data-key no longer carries the RAW key.`)
}
if (!fails.some((f) => f.startsWith('2'))) notes.push('charts legend: text projected, data-key still the canonical value')
}
}
}
// ── 2b. the table itself was not translated ──────────────────────────────────────────────────
{
const rel = 'library/colors/religion.js'
const src = read(rel)
if (!src) fails.push(`2 ${rel} is missing.`)
else {
const tbl = /export const RELIGION_FAMILIES = \{([\s\S]*?)\}/.exec(src)
if (!tbl) fails.push(`2 ${rel}: RELIGION_FAMILIES not found.`)
else {
const values = [...tbl[1].matchAll(/'[A-Z]':\s*'([^']+)'/g)].map((m) => m[1])
const nonAscii = values.filter((v) => /[^\x20-\x7E]/.test(v))
if (nonAscii.length) {
fails.push(`2 ${rel}: RELIGION_FAMILIES has been TRANSLATED (${nonAscii.join(', ')}). These are VALUES: the dashboard puts them on the selection bus and people-groups-grid re-derives them per row. Localized, the link works in English and matches nothing elsewhere. Translate religionFamilyLabel's catalogue entries instead.`)
}
if (!/export function religionFamilyLabel/.test(src)) {
fails.push(`2 ${rel}: religionFamilyLabel() is gone, so there is no way to show a family name to a reader without translating the value.`)
}
if (!fails.some((f) => f.includes('RELIGION_FAMILIES has been'))) notes.push(`religion table: ${values.length} values still canonical English, projection available`)
}
}
}
// ── 2c. the projection has something to project ──────────────────────────────────────────────
{
const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
const en = read('library/i18n/locales/en/religion.json')
if (!en) fails.push('2 library/i18n/locales/en/religion.json is missing — religionFamilyLabel() would fall back to English everywhere, which is the defect it exists to fix.')
else {
const names = Object.keys(JSON.parse(en).family || {})
if (!names.length) fails.push('2 en/religion.json has no `family` entries.')
for (const loc of LOCALES) {
const j = read(`library/i18n/locales/${loc}/religion.json`)
if (!j) { fails.push(`2 ${loc}/religion.json is missing.`); continue }
const missing = names.filter((n) => !(JSON.parse(j).family || {})[n])
if (missing.length) fails.push(`2 ${loc}/religion.json is missing ${missing.length} family name(s): ${missing.join(', ')}`)
}
if (!fails.some((f) => f.includes('religion.json'))) notes.push(`catalogue: ${names.length} family names in all ${LOCALES.length} locales`)
}
}
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 — the legends mirror, and their labels never became their values.`)

View file

@ -1,4 +1,5 @@
<script setup>
import { religionFamilyLabel } from '@map/colors/religion.js'
import { tStatic } from '@map/i18n/index.js'
/**
* ChartsRail — RDASH phase 5: cross-filtered charts rail. Religion donut + Region donut (doxa-region
@ -90,6 +91,11 @@ const charts = computed(() => (cfg.charts || []).map((c) => {
}
}))
const isActive = (facet, key) => bus.isFilterActive(facet, key)
// Display-only projection for a facet VALUE. Only the religion family axis has values that were
// derived in English rather than taken from the API, so it is the only one that needs projecting;
// every other facet's values arrive already localized. Returns the key unchanged for anything else,
// so a new facet is never silently rewritten.
const legendLabel = (facet, key) => (facet === 'religion_family' ? religionFamilyLabel(key) : key)
// true when this chart's own facet has an active selection → drives the pop-out / dim-others state.
const hasSelection = (facet) => filterSet[facet] != null // any selection, scalar or array
@ -240,7 +246,7 @@ useShadowStyles(`
a little air. sticky right:0 (from the frozen-column fix) is what makes it immovable while the
label scrolls beneath. Both donut legends share .rd-li, so religion and region get this from
the one rule -- which is exactly why it lives here and not per-chart. */
.rd-li b{ flex:0 0 auto; min-width:3.4em; text-align:right;
.rd-li b{ flex:0 0 auto; min-width:3.4em; text-align:end;
color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; }
/* The label is the ONLY item allowed to shrink. It used to be a bare text node, which in a
nowrap flex row cannot shrink at all: the row grew past .rd-legend (overflow-x:hidden) and
@ -294,7 +300,7 @@ useShadowStyles(`
display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
.rd-bt{ flex:1 1 auto; height:11px; background:var(--dx-surface, #f3f3f1); border-radius:3px; overflow:hidden; }
.rd-bf{ display:block; height:100%; border-radius:3px; transition:width .35s cubic-bezier(.4,0,.2,1); }
.rd-bv{ flex:0 0 auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; min-width:30px; text-align:right; }
.rd-bv{ flex:0 0 auto; color:var(--rd-muted, #6d766e); font-variant-numeric:tabular-nums; min-width:30px; text-align:end; }
`, 'rd-charts')
</script>
@ -337,8 +343,20 @@ useShadowStyles(`
</path>
</svg>
<div class="rd-legend">
<!-- ★ THE LABEL IS PROJECTED; THE KEY IS NOT (rega's Arabic survey: 70 of 85 letters in
this legend were Latin). `s.key` is a facet VALUE, and for the religion donut it is a
DERIVED English family name — "Islam", "Hinduism" — that library/colors/religion.js
produces from a religion code. It is English in every locale because it is not a
string anyone wrote for a reader; it is the value the bus carries and the grid
re-derives per row.
So this does NOT translate the value. data-key, isActive() and every click path keep
the canonical English, exactly as before; only the visible text is projected through
legendLabel(). Translating the value instead would work in English and match nothing
in Arabic — silently, because an empty intersection reads as "no results". That is
the language-link defect, one facet over. Same rule as the shareable URL: the code is
canonical, the label is display-only. -->
<span v-for="s in chart.geometry" :key="s.key" class="rd-li" :class="{ on: isActive(chart.facet, s.key) }"
:data-facet="chart.facet" :data-key="s.key" :title="s.key + ': ' + s.value"><i :style="{ background: s.color }"></i><span class="rd-lt">{{ s.key }}</span><b>{{ s.value }}</b></span>
:data-facet="chart.facet" :data-key="s.key" :title="legendLabel(chart.facet, s.key) + ': ' + s.value"><i :style="{ background: s.color }"></i><span class="rd-lt">{{ legendLabel(chart.facet, s.key) }}</span><b>{{ s.value }}</b></span>
</div>
</div>
</div>

View file

@ -1622,7 +1622,17 @@ useShadowStyles(`
been removed, so the corner is free if a future control needs it).
Stays on the right so the legend sits beside the choropleth it explains. */
.rd-mapwrap{ --rd-attrib-clear:26px; }
.rd-legend-map{ position:absolute; right:8px; bottom:calc(var(--rd-attrib-clear,26px) + 8px); z-index:2;
/* ★ LOGICAL, NOT PHYSICAL (rega's Arabic survey). A physical right:8px pins this to the right
edge in every locale, so in Arabic the legend stayed where an English reader expects it while
everything around it mirrored. inset-inline-end puts it at the END of the reading direction —
right in English, left in Arabic — which is the pattern cities-internet already uses for its
own legend and control rail. inset-inline-start:auto is part of the pair, not decoration: it
cancels any physical left inherited from a control container, which is what otherwise
stretches the box across the map once the end edge moves.
NO BACKTICKS IN THIS COMMENT: it lives inside a shadow-CSS template literal, where a backtick
ends the literal and everything after it is read as code. I wrote three here and broke the
file's parse; check:sfc-parse and check:no-free-identifiers both caught it. */
.rd-legend-map{ position:absolute; inset-inline-end:8px; inset-inline-start:auto; bottom:calc(var(--rd-attrib-clear,26px) + 8px); z-index:2;
background:#ffffffe8; border:1px solid var(--rd-line, #d1d4cd);
border-radius:var(--r-md,6px); padding:6px 8px; backdrop-filter:blur(3px); box-sizing:border-box; max-width:148px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }

View file

@ -1,19 +1,18 @@
// useNeighborT — My UPG Neighbors' UI text: library/i18n/locales/<lc>/neighbor.json (11 locales).
// Chrome only, by decision (dx-shepherd, 2026-09-26): research terms, provenance lines and proper
// nouns (SEND, doxa-2000, Bounded set, JP, PPLR…) stay in English as named sources.
// i18n is OPTIONAL: useI18n() throws on a host that never installed it, so the English catalog is
// the fallback, never a blank or a raw key. Call it inside setup().
import { useI18n } from 'vue-i18n'
import enNeighbor from '@map/i18n/locales/en/neighbor.json'
const enLookup = (key) => String(key).replace(/^neighbor\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enNeighbor)
const fill = (str, params) => (params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)) : str)
// i18n is OPTIONAL: useI18n() throws on a host that never installed it, so the catalogue is the
// fallback, never a blank or a raw key. Call it inside setup().
//
// C4: this file and pplr-data-maps' usePplrT.js were whole-file near-duplicates at Jaccard 0.82 —
// the curation audit's proof that the guard was being COPIED, not shared. Both now call
// library/composables/useOptionalT.js. The local `import enNeighbor from
// '@map/i18n/locales/en/neighbor.json'` went with it: tStatic already walks
// messages[locale].neighbor and already falls back to English.
//
// The NAME stays. Every caller keeps `useNeighborT()`, so this migration changes no call site.
import { useOptionalT } from '@map/composables/useOptionalT.js'
export function useNeighborT() {
try {
const i18n = useI18n()
return (key, params) => { const v = i18n.t(key, params || {}); return v === key ? fill(enLookup(key) ?? key, params) : v }
} catch (_) {
return (key, params) => fill(enLookup(key) ?? String(key), params)
}
return useOptionalT('neighbor')
}

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>
<!-- Bundle IIFE — registers the <all-people-groups> custom element. -->
<script src="../all-people-groups.js" integrity="sha384-5LNvGnmUe5u6nHd5bo4FdYuQ4PQxKEcE2h+v4JpigpxVYyBWkChaslX5OHpg4zVC" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -43,7 +43,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../../big-picture.js" integrity="sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <bp-big-picture> custom element. -->
<script src="../big-picture.js" integrity="sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <cities-internet-map> custom element. -->
<script src="../cities-internet.js" integrity="sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <country-posters-map> custom element. -->
<script src="../country-posters.js" integrity="sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../demo-map.js" integrity="sha384-FjzBgsqvMYvPZTBKFLdzl7/mICEmWbgf+XREF6qk3byKI3RRmPUFc2bXy8TcYfIb" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-nlkZeJFDIc718FpkJlZg3vsfXphzPaOBN/fyFVhVPTUcqlEHobdqwJJete8thIUx" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <demo-map> custom element. -->
<script src="../../demo-map.js" integrity="sha384-FjzBgsqvMYvPZTBKFLdzl7/mICEmWbgf+XREF6qk3byKI3RRmPUFc2bXy8TcYfIb" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-nlkZeJFDIc718FpkJlZg3vsfXphzPaOBN/fyFVhVPTUcqlEHobdqwJJete8thIUx" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -12,7 +12,7 @@
"bundle": "all-people-groups",
"scriptUrl": "./all-people-groups/all-people-groups.js",
"output": "dx-maps/all-people-groups/all-people-groups.js",
"integrity": "sha384-5LNvGnmUe5u6nHd5bo4FdYuQ4PQxKEcE2h+v4JpigpxVYyBWkChaslX5OHpg4zVC",
"integrity": "sha384-XaGcLOf57NmhBSXFdx1jkgZPNNmMOUmdUxvCcaJ7rVgixyYx+ZHb+6e9SIwOjipD",
"elements": [
"all-people-groups"
],
@ -28,7 +28,7 @@
"scriptUrl": "./all-people-groups/all-people-groups.js",
"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>",
"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-5LNvGnmUe5u6nHd5bo4FdYuQ4PQxKEcE2h+v4JpigpxVYyBWkChaslX5OHpg4zVC\" 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-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>"
},
"bundle": "all-people-groups"
}
@ -38,7 +38,7 @@
"bundle": "big-picture",
"scriptUrl": "./big-picture/big-picture.js",
"output": "dx-maps/big-picture/big-picture.js",
"integrity": "sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP",
"integrity": "sha384-CxP2odnbw+jO7dkZ9M7NfxIdcHkWBCCYX1QqWXFwiLELflTBcgjyyWvmKOF+Jaxm",
"elements": [
"bp-big-picture",
"bp-research-header",
@ -60,7 +60,7 @@
"scriptUrl": "./big-picture/big-picture.js",
"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>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-m3ZXEY2hDwpe+EHCKPWcsYdbsaAOpQ/8XyB4oTYL8H/6tYUeKups1lNvGP8oA9WP\" 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-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>"
},
"instances": [
{
@ -104,7 +104,7 @@
"bundle": "cities-internet",
"scriptUrl": "./cities-internet/cities-internet.js",
"output": "dx-maps/cities-internet/cities-internet.js",
"integrity": "sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe",
"integrity": "sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH",
"elements": [
"cities-internet-map"
],
@ -120,7 +120,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -134,7 +134,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/connectivity-map/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"connectivity-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
},
{
@ -148,7 +148,7 @@
"scriptUrl": "./cities-internet/cities-internet.js",
"embed": {
"iframe": "<iframe src=\"./cities-internet/data-tables/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=\"./cities-internet/cities-internet.js\" integrity=\"sha384-9ZJk5RqZiiNbvFjaz5+H2RvFYmVUD22h3i5L0GWHAwT1cqz8gjLoYL+XpG1+KUwe\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./cities-internet/cities-internet.js\" integrity=\"sha384-tiTYSH8Ogfjdqa2vfpp8w7aN1UxY0rR+sfpm/fGvn9qE+QZoI3cLBjZCvtqh+jvH\" crossorigin=\"anonymous\"></script>\n<cities-internet-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></cities-internet-map>"
}
}
]
@ -157,7 +157,7 @@
"bundle": "country-posters",
"scriptUrl": "./country-posters/country-posters.js",
"output": "dx-maps/country-posters/country-posters.js",
"integrity": "sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C",
"integrity": "sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX",
"elements": [
"country-posters-map"
],
@ -177,7 +177,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/countries-map/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=\"./country-posters/country-posters.js\" integrity=\"sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-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=\"./country-posters/country-posters.js\" integrity=\"sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"countries-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -191,7 +191,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-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=\"./country-posters/country-posters.js\" integrity=\"sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./country-posters/country-posters.js\" integrity=\"sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-poster-wall\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
},
{
@ -205,7 +205,7 @@
"scriptUrl": "./country-posters/country-posters.js",
"embed": {
"iframe": "<iframe src=\"./country-posters/country-posters-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=\"./country-posters/country-posters.js\" integrity=\"sha384-twlsREX+mrYcmklsRuezHWuSu4WzGjCykpkY9g34C8loKbtWois1sELuSF/eUd+C\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-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=\"./country-posters/country-posters.js\" integrity=\"sha384-92Nh2dZlGMtT7LyH8322VUjf/FsTbPNFBV/byLsa/3oKjeLdEK+VFhvcorkYsxUX\" crossorigin=\"anonymous\"></script>\n<country-posters-map profile-config='{\"profile\":\"country-posters-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></country-posters-map>"
}
}
]
@ -214,7 +214,7 @@
"bundle": "dashboard-plus-grid",
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"output": "dx-maps/dashboard-plus-grid/dashboard-plus-grid.js",
"integrity": "sha384-aEXRsVorVuFmdtMLGu09mEj6odlTMSYIEjfoBPXi3B1c3cFLlcHJDi/pbgbTBtuG",
"integrity": "sha384-ILM6z2i4Q9M6GDfQ22b8IW1ZfAM/zajTXWMwYzQeYni25jlQAWlzvnLmXdI715/4",
"elements": [
"dashboard-plus-grid"
],
@ -234,7 +234,7 @@
"scriptUrl": "./dashboard-plus-grid/dashboard-plus-grid.js",
"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>",
"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-aEXRsVorVuFmdtMLGu09mEj6odlTMSYIEjfoBPXi3B1c3cFLlcHJDi/pbgbTBtuG\" 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-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>"
},
"instances": [
{
@ -258,7 +258,7 @@
"bundle": "demo-map",
"scriptUrl": "./demo-map/demo-map.js",
"output": "dx-maps/demo-map/demo-map.js",
"integrity": "sha384-FjzBgsqvMYvPZTBKFLdzl7/mICEmWbgf+XREF6qk3byKI3RRmPUFc2bXy8TcYfIb",
"integrity": "sha384-nlkZeJFDIc718FpkJlZg3vsfXphzPaOBN/fyFVhVPTUcqlEHobdqwJJete8thIUx",
"elements": [
"demo-map"
],
@ -274,7 +274,7 @@
"scriptUrl": "./demo-map/demo-map.js",
"embed": {
"iframe": "<iframe src=\"./demo-map/demo-map/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=\"./demo-map/demo-map.js\" integrity=\"sha384-FjzBgsqvMYvPZTBKFLdzl7/mICEmWbgf+XREF6qk3byKI3RRmPUFc2bXy8TcYfIb\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-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=\"./demo-map/demo-map.js\" integrity=\"sha384-nlkZeJFDIc718FpkJlZg3vsfXphzPaOBN/fyFVhVPTUcqlEHobdqwJJete8thIUx\" crossorigin=\"anonymous\"></script>\n<demo-map profile-config='{\"profile\":\"demo-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></demo-map>"
},
"instances": [
{
@ -353,7 +353,7 @@
"bundle": "doxa-research-map",
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"output": "dx-maps/doxa-research-map/doxa-research-map.js",
"integrity": "sha384-7E8ejOUNgxdBp16k40xyyk9UQfue+Yv/CGDMnAL8j94pCihf3GtmiTNO/6LAvoRP",
"integrity": "sha384-r2Ntm0UAR6OVoZwrRWjh4Y73whd7DUGsM2IlKWak1w6DRJQN7PReZi2y3QgRYrqu",
"elements": [
"doxa-research-map"
],
@ -369,7 +369,7 @@
"scriptUrl": "./doxa-research-map/doxa-research-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-research-map/research-map/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=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-7E8ejOUNgxdBp16k40xyyk9UQfue+Yv/CGDMnAL8j94pCihf3GtmiTNO/6LAvoRP\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-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=\"./doxa-research-map/doxa-research-map.js\" integrity=\"sha384-r2Ntm0UAR6OVoZwrRWjh4Y73whd7DUGsM2IlKWak1w6DRJQN7PReZi2y3QgRYrqu\" crossorigin=\"anonymous\"></script>\n<doxa-research-map profile-config='{\"profile\":\"research-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-research-map>"
},
"instances": [
{
@ -395,7 +395,7 @@
"bundle": "doxa-simple-map",
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"output": "dx-maps/doxa-simple-map/doxa-simple-map.js",
"integrity": "sha384-3if9+nzL0n3CYZ2tXjI9AE0geIJ7gmF91GQScy+v+U4/RYJ/4ZUnp9ZvJ9WdRVio",
"integrity": "sha384-UeT9FGcOIfUdlQHKiX7b6EzOIazZplsbPDJ+B/khoCbd3hYl5b72VEePBS8UH5Vo",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -412,7 +412,7 @@
"scriptUrl": "./doxa-simple-map/doxa-simple-map.js",
"embed": {
"iframe": "<iframe src=\"./doxa-simple-map/doxa-simple-map/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=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-3if9+nzL0n3CYZ2tXjI9AE0geIJ7gmF91GQScy+v+U4/RYJ/4ZUnp9ZvJ9WdRVio\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-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=\"./doxa-simple-map/doxa-simple-map.js\" integrity=\"sha384-UeT9FGcOIfUdlQHKiX7b6EzOIazZplsbPDJ+B/khoCbd3hYl5b72VEePBS8UH5Vo\" crossorigin=\"anonymous\"></script>\n<doxa-map profile-config='{\"profile\":\"doxa-simple-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-map>"
},
"instances": [
{
@ -578,7 +578,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-yMEw0MsVX7FExaOi9gCZmOL2lo/FN+beYrcnF2niCO2Ikdz01FMKiM8ipoyvFfaU",
"integrity": "sha384-yVLMMRrJvvr0kLDY/lhyT+QRhKdhpAlopst654Qz4vdD4nRL81yNYJCrf7/zN9w9",
"elements": [
"gospel-resources-map"
],
@ -598,7 +598,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/charts/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yMEw0MsVX7FExaOi9gCZmOL2lo/FN+beYrcnF2niCO2Ikdz01FMKiM8ipoyvFfaU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yVLMMRrJvvr0kLDY/lhyT+QRhKdhpAlopst654Qz4vdD4nRL81yNYJCrf7/zN9w9\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"charts\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -612,7 +612,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/data-tables/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yMEw0MsVX7FExaOi9gCZmOL2lo/FN+beYrcnF2niCO2Ikdz01FMKiM8ipoyvFfaU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yVLMMRrJvvr0kLDY/lhyT+QRhKdhpAlopst654Qz4vdD4nRL81yNYJCrf7/zN9w9\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"data-tables\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -626,7 +626,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/gospel-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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yMEw0MsVX7FExaOi9gCZmOL2lo/FN+beYrcnF2niCO2Ikdz01FMKiM8ipoyvFfaU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yVLMMRrJvvr0kLDY/lhyT+QRhKdhpAlopst654Qz4vdD4nRL81yNYJCrf7/zN9w9\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"gospel-dashboard\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -640,7 +640,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yMEw0MsVX7FExaOi9gCZmOL2lo/FN+beYrcnF2niCO2Ikdz01FMKiM8ipoyvFfaU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yVLMMRrJvvr0kLDY/lhyT+QRhKdhpAlopst654Qz4vdD4nRL81yNYJCrf7/zN9w9\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
},
{
@ -654,7 +654,7 @@
"scriptUrl": "./gospel-resources/gospel-resources.js",
"embed": {
"iframe": "<iframe src=\"./gospel-resources/resources-map/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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yMEw0MsVX7FExaOi9gCZmOL2lo/FN+beYrcnF2niCO2Ikdz01FMKiM8ipoyvFfaU\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-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=\"./gospel-resources/gospel-resources.js\" integrity=\"sha384-yVLMMRrJvvr0kLDY/lhyT+QRhKdhpAlopst654Qz4vdD4nRL81yNYJCrf7/zN9w9\" crossorigin=\"anonymous\"></script>\n<gospel-resources-map profile-config='{\"profile\":\"resources-map\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-resources-map>"
}
}
]
@ -663,7 +663,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-8ehq2MaYN5Xsk17ZXmugXjoAfSY0+kzO81TnZ3ocE0JcmczAe0fl0iDSR2m+UqLB",
"integrity": "sha384-84UD561U8Y1zMY1YPf6RRmICYDgxgV5lqLzRcZLk5LbqiyYhXOwjvHZavUffoF1V",
"elements": [
"my-upg-100-list-map"
],
@ -683,7 +683,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"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>",
"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-8ehq2MaYN5Xsk17ZXmugXjoAfSY0+kzO81TnZ3ocE0JcmczAe0fl0iDSR2m+UqLB\" 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-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>"
}
},
{
@ -697,7 +697,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"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>",
"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-8ehq2MaYN5Xsk17ZXmugXjoAfSY0+kzO81TnZ3ocE0JcmczAe0fl0iDSR2m+UqLB\" 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-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>"
}
},
{
@ -711,7 +711,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"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>",
"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-8ehq2MaYN5Xsk17ZXmugXjoAfSY0+kzO81TnZ3ocE0JcmczAe0fl0iDSR2m+UqLB\" 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-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>"
}
}
]
@ -720,7 +720,7 @@
"bundle": "people-groups-grid",
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"output": "dx-maps/people-groups-grid/people-groups-grid.js",
"integrity": "sha384-rgMFgukVmFCDPWYtJdcI0zoiKic20G+OA3OBrZcI2IZHNpC6uJzxjHQ/Ogj1aUsD",
"integrity": "sha384-DXo4RPMd0fbp2DWquXcbx5JAUkaDhN60k7vrBsbGU4vfIDS8d8PqjvCdVwDeSv25",
"elements": [
"people-groups-grid"
],
@ -736,7 +736,7 @@
"scriptUrl": "./people-groups-grid/people-groups-grid.js",
"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>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-rgMFgukVmFCDPWYtJdcI0zoiKic20G+OA3OBrZcI2IZHNpC6uJzxjHQ/Ogj1aUsD\" 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-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>"
},
"instances": [
{
@ -836,7 +836,7 @@
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"output": "dx-maps/pplr-data-maps/pplr-data-maps.js",
"integrity": "sha384-lAVHgQiuzCDArTgiqiOTJbMCFkpEj1arMB/d2FHHc9CuMEoivuMZxbzhkluiiVD1",
"integrity": "sha384-Wf5IA8IPewJbr1WvTJ6FsVRBaJUpZuhFDZalAW9v+H19fiqQ5wffns7hUc7WXdIJ",
"elements": [
"pplr-data-maps"
],
@ -852,7 +852,7 @@
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"embed": {
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/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=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-lAVHgQiuzCDArTgiqiOTJbMCFkpEj1arMB/d2FHHc9CuMEoivuMZxbzhkluiiVD1\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
"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=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-Wf5IA8IPewJbr1WvTJ6FsVRBaJUpZuhFDZalAW9v+H19fiqQ5wffns7hUc7WXdIJ\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
},
"bundle": "pplr-data-maps"
}
@ -887,7 +887,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-+EegDChXdx7L5wXx/ODee8uwg1WhDwSR7XyWJWCmd8Xd8qm19t+njBTkphRiBNpn",
"integrity": "sha384-6ntnALRXPq2p2MTj/JuhXzSpxQFGTYfI/JV58hWuHRTzf/MXeL4T0kkzFtAxn+BJ",
"elements": [
"regions-dashboard"
],
@ -907,7 +907,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"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>",
"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-+EegDChXdx7L5wXx/ODee8uwg1WhDwSR7XyWJWCmd8Xd8qm19t+njBTkphRiBNpn\" 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-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>"
}
},
{
@ -921,7 +921,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"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>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-+EegDChXdx7L5wXx/ODee8uwg1WhDwSR7XyWJWCmd8Xd8qm19t+njBTkphRiBNpn\" 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-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>"
}
},
{
@ -935,7 +935,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"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>",
"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-+EegDChXdx7L5wXx/ODee8uwg1WhDwSR7XyWJWCmd8Xd8qm19t+njBTkphRiBNpn\" 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-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>"
}
}
]
@ -944,7 +944,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-C87ouWRquXgEe4W9f1D4c2Zk8bdRiApdNxwzREPrbpjLiKUvLPJ6tIr+Rdu+oMgt",
"integrity": "sha384-9uLizaH0Q8GRKMZllin/7KWx1zwWp5foHO8uBaxv6gR4DSaw5KYNtJknesc2+g/v",
"elements": [
"research-dashboard-map"
],
@ -960,7 +960,7 @@
"scriptUrl": "./research-dashboard/research-dashboard.js",
"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>",
"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-C87ouWRquXgEe4W9f1D4c2Zk8bdRiApdNxwzREPrbpjLiKUvLPJ6tIr+Rdu+oMgt\" 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-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>"
},
"bundle": "research-dashboard"
}
@ -970,7 +970,7 @@
"bundle": "research-map-plus-grid",
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
"output": "dx-maps/research-map-plus-grid/research-map-plus-grid.js",
"integrity": "sha384-mQ5oaa4Ew2CSwX4Jt1zM2M+rLZfRS1TA/0irMWeoJmf+e+0eYIwFTEgUCaYrTN1n",
"integrity": "sha384-XyIzplVHeZOnYwSzrTi4LRS35Q4S7NruPFJtNxIxN7MmAhYmzfI2lGwBlOHTzAbp",
"elements": [
"research-map-plus-grid"
],
@ -990,7 +990,7 @@
"scriptUrl": "./research-map-plus-grid/research-map-plus-grid.js",
"embed": {
"iframe": "<iframe src=\"./research-map-plus-grid/research-map-plus-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=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-mQ5oaa4Ew2CSwX4Jt1zM2M+rLZfRS1TA/0irMWeoJmf+e+0eYIwFTEgUCaYrTN1n\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./research-map-plus-grid/research-map-plus-grid.js\" integrity=\"sha384-XyIzplVHeZOnYwSzrTi4LRS35Q4S7NruPFJtNxIxN7MmAhYmzfI2lGwBlOHTzAbp\" crossorigin=\"anonymous\"></script>\n<research-map-plus-grid profile-config='{\"profile\":\"research-map-plus-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></research-map-plus-grid>"
},
"instances": [
{
@ -1058,7 +1058,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-WpUGlUsRPS2ut+hRYvcneTAi2ufDabQjm6k2tfloz/9ykqnliRD7ipsaGvnq34ti",
"integrity": "sha384-2eQCUQVWyddVJgljeE76ioH4vF003jiGlKvNgQoGdamXHkOjdZ/tN9ZipLO4FIJY",
"elements": [
"upg-neighbor"
],
@ -1074,7 +1074,7 @@
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"embed": {
"iframe": "<iframe src=\"./upg-neighbor/upg-neighbor/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=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-WpUGlUsRPS2ut+hRYvcneTAi2ufDabQjm6k2tfloz/9ykqnliRD7ipsaGvnq34ti\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
"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=\"./upg-neighbor/upg-neighbor.js\" integrity=\"sha384-2eQCUQVWyddVJgljeE76ioH4vF003jiGlKvNgQoGdamXHkOjdZ/tN9ZipLO4FIJY\" crossorigin=\"anonymous\"></script>\n<upg-neighbor profile-config='{\"profile\":\"upg-neighbor\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></upg-neighbor>"
},
"instances": [
{
@ -1157,7 +1157,7 @@
"bundle": "wall-of-people-groups",
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"output": "dx-maps/wall-of-people-groups/wall-of-people-groups.js",
"integrity": "sha384-7oNSqYDiXNW5yBq5NpJwK7MwL4yJ8oLMnnrJBS/2Bu0WYpWjsQrqYmtbFMxjAbau",
"integrity": "sha384-QtpkRLm9y3siKeoKTfdmG/8Xi3S+TFSzjZ8aw35zjSwe4KenMTVpgFXC3Hjc7R8U",
"elements": [
"wall-of-people-groups"
],
@ -1173,7 +1173,7 @@
"scriptUrl": "./wall-of-people-groups/wall-of-people-groups.js",
"embed": {
"iframe": "<iframe src=\"./wall-of-people-groups/wall-of-people-groups/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=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-7oNSqYDiXNW5yBq5NpJwK7MwL4yJ8oLMnnrJBS/2Bu0WYpWjsQrqYmtbFMxjAbau\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./wall-of-people-groups/wall-of-people-groups.js\" integrity=\"sha384-QtpkRLm9y3siKeoKTfdmG/8Xi3S+TFSzjZ8aw35zjSwe4KenMTVpgFXC3Hjc7R8U\" crossorigin=\"anonymous\"></script>\n<wall-of-people-groups profile-config='{\"profile\":\"wall-of-people-groups\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></wall-of-people-groups>"
},
"bundle": "wall-of-people-groups"
}

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>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../doxa-research-map.js" integrity="sha384-7E8ejOUNgxdBp16k40xyyk9UQfue+Yv/CGDMnAL8j94pCihf3GtmiTNO/6LAvoRP" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-r2Ntm0UAR6OVoZwrRWjh4Y73whd7DUGsM2IlKWak1w6DRJQN7PReZi2y3QgRYrqu" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -33,7 +33,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-research-map> custom element. -->
<script src="../../doxa-research-map.js" integrity="sha384-7E8ejOUNgxdBp16k40xyyk9UQfue+Yv/CGDMnAL8j94pCihf3GtmiTNO/6LAvoRP" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-r2Ntm0UAR6OVoZwrRWjh4Y73whd7DUGsM2IlKWak1w6DRJQN7PReZi2y3QgRYrqu" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-3if9+nzL0n3CYZ2tXjI9AE0geIJ7gmF91GQScy+v+U4/RYJ/4ZUnp9ZvJ9WdRVio" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-UeT9FGcOIfUdlQHKiX7b6EzOIazZplsbPDJ+B/khoCbd3hYl5b72VEePBS8UH5Vo" crossorigin="anonymous"></script>
<script>
// 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>
<!-- Bundle IIFE — registers the <doxa-map> custom element. -->
<script src="../../doxa-simple-map.js" integrity="sha384-3if9+nzL0n3CYZ2tXjI9AE0geIJ7gmF91GQScy+v+U4/RYJ/4ZUnp9ZvJ9WdRVio" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-UeT9FGcOIfUdlQHKiX7b6EzOIazZplsbPDJ+B/khoCbd3hYl5b72VEePBS8UH5Vo" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

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