Merge main (studio landing 10) into go-int/work

The Research Gallery keeps main's source and bundle: per its owner (rega,
relaying the Driver) the board appears under Experimental, which the
gallery discovers from the manifest with no gallery change.
This commit is contained in:
Doxa Maps 2026-09-27 04:37:37 +00:00
commit 2b4457e157
148 changed files with 6709 additions and 1237 deletions

View file

@ -26,6 +26,12 @@ and a change here can break other maps.
`import … from '@map/components/ViewSelect.vue'`
- **BarChart** — BarChart.vue — THE SHARED HORIZONTAL BAR CHART (hand-rolled SVG, zero dependencies).
`import … from '@map/components/charts/BarChart.vue'`
- **ProgressBar** — ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
`import … from '@map/components/charts/ProgressBar.vue'`
- **DashboardFacetRail** — DashboardFacetRail (was Sidebar.vue) — RDASH redesign phase 2: the floating @map SemanticTreeLegend is DROPPED
`import … from '@map/components/dashboards/DashboardFacetRail.vue'`
- **DashboardFilterChips** — DashboardFilterChips — the live filterSet rendered as removable AND-stacked chips
`import … from '@map/components/dashboards/DashboardFilterChips.vue'`
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).
`import … from '@map/components/hints/PinHint.vue'`
- **ApproxPinNote** — ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
@ -84,8 +90,12 @@ and a change here can break other maps.
`import … from '@map/components/poster/slots/TitleSlot.vue'`
- **QaCorner** — QaCorner.vue — the embeddable QA corner widget (bottom-right of any map).
`import … from '@map/components/qa/QaCorner.vue'`
- **GoalCard** — GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
`import … from '@map/components/regions/GoalCard.vue'`
- **RegionSectionCard** — RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
`import … from '@map/components/regions/RegionSectionCard.vue'`
- **StatCard** — StatCard.vue — one figure with a caption, on the DX-life card surface.
`import … from '@map/components/regions/StatCard.vue'`
- **FilterDropdown** — FilterDropdown.vue — SHARED (@map/components/search/FilterDropdown.vue).
`import … from '@map/components/search/FilterDropdown.vue'`
- **SearchBar** — SearchBar.vue — SHARED (@map/components/search/SearchBar.vue).
@ -117,6 +127,8 @@ and a change here can break other maps.
`import … from '@map/composables/retryableAsyncComponent.js'`
- **useAvatarGenerator** — Avatar Generator Composable
`import … from '@map/composables/useAvatarGenerator.js'`
- **useCardSurface** — useCardSurface.js — the DX-life card surface, as ONE rule set shared by every card component.
`import … from '@map/composables/useCardSurface.js'`
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
`import … from '@map/composables/useCountryOutline.js'`
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
@ -129,6 +141,8 @@ and a change here can break other maps.
`import … from '@map/composables/useDocumentFont.js'`
- **useEdgeScrollAffordance** — useEdgeScrollAffordance — the gray "you can scroll past this" edge strips.
`import … from '@map/composables/useEdgeScrollAffordance.js'`
- **useFacetAggregation** — useFacetAggregation.js — count rows by a facet accessor, and roll the tail into one bucket.
`import … from '@map/composables/useFacetAggregation.js'`
- **useGeocoderSearch** — Shared geocoder→map search logic (kind-switch + Mapbox filter builder + the
`import … from '@map/composables/useGeocoderSearch.js'`
- **useMapAttribution** — useMapAttribution.js — the data-credit line every map MUST carry.
@ -194,6 +208,8 @@ and a change here can break other maps.
`import … from '@map/utils/colocation.js'`
- **countries-meta** — countries-meta.js — a PORT of DX-life's config/countries-meta.ts (326 lines), the rollup
`import … from '@map/utils/countries-meta.js'`
- **countryName** — countryName.js — a country's name in the reader's language.
`import … from '@map/utils/countryName.js'`
- **embedBase** — embedBase.js — Where THIS bundle's built tree is served from, resolved at runtime.
`import … from '@map/utils/embedBase.js'`
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
@ -235,6 +251,8 @@ and a change here can break other maps.
`import … from '@map/data/country-posters.json'`
- **iso-numeric-to-alpha2** — "4":"AF","8":"AL","12":"DZ","24":"AO","32":"AR","36":"AU","40":"AT","50":"BD",
`import … from '@map/data/iso-numeric-to-alpha2.json'`
- **iso3-to-alpha2** — "_meta": {
`import … from '@map/data/iso3-to-alpha2.json'`
- **langFamilyByIso** — "aar": "Afro-Asiatic",
`import … from '@map/data/langFamilyByIso.json'`
- **langFamilyByLanguage** — {"Chamacoco":"Zamucoan","Ninam":"Yanomaman","Yagua":"Yaguan","Muinane":"Witotoan","Finnish":"Uralic","Hill Mar

View file

@ -127,6 +127,10 @@ const CONSUMERS = [
]
const SOURCES = [
'plugin-designs/research-dashboard/src/composables/useDashboardAggregations.js',
// kit landing 4c-1 split the generic counting half out of the file above. Its palettes STAYED, so
// this gate's subject did not move — but the new module is scanned too, so a private palette
// added to kit code in future is caught on the same rule rather than escaping into the library.
'library/composables/useFacetAggregation.js',
'plugin-designs/research-dashboard/src/composables/useTreeLegendColors.js',
]

View file

@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* check-package-json-unique-keys.mjs — no object in package.json may carry the same key twice.
*
* WHY: JSON.parse keeps the LAST of two equal keys and says nothing, so a duplicate silently kills the
* first entry. "check:religion-label-not-value" sat twice in //scripts, one description dead, while
* check:doc-scripts passed (it reads the parsed object, where only one survives). It was a merge artifact:
* the landing tooling unions both sides' package.json additions. Found by regions-plugin; only Bun's parser
* warned, while running something unrelated.
*
* HOW: a small JSON scanner that tracks every object's keys by position, so it sees what JSON.parse
* discards. The self-test runs on every invocation (a poisoned sample must fail), so the gate cannot go blind.
*/
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = process.env.DOXA_REPO || join(dirname(fileURLToPath(import.meta.url)), '..')
/** Every duplicated key, as "<path>: <key>". Assumes text that JSON.parse accepts. */
export function duplicateKeys(text) {
const out = []
const stack = [] // { keys:Set, path, isObj, expectKey }
let i = 0
const readString = () => {
let s = ''; i++
while (text[i] !== '"') { if (text[i] === '\\') { s += text[i] + text[i + 1]; i += 2 } else s += text[i++] }
i++
return s
}
let lastKey = null
while (i < text.length) {
const c = text[i]
const top = stack[stack.length - 1]
if (c === '{' || c === '[') {
const path = top ? `${top.path}${top.isObj ? '.' + lastKey : '[]'}` : '$'
stack.push({ keys: new Set(), path, isObj: c === '{', expectKey: c === '{' })
i++
} else if (c === '}' || c === ']') { stack.pop(); i++ }
else if (c === '"') {
const s = readString()
if (top && top.isObj && top.expectKey) {
if (top.keys.has(s)) out.push(`${top.path}: ${s}`)
top.keys.add(s); lastKey = s; top.expectKey = false
}
} else if (c === ',') { if (top && top.isObj) top.expectKey = true; i++ }
else i++
}
return out
}
// Self-test, every run: a duplicate at depth must be caught, and a clean sample must pass.
const poisoned = '{"scripts":{"a":"x","b":"y"},"//scripts":{"a":"1","c":"2","a":"3"},"v":["a","a"]}'
const selfBad = duplicateKeys(poisoned)
if (selfBad.length !== 1 || !selfBad[0].includes('a')) {
console.error(`PACKAGE-JSON-UNIQUE-KEYS: self-test FAIL — expected exactly one duplicate in the poisoned sample, got ${JSON.stringify(selfBad)}`)
process.exit(3)
}
if (duplicateKeys('{"a":{"b":1},"c":{"b":2},"d":"\\"b\\""}').length) {
console.error('PACKAGE-JSON-UNIQUE-KEYS: self-test FAIL — a clean sample (same key in SIBLING objects, a quoted key in a value) was reported')
process.exit(3)
}
const text = readFileSync(join(ROOT, 'package.json'), 'utf8')
JSON.parse(text)
const dups = duplicateKeys(text)
if (dups.length) {
console.error(`PACKAGE-JSON-UNIQUE-KEYS: FAIL — ${dups.length} duplicated key(s); JSON.parse silently keeps only the last:`)
for (const d of dups) console.error(' ✗ ' + d)
process.exit(1)
}
console.log('PACKAGE-JSON-UNIQUE-KEYS: PASS — every object in package.json has unique keys (self-test: a poisoned duplicate is caught).')

View file

@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* check-setup-order.mjs — code that RUNS during setup must not be written below what it reads.
*
* Two arms, one root cause: a line's POSITION in a setup block is not the same as the MOMENT it
* runs, and both defects below shipped because the author read the file top-to-bottom and assumed
* the runtime would too. Both were found in the Driver's own console, in a gallery tab, on code
* that built clean, parsed clean and passed every other gate in this tree.
*
* ── ARM A: IMMEDIATE-TDZ ──────────────────────────────────────────────────────────────────────
* research-map.vue:2427 — `ReferenceError: Cannot access '_lastFitKey' before initialization`,
* and the whole map blanked, because setup threw.
*
* `watch(src, cb, { immediate: true })` runs cb DURING setup, at the line the watch() sits on.
* That watcher called applyFitSlugs(), a `function` declaration 350 lines further down — which is
* hoisted, so the CALL resolved fine. Its constants were not: `let _lastFitKey` and seven
* `const FIT_*` above it are lexical, still in their temporal dead zone, and the first read threw.
*
* THE HOISTING ASYMMETRY IS THE WHOLE TRAP. A hoisted function is a promise that the NAME
* resolves early, not that its BODY can run early. So the dangerous edge is not "does the watcher
* read something below it" — that is easy to see — it is "does the watcher CALL something that
* reads something below it", one level of indirection away, and invisible at both ends: the
* watcher looks like it only touches the store, and the function looks like it only touches
* constants that are obviously right there beside it.
*
* ── ARM B: HOOK-AFTER-AWAIT ───────────────────────────────────────────────────────────────────
* DashboardMapPanel.vue:1233 — `onBeforeUnmount is called when there is no active component
* instance` (a warning, which is why it survived), and the cleanup silently never ran.
*
* Every lifecycle registrar closes over Vue's CURRENT INSTANCE, which exists only while setup is
* on the stack. `onMounted(async () => { await initializeMap(); ...; onBeforeUnmount(teardown) })`
* resumes after a microtask, with no current instance — so the registration is dropped and a
* ResizeObserver on document.body plus a window 'resize' listener outlive the component. The
* failure mode is the worst kind: nothing throws, the feature works, and the leak only shows up
* as a studio that gets slower the more tabs the reader has visited.
*
* FIX SHAPE, not just "move the line": the teardown cannot live next to the code that creates the
* thing, because that code is past the await. Declare the handle at setup scope and tear it down
* in one hook registered during setup — which is the pattern the same file already used for
* containerRO and attribRO, three lines away from the one that got it wrong.
*
* Both arms STRIP COMMENTS FIRST. This tree has been bitten five times by a check satisfied by a
* comment quoting the code it was looking for, and this file's own header names both symbols.
*
* USAGE: node internal/check-setup-order.mjs [--poison]
* EXIT: 0 = clean, 1 = a defect, 3 = the self-test failed (the gate cannot be trusted).
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, relative, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '..')
const POISON = process.argv.includes('--poison')
const HOOKS = [
'onMounted', 'onBeforeMount', 'onBeforeUnmount', 'onUnmounted', 'onUpdated', 'onBeforeUpdate',
'onActivated', 'onDeactivated', 'onErrorCaptured', 'onRenderTracked', 'onRenderTriggered',
'onServerPrefetch',
]
const HOOK_RE = new RegExp('\\b(' + HOOKS.join('|') + ')\\s*\\(', 'g')
/* Blank out comments and string bodies in place, so every index stays where it was. */
const blank = (m) => m.replace(/[^\n]/g, ' ')
const stripComments = (src) => src
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(/\/\*[\s\S]*?\*\//g, blank)
.replace(/(^|[^:\\'"`/])\/\/[^\n]*/g, (m, p) => p + ' '.repeat(m.length - p.length))
/* Blank the TEXT of every literal while keeping `${...}` interpolations, which are code.
* One state machine rather than three regexes, because the three quote styles nest: version 2 of
* this gate flagged four data-tables.vue files for "ensure() reads `table`" when the only `table`
* in ensure() is the word in `new Error(`table not found (${r.status})`)`. Prose in a template
* literal is exactly as inert as prose in a comment, and a regex cannot tell them apart. */
const stripStrings = (src) => {
const out = src.split('')
const stack = []
let i = 0
const blankAt = (n) => { if (src[n] !== '\n') out[n] = ' ' }
while (i < src.length) {
const c = src[i]
const top = stack[stack.length - 1]
if (top && (top.kind === 'sq' || top.kind === 'dq')) {
if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue }
if ((c === "'" && top.kind === 'sq') || (c === '"' && top.kind === 'dq') || c === '\n') { stack.pop(); i++; continue }
blankAt(i); i++; continue
}
if (top && top.kind === 'tpl') {
if (c === '\\') { blankAt(i); if (i + 1 < src.length) blankAt(i + 1); i += 2; continue }
if (c === '`') { stack.pop(); i++; continue }
if (c === '$' && src[i + 1] === '{') { stack.push({ kind: 'expr', depth: 0 }); i += 2; continue }
blankAt(i); i++; continue
}
if (c === "'") { stack.push({ kind: 'sq' }); i++; continue }
if (c === '"') { stack.push({ kind: 'dq' }); i++; continue }
if (c === '`') { stack.push({ kind: 'tpl' }); i++; continue }
if (top && top.kind === 'expr') {
if (c === '{') top.depth++
else if (c === '}') { if (top.depth === 0) { stack.pop(); i++; continue } top.depth-- }
}
i++
}
return out.join('')
}
/* Only the <script> half of an SFC can run at setup time; the template is a different phase. */
function scriptOf(src, file) {
if (!file.endsWith('.vue')) return src
const out = src.replace(/[^\n]/g, ' ').split('')
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g
let m
while ((m = re.exec(src))) {
for (let i = m.index + m[0].indexOf('>') + 1; i < m.index + m[0].length - 9; i++) out[i] = src[i]
}
return out.join('')
}
/* Brace-match forward from the first `{` at or after `from`. Returns [open, close] or null. */
function braceSpan(src, from) {
const open = src.indexOf('{', from)
if (open < 0) return null
let d = 0
for (let i = open; i < src.length; i++) {
if (src[i] === '{') d++
else if (src[i] === '}' && --d === 0) return [open, i]
}
return null
}
const lineOf = (src, i) => src.slice(0, i).split('\n').length
const namesInPattern = (pat) => String(pat)
.replace(/[{}[\]]/g, ' ')
.split(',')
.map((s) => s.replace(/:[\s\S]*$/, '').replace(/=[\s\S]*$/, '').replace(/\.\.\./, '').trim())
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n))
/* Names a span DECLARES for itself: any of these resolves locally, so reading one says nothing
* about a binding further down the file. Version 3 of this gate flagged charts.vue for "load()
* reads byCountry, declared at line 354" when load() destructures its OWN `byCountry` on its
* second line — a read of a shadowing local, 220 lines away from the const it appears to name.
* Shadowing is the normal case in this tree (short names, small helpers), not an exotic one. */
function localNames(span) {
const out = new Set()
const add = (pat) => { for (const n of namesInPattern(pat)) out.add(n) }
let m
const decl = /\b(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)/g
while ((m = decl.exec(span))) add(m[1])
const params = /\bfunction\s*[A-Za-z_$]?[\w$]*\s*\(([^)]*)\)|\(([^)]*)\)\s*=>|\bcatch\s*\(([^)]*)\)/g
while ((m = params.exec(span))) add(m[1] ?? m[2] ?? m[3])
const bare = /\b([A-Za-z_$][\w$]*)\s*=>/g
while ((m = bare.exec(span))) out.add(m[1])
return out
}
/* Identifiers a span READS FROM AN OUTER SCOPE: member names, object keys and anything the span
* declares itself are all removed, because none of them is a binding this scope has to resolve. */
function identsIn(span) {
const local = localNames(span)
const read = stripStrings(span)
.replace(/\.\s*[A-Za-z_$][\w$]*/g, ' ') // r.slug — a property, not a binding
.replace(/\b[A-Za-z_$][\w$]*\s*:/g, ' ') // { padding: … } — a key, not a binding
.match(/\b[A-Za-z_$][\w$]*\b/g) || []
return new Set(read.filter((n) => !local.has(n)))
}
/* Top-level (column-0) lexical declarations, which are the ones a setup block resolves. `var`
* hoists to undefined rather than throwing, so it is a different bug and not this gate's. */
function topLevelLexical(src) {
const out = []
const re = /^(?:const|let)\s+(?:\{([^}]*)\}|\[([^\]]*)\]|([A-Za-z_$][\w$]*))/gm
let m
while ((m = re.exec(src))) {
const names = m[3] ? [m[3]]
: String(m[1] || m[2]).split(',').map((s) => s.replace(/:.*$/, '').replace(/=.*$/, '').replace(/\.\.\./, '').trim())
for (const n of names) if (/^[A-Za-z_$][\w$]*$/.test(n)) out.push({ name: n, at: m.index })
}
return out
}
/* Top-level `function NAME(...) {}` declarations — hoisted, so reachable from an immediate watcher. */
function topLevelFunctions(src) {
const out = new Map()
const re = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/gm
let m
while ((m = re.exec(src))) {
const span = braceSpan(src, m.index)
if (span) out.set(m[1], { at: m.index, body: src.slice(span[0], span[1] + 1) })
}
return out
}
// ── ARM A ────────────────────────────────────────────────────────────────────────────────────────
function armA(code) {
const hits = []
const lexical = topLevelLexical(code)
const fns = topLevelFunctions(code)
const re = /\bimmediate\s*:\s*true\b/g
let m
while ((m = re.exec(code))) {
// Walk back to the watch()/watchEffect() this option belongs to: the nearest one before it.
const before = code.slice(0, m.index)
const start = Math.max(before.lastIndexOf('watch('), before.lastIndexOf('watchEffect('))
if (start < 0) continue
const span = code.slice(start, m.index)
const reads = identsIn(span)
// Declared BELOW the watcher = in its temporal dead zone while the immediate run is on the stack.
for (const d of lexical) {
if (d.at > start && reads.has(d.name)) {
hits.push({ line: lineOf(code, start), why: `immediate watcher reads '${d.name}', declared at line ${lineOf(code, d.at)}` })
}
}
// One level of indirection: a hoisted function it calls, whose own body reads something below.
for (const [name, fn] of fns) {
if (!reads.has(name)) continue
const inner = identsIn(fn.body)
for (const d of lexical) {
if (d.at > start && inner.has(d.name)) {
hits.push({ line: lineOf(code, start), why: `immediate watcher calls ${name}(), which reads '${d.name}', declared at line ${lineOf(code, d.at)}` })
}
}
}
}
return hits
}
// ── ARM B ────────────────────────────────────────────────────────────────────────────────────────
function armB(code) {
const hits = []
const re = /\basync\b/g
let m
while ((m = re.exec(code))) {
const span = braceSpan(code, m.index)
if (!span) continue
const body = code.slice(span[0], span[1] + 1)
const firstAwait = body.search(/\bawait\b/)
if (firstAwait < 0) continue
let h
const hre = new RegExp(HOOK_RE.source, 'g')
while ((h = hre.exec(body))) {
if (h.index > firstAwait) {
hits.push({ line: lineOf(code, span[0] + h.index), why: `${h[1]}() is registered after an await, so it never attaches` })
}
}
}
return hits
}
const run = (src, file) => {
/* Strings are blanked HERE, for both arms. Arm B's first version flagged useMapInstance.js:430 —
* where the only `onBeforeUnmount` on the line is inside the text of a console.error advising a
* caller to use one. A gate that reads prose as code cries wolf, and a crying gate gets ignored. */
const code = stripStrings(stripComments(scriptOf(src, file)))
return [...armA(code).map((h) => ({ ...h, arm: 'IMMEDIATE-TDZ' })), ...armB(code).map((h) => ({ ...h, arm: 'HOOK-AFTER-AWAIT' }))]
}
// ── self-test: both defects as they actually shipped, and the fixes, which must stay quiet ───────
const PLANTED_A = `<script setup>
const mapStore = inject('mapStore')
watch(() => cfg?.value?.externalFilters, (raw) => {
if ('fitSlugs' in raw) applyFitSlugs(raw.fitSlugs)
}, { deep: true, immediate: true })
const FIT_MAX_ZOOM = 5.5
let _lastFitKey = ''
function applyFitSlugs(slugs) {
const key = String(slugs)
if (key === _lastFitKey) return
_lastFitKey = key
fit({ maxZoom: FIT_MAX_ZOOM })
}
</script>`
const PLANTED_B = `<script setup>
onMounted(async () => {
await initializeMap()
const resync = () => sync(map.value)
window.addEventListener('resize', resync)
onBeforeUnmount(() => { window.removeEventListener('resize', resync) })
})
</script>`
/* The same two files after the fix, plus the shapes most likely to be mistaken for the defects:
* a NON-immediate watcher above its constants (legal — it cannot run until they exist), a hook
* registered BEFORE an await, and a comment that names the symbol the arms search for. */
const CLEAN = `<script setup>
console.error('containerRef.value is null - call initializeMap() inside onMounted()')
async function ensure(file) { if (!ok) throw new Error(\`table not found (\${file})\`) }
async function load() { const [pins, byCountry] = await all(); tables.value = { pins, byCountry } }
watch(profileConfig, load, { immediate: true })
const byCountry = computed(() => series(tables.value))
watch(current, (t) => t && ensure(t.file), { immediate: true })
const table = computed(() => cache.value[current.value?.file] || null)
const FIT_MAX_ZOOM = 5.5
let _lastFitKey = ''
watch(() => cfg?.value?.externalFilters, (raw) => {
if ('fitSlugs' in raw) applyFitSlugs(raw.fitSlugs)
}, { deep: true, immediate: true })
watch(() => store.other, () => { later({ maxZoom: LATE_ZOOM }) })
const LATE_ZOOM = 3
function applyFitSlugs(slugs) {
if (String(slugs) === _lastFitKey) return
_lastFitKey = String(slugs)
fit({ maxZoom: FIT_MAX_ZOOM, padding: 70 })
}
let scrollRO = null
onBeforeUnmount(() => { if (scrollRO) scrollRO.disconnect() })
onMounted(async () => {
await initializeMap()
scrollRO = new ResizeObserver(() => sync(map.value))
})
</script>`
const blindA = run(PLANTED_A, 'x.vue').filter((h) => h.arm === 'IMMEDIATE-TDZ').length === 0
const blindB = run(PLANTED_B, 'x.vue').filter((h) => h.arm === 'HOOK-AFTER-AWAIT').length === 0
const noisy = run(CLEAN, 'x.vue')
if (blindA || blindB || noisy.length) {
console.error('SETUP-ORDER: SELF-TEST FAILED — '
+ (blindA ? 'arm A is blind to the shipped TDZ. ' : '')
+ (blindB ? 'arm B is blind to the shipped late hook. ' : '')
+ (noisy.length ? 'false positives on the fixed code: ' + noisy.map((h) => `${h.arm} ${h.why}`).join('; ') : ''))
process.exit(3)
}
if (POISON) { console.log('SETUP-ORDER: self-test passed (both defects caught, both fixes quiet).'); process.exit(0) }
// ── scan ─────────────────────────────────────────────────────────────────────────────────────────
const ROOTS = ['plugin-designs', 'library']
const SKIP = /(^|\/)(node_modules|dist|public|\.git|fixtures)(\/|$)/
function files(dir, acc = []) {
let entries
try { entries = readdirSync(dir) } catch (_) { return acc }
for (const e of entries) {
const p = join(dir, e)
if (SKIP.test(relative(REPO, p))) continue
let st
try { st = statSync(p) } catch (_) { continue }
if (st.isDirectory()) files(p, acc)
else if (/\.(vue|js|mjs)$/.test(e)) acc.push(p)
}
return acc
}
const found = []
for (const root of ROOTS) {
for (const f of files(resolve(REPO, root))) {
let src
try { src = readFileSync(f, 'utf8') } catch (_) { continue }
if (!/\bimmediate\s*:\s*true\b|\basync\b/.test(src)) continue
for (const h of run(src, f)) found.push({ file: relative(REPO, f), ...h })
}
}
if (!found.length) {
console.log('SETUP-ORDER: OK — no immediate watcher reads below itself, and no lifecycle hook is registered after an await.')
process.exit(0)
}
console.error(`SETUP-ORDER: ${found.length} defect(s) —`)
for (const h of found) console.error(` ${h.arm} ${h.file}:${h.line} ${h.why}`)
console.error('\nARM A: move the declaration above the watcher (a hoisted function resolves its NAME early, not its BODY).')
console.error('ARM B: declare the handle at setup scope and tear it down in a hook registered BEFORE the first await.')
process.exit(1)

View file

@ -0,0 +1,203 @@
#!/usr/bin/env node
// check-shadow-css-parses.mjs — `bun run check:shadow-css-parses`. Source-only, headless, no network.
//
// LINE COMMENTS, NOT A BLOCK COMMENT, and that is not a style preference: this file's own header
// quotes the CSS comment delimiters it exists to reason about, so wrapping it in /* */ ended the
// comment early and the gate would not even parse. A file about a reader getting confused by comment
// delimiters is an apt place to be bitten by one.
//
// Does the CSS we assemble as JS strings actually PARSE as CSS?
//
// THE GAP THIS CLOSES WAS MEASURED, not imagined. In REGLIB batch 1 a line-filtered deletion left 9
// orphaned declarations and braces at 12 open / 21 close in regions-list.vue -- and check:sfc-parse
// PASSED it, because that gate only proves the JS parses, and a broken stylesheet inside a valid
// string literal is still a valid string literal. studio confirmed independently that nothing in the
// tree validates CSS syntax: "check:legend-logical-insets reads declarations and would not notice
// unbalanced braces."
//
// ★ IT USES THE REAL PARSERS, AND THE THREE BUGS THAT FORCED THAT ARE THE POINT OF THIS COMMENT.
// The first version hand-rolled a JS lexer to pull the string literals out, and it was wrong three
// times in a row, each time producing a confident FALSE RED against a file that was fine:
// 1. it read /* CSS comments */ inside the CSS as stranded declarations -- the reader must speak
// the language of the text it reads, and this text is CSS, not JS;
// 2. it ran over a whole .vue file, so the apostrophe in GoalCard's prose header ("one caller's
// feature") opened a phantom string literal and it reported `</div>` from the TEMPLATE as CSS;
// 3. PosterWall.vue:518 has `.replace(/"/g, '&quot;')` -- a REGEX LITERAL CONTAINING A QUOTE, which
// no hand-rolled scanner can get right without resolving the regex-vs-division ambiguity.
// Three false reds from one shortcut. So the literals now come out of the Babel AST that the build
// itself parses: comments, template literals, regexes and apostrophes all stop being my problem.
// Only the CSS scan below is hand-written, because CSS is the thing actually being checked.
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse as sfcParse } from '@vue/compiler-sfc'
import { parse as babelParse } from '@babel/parser'
// Each copy of this repo grades ITS OWN tree: a hardcoded path once made one copy silently grade
// another worktree's files (check:no-abs-paths, 2026-09-24).
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..')
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
// ── the script block, via the SFC parser the build uses ─────────────────────────────────────────
function scriptsOf(path, src) {
if (!path.endsWith('.vue')) return [src]
const { descriptor } = sfcParse(src, { filename: path })
return [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean)
}
// ── every useShadowStyles(<css>, label) argument, folded to a string ────────────────────────────
// Returns { css, complete } -- complete:false means part of the argument was an identifier (an
// imported constant), so the text is PARTIAL and must not be scanned as if it were the whole sheet.
function shadowCssOf(script, filename) {
const ast = babelParse(script, { sourceType: 'module', plugins: ['jsx'], errorRecovery: true })
const found = []
// Fold a `'a' + 'b' + IDENT` expression into its literal parts, noting whether any part was not one.
function fold(node, acc) {
if (!node) return
if (node.type === 'StringLiteral') { acc.css += node.value; return }
if (node.type === 'TemplateLiteral') {
if (node.expressions.length) acc.complete = false
acc.css += node.quasis.map((q) => q.value.cooked ?? '').join('')
return
}
if (node.type === 'BinaryExpression' && node.operator === '+') { fold(node.left, acc); fold(node.right, acc); return }
acc.complete = false // an identifier, a call, a conditional -- not text we can read here
}
;(function walk(node) {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) { node.forEach(walk); return }
if (node.type === 'CallExpression' && node.callee && node.callee.name === 'useShadowStyles') {
const acc = { css: '', complete: true }
fold(node.arguments[0], acc)
found.push(acc)
}
for (const k of Object.keys(node)) {
if (k === 'loc' || k === 'leadingComments' || k === 'trailingComments') continue
walk(node[k])
}
})(ast.program)
return found
}
// ── scan a CSS string ───────────────────────────────────────────────────────────────────────────
// CSS comments and quoted values are consumed as their own token types: a /* */ is not a declaration
// and a brace inside content:'a{b' does not open a rule.
function scanCss(cssRaw) {
let depth = 0, chunk = '', open = 0, close = 0, i = 0
const orphans = [], emptySelectors = []
const n = cssRaw.length
while (i < n) {
const ch = cssRaw[i]
if (ch === '/' && cssRaw[i + 1] === '*') {
i += 2
while (i < n && !(cssRaw[i] === '*' && cssRaw[i + 1] === '/')) i++
i += 2
continue
}
if (ch === "'" || ch === '"') {
const q = ch
i++
while (i < n && cssRaw[i] !== q) { if (cssRaw[i] === '\\') i++; i++ }
i++
chunk += "''"
continue
}
if (ch === '{') {
open++
if (depth === 0 && !chunk.trim()) emptySelectors.push('(empty selector before "{")')
if (depth === 0 && /;/.test(chunk) && /[A-Za-z-]+\s*:/.test(chunk)) orphans.push(chunk.trim().slice(0, 90))
depth++; chunk = ''; i++
continue
}
if (ch === '}') { close++; depth = Math.max(0, depth - 1); chunk = ''; i++; continue }
chunk += ch
if (depth === 0 && ch === ';' && /[A-Za-z-]+\s*:/.test(chunk)) { orphans.push(chunk.trim().slice(0, 90)); chunk = '' }
i++
}
return { open, close, orphans, emptySelectors }
}
// ── SELF-TESTS: the scanner must be shown to FAIL on the damage it is meant to catch ────────────
console.log('SELF-TESTS (a scanner that cannot fail proves nothing)')
let r = scanCss('.a{ color:red; } @media (x){ .b{ color:blue; } }')
ok(r.open === r.close && !r.orphans.length, `valid CSS scans clean (${r.open}/${r.close} braces, 0 orphans)`)
r = scanCss('.a{ color:red; } padding:4px; margin:0; .b{ color:blue; }')
ok(r.orphans.length === 2, `NEG CONTROL: two stranded declarations are caught (found ${r.orphans.length}) -- the exact batch-1 damage`)
r = scanCss('.a{ color:red; } } }')
ok(r.close - r.open === 2, `NEG CONTROL: unbalanced braces are caught (${r.open} open / ${r.close} close)`)
r = scanCss(".a{ content:'a{b'; }")
ok(r.open === 1 && r.close === 1, `NEG CONTROL: a brace inside a quoted CSS value does not unbalance the count (${r.open}/${r.close})`)
r = scanCss('/* padding:4px; a comment that looks like a declaration */ .a{ color:red; }')
ok(!r.orphans.length, 'NEG CONTROL: a CSS comment containing "padding:4px;" is not read as a stranded declaration')
r = scanCss('.a{ background:url(data:image/svg+xml;base64,AA==); }')
ok(r.open === 1 && !r.orphans.length, 'a data: URL with a ; inside url() does not read as an orphan')
// the three extractor bugs, as regression tests against the files that exposed them
{
const apostrophe = "<!-- one caller's feature -->\n<script setup>\nuseShadowStyles('.a{ color:red; }', 'x')\n</script>\n<template><div class=\"b\"/></template>"
const got = scriptsOf('x.vue', apostrophe).flatMap((s) => shadowCssOf(s, 'x.vue')).map((a) => a.css).join('')
ok(got === '.a{ color:red; }', 'NEG CONTROL: an apostrophe in a .vue prose header cannot leak template text into the CSS')
}
{
const regexy = "const esc = (v) => String(v).replace(/\"/g, '&quot;')\nuseShadowStyles('.a{ color:red; }', 'x')"
const got = shadowCssOf(regexy, 'x.js').map((a) => a.css).join('')
ok(got === '.a{ color:red; }', 'NEG CONTROL: a regex literal containing a quote (PosterWall.vue:518) no longer desynchronises the read')
}
{
const partial = "import { A } from './a.js'\nuseShadowStyles(A + '.b{ color:red; }', 'x')"
const got = shadowCssOf(partial, 'x.js')
ok(got.length === 1 && got[0].complete === false,
'an argument built partly from an imported constant is reported PARTIAL, not scanned as a whole sheet')
}
// ── the tree ────────────────────────────────────────────────────────────────────────────────────
const walk = (d) => readdirSync(d).flatMap((f) => {
const p = join(d, f)
if (statSync(p).isDirectory()) return f === 'node_modules' || f.startsWith('.') ? [] : walk(p)
return /\.(vue|js)$/.test(f) ? [p] : []
})
const rel = (p) => p.slice(REPO.length + 1)
const files = [...walk(join(REPO, 'library')), ...walk(join(REPO, 'plugin-designs'))].sort()
console.log('\nSHADOW CSS, file by file')
let measured = 0
const partials = []
for (const f of files) {
const src = readFileSync(f, 'utf8')
if (!src.includes('useShadowStyles(')) continue
let blocks
try { blocks = scriptsOf(f, src).flatMap((s) => shadowCssOf(s, f)) }
catch (e) { console.log(` FAIL - ${rel(f)}: could not be parsed (${e.message.slice(0, 70)})`); fails++; continue }
for (const { css, complete } of blocks) {
if (!/[{}]/.test(css)) { partials.push(f); continue }
// ★ A PARTIAL ARGUMENT IS STILL SCANNED, and skipping it was a false green waiting to happen.
// region-dashboards.vue builds its sheet as DXLIFE_TOKENS + SHADOW_BOX_SIZING_RESET + literals,
// so the first cut of this check classified it PARTIAL and skipped it -- excluding the very file
// whose CSS I had just cut rules out of, which is the whole reason the check exists. The imported
// constants are complete rule sets, so the literal remainder must balance on its own; if one ever
// straddled a rule this would go red and the label below says where to look.
measured++
const { open, close, orphans, emptySelectors } = scanCss(css)
const note = complete ? '' : ' (literal segments only; imported constants scanned in their own module)'
if (open !== close || orphans.length || emptySelectors.length) {
console.log(` FAIL - ${rel(f)}: ${open} open / ${close} close brace(s)${note}`)
for (const o of orphans) console.log(` orphan declaration: ${o}`)
for (const e of emptySelectors) console.log(` ${e}`)
fails++
} else {
console.log(` ok - ${rel(f)}: ${open} rule(s), braces balanced, 0 orphans${note}`)
}
}
}
ok(measured > 0, `${measured} stylesheet(s) were actually scanned (0 would mean this check measured nothing)`)
if (partials.length) {
console.log('\n NO CSS LITERALS AT ALL (the argument is entirely an imported constant):')
for (const f of [...new Set(partials)]) console.log(` ${rel(f)}`)
}
console.log(fails ? `\nSHADOW-CSS: ${fails} FAIL(s)` : `\nSHADOW-CSS: ${measured} stylesheet(s) parse as CSS -- braces balanced, no stranded declarations`)
process.exit(fails ? 1 : 0)

View file

@ -0,0 +1,133 @@
<!--
ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
Ported from blocks/_progress-bar.scss plus RegionDetail.vue's scoped .prayer-coverage-segments
override. Three instances in the regions dashboard used it: one segmented bar with a three-key
legend, and two plain adopted/engaged bars.
★ ONE NUMBER FEEDS BOTH THE WIDTH AND THE LEGEND, which is the actual reason this is a component
and not two copies of markup. Before extraction the segment widths were computed from raw counts in
the template while the legend printed those counts separately, so a bar and the number beside it
could disagree and nothing would say so. Here a segment is { value, color, label } and the width
IS value/max -- they cannot drift apart, because there is only one figure.
★ SEGMENT COLOUR IS A VALUE, NOT A CLASS, and this is the ONE substitution in the port. Their
markup (and ours, before this) selected colours with .rd-seg--full / --partial / --none, three
prayer-specific class names. A shared component cannot know what "full prayer coverage" is, and
baking those three names in is exactly the monolith this extraction exists to undo. So the caller
passes a CSS colour -- the regions plugin passes the same var(--dx-accent) / var(--dx-partial) /
var(--dx-track) tokens those classes set, so every rendered colour is unchanged; only the mechanism
moved from a stylesheet rule to an inline background. The three --full/--partial/--none rules are
deleted rather than left behind, because a rule no selector can reach is not a fallback.
THE THREE TEMPLATE BRANCHES, and why there are no comments between them: a comment sitting between
v-if branches is a RENDERED NODE, which turns the component's root into a FRAGMENT
(<!--[-->...<!--]-->) instead of a single element. Measured by server-rendering it. So the branches
are bare and the explanation lives here:
• showLegend -> root is the ported .stack.stack--xs wrapper, 10px above the legend
• segments only -> root is the segmented .rd-bar
• neither -> root IS the plain .rd-bar, exactly as their cards 2 and 3 render it
DOM IS UNCHANGED FOR ALL THREE INSTANCES, deliberately, because the clone is measured against the
live site. With a legend the root is the ported .stack.stack--xs wrapper (10px between bar and
legend); without one the root IS the .rd-bar element, exactly as their cards 2 and 3 render it --
hence the three template branches rather than one uniform wrapper, which would have added a box
DX-life does not have to two of the three cards.
NOT ADDED HERE, ON PURPOSE: role="progressbar" / aria-valuenow. Neither their bar nor our port has
it, and inventing an accessibility tree the clone target does not have is a change to request, not
to slip in. Carded for a ruling instead.
CONTRACT: the containing shadow root carries the DX-life token layer (--dx-track, --dx-accent,
--spacing-*). No i18n is installed or required -- every string arrives pre-localized.
-->
<script setup>
import { computed } from 'vue'
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const props = defineProps({
/** The filled amount, in the same units as `max`. Ignored when `segments` is non-empty. */
value: { type: Number, default: 0 },
/** The denominator. A zero or missing max renders an empty bar rather than dividing by zero. */
max: { type: Number, default: 100 },
/** The single-value fill colour. Any CSS colour; defaults to the DX-life accent. */
color: { type: String, default: 'var(--dx-accent)' },
/**
* [{ value, color, label }] — when non-empty the bar is segmented. `value` is a RAW count over the
* same `max`, not a percentage: the component divides, so the legend and the width agree.
*/
segments: { type: Array, default: () => [] },
/** Render the legend built from `segments`. Has no effect without segments. */
legend: { type: Boolean, default: false },
/** aria-label for the track. Omitted entirely when empty, as their plain bars have none. */
label: { type: String, default: '' },
/** Formats a segment's value for the legend. The caller owns locale-dependent formatting. */
format: { type: Function, default: (n) => String(n) },
/** Between a legend key's label and its number. A prop because ':' is not universal punctuation. */
separator: { type: String, default: ': ' },
})
// RegionDetail.vue:28-30. Capped at 100 for the same reason theirs is: a bar wider than its
// track is a rendering bug, not a data story. Ported verbatim from the plugin's percentOfSubject,
// which existed only to serve these bars and is deleted there.
const percentOf = (count) => (props.max ? Math.min(100, ((count || 0) / props.max) * 100) : 0)
const isSegmented = computed(() => props.segments.length > 0)
const showLegend = computed(() => props.legend && isSegmented.value)
const pct = computed(() => percentOf(props.value))
const segStyles = computed(() => props.segments.map((s) => ({
width: percentOf(s.value) + '%',
background: s.color,
})))
useShadowStyles(
// ── .progress-bar: blocks/_progress-bar.scss. The HEIGHT comes from padding-top, not height
// -- clamp(15px, 6%, 25px) at [data-size="md"] -- so the track scales with its own width.
'.rd-bar{ position:relative; overflow:hidden; width:100%; border-radius:100px;' +
' background:var(--dx-track); border:none; padding-top:clamp(15px, 6%, 25px); }' +
'.rd-bar__slider{ position:absolute; top:0; height:100%; width:0; border-radius:100px;' +
' background:var(--dx-accent); transition:width 1s ease-out; }' +
// .prayer-coverage-segments overrides that padding with a real height (their own scoped rule)
'.rd-bar--segments{ display:flex; height:clamp(15px, 6cqw, 25px); padding-top:0; }' +
'.rd-seg{ height:100%; transition:width 1s ease-out; }' +
// The ported .stack.stack--xs that separates the bar from its legend by 10px. It is the root only
// when a legend is rendered, so a plain bar keeps their exact single-element shape.
'.rd-bar-stack{ display:flex; flex-direction:column; }' +
'.rd-bar-stack > * + *{ margin-top:10px; }' +
'.rd-legend{ display:grid; justify-content:center; gap:3.5px; font-size:var(--font-size-sm);' +
' font-weight:600; text-align:center; }' +
'.rd-legend span{ display:inline-flex; align-items:center; justify-content:center; gap:4px; }' +
'.rd-key{ display:inline-block; flex:0 0 auto; width:7.5px; height:7.5px; border-radius:100px; }' +
// ★ THE COMPONENT CARRIES ITS OWN REDUCED-MOTION GUARD, and it is not redundant. base/_reset.scss
// does this with a DOCUMENT-level universal selector, which styles nothing inside a shadow root, so
// the regions plugin re-ported it scoped to its own `.rd-root *`. That covers these bars only while
// they are inside .rd-root. A shared component used anywhere else would carry the 1s width
// transition with nothing suppressing it -- reintroducing the exact regression that was measured
// and fixed here (theirs 1s -> 1e-05s under emulated reduce, ours stayed 1s: a vestibular visitor
// got a full one-second sliding bar their own site suppresses). A component that ships an animation
// owes the guard for it; relying on an ancestor's reset is what makes a component non-portable.
'@media (prefers-reduced-motion: reduce){' +
' .rd-bar__slider, .rd-seg{ transition-duration:0.01ms !important; } }'
, 'progress-bar')
</script>
<template>
<div v-if="showLegend" class="rd-bar-stack">
<div class="rd-bar rd-bar--segments" :aria-label="label || undefined">
<div v-for="(s, i) in segStyles" :key="i" class="rd-seg" :style="s" />
</div>
<div class="rd-legend">
<span v-for="(s, i) in segments" :key="i">
<i class="rd-key" :style="{ background: s.color }" />{{ s.label }}{{ separator }}{{ format(s.value) }}
</span>
</div>
</div>
<div v-else-if="isSegmented" class="rd-bar rd-bar--segments" :aria-label="label || undefined">
<div v-for="(s, i) in segStyles" :key="i" class="rd-seg" :style="s" />
</div>
<div v-else class="rd-bar" :aria-label="label || undefined">
<div class="rd-bar__slider" :style="{ width: pct + '%', background: color }" />
</div>
</template>

View file

@ -15,20 +15,41 @@ const L = (key, en) => tStatic('dashboard.' + key) || en
* --rd-accent left rail. Reuses aggregate()/lbl() from @map/composables/useDashboardAggregations and
* the same per-row facet → bus.setFilter wiring.
*/
/**
* ── KIT LANDING 4c-3: MOVED HERE FROM plugin-designs/research-dashboard/ ──────────────────────
* The facet rail follows the chip bar (4c-2). Rendering is unchanged; what changed is where its two
* remaining plugin imports point. 4b had already removed the research COLOUR and LABEL tables (they
* stay in plugin-designs and arrive on dashboardConfig.legend), which left exactly two:
* useDashboardAggregations -> @map/composables/useFacetAggregation.js (extracted by 4c-1)
* useDashboardShell -> @map/composables/useDashboardSearch.js (extracted by 4a)
* Both repoint at the MODULE that owns the function rather than at a re-export: a library component
* reading a plugin's shim is the dependency this move exists to remove, and check:filter-multivalue
* already records why it follows the file — left at the plugin path it read the shim, found no
* predicate to object to, and PASSED VACUOUSLY.
*
* Unlike the chip bar, this component IS mounted and IS on screen (research-dashboard.vue), so the
* move is verifiable by eyes as well as by gate — and it needs them: it is 770 lines of tree, search
* and drag behaviour, and nothing about "the imports resolve" says the rail still opens four trees
* by default or that its search still narrows.
*/
import { inject, ref, reactive, computed, onMounted } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { aggregate, lbl } from '../../composables/useDashboardAggregations.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
import { useTreeLegendColors } from '../../composables/useTreeLegendColors.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
import { aggregate, lbl, otherLabel } from '@map/composables/useFacetAggregation.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
// ★ NO RESEARCH COLOUR IMPORTS (kit landing 4b). This component used to import
// useTreeLegendColors and useTreeLegendLabels, which are the DOXA palettes and the People code
// tables — research-specific, and they stay in plugin-designs. Importing them is what stopped this
// file from being able to move into the kit at all. It now declares what it needs and the shell
// supplies it on dashboardConfig.legend; see the note beside that provide().
const dash = inject('dashboardData', null)
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
// DataSourceManager-normalized features (same store the geocoder bars use) — the shape
// useAffinityBlockLegendData resolves labels from (affinityBlockLabel / clusterLabel / peopleGroupLabel).
const dataStore = inject('dataStore', null)
const searchSourceId = inject('searchSourceId', null)
// `dataStore` and `searchSourceId` were injected here ONLY to construct useTreeLegendColors and
// useTreeLegendLabels, and landing 4b removed both constructions. They are gone rather than left
// harmlessly in place: an unused inject is a claim about the host, and a kit component that appears
// to need a research dataStore is one a new dashboard will dutifully provide before finding out it
// was never read. The chip bar shed the same pair in 4c-2, for the same reason.
const filterSet = bus?.filterSet || {}
// facet-key -> row accessor (sourced from config) — drives both the count tally and setFilter.
@ -134,7 +155,12 @@ function isExpandedRaw(facet, value) {
function toRows(agg, facet, level) {
const max = agg.reduce((m, d) => Math.max(m, d.value), 0) || 1
return agg.map((d) => ({
facet, value: d.key, label: d.key, count: d.value, frac: d.value / max, level,
// ★ THE BUCKET'S KEY IS A SENTINEL, ITS LABEL IS A TRANSLATION (kit landing 4c-1).
// `value` stays d.key — including the English 'Other' — because the row's inertness is tested
// against it (`row.other` is set from it below, and the template refuses to make such a row
// clickable). Translating the value would make the bucket filterable in Arabic, on a value no
// row carries. Only the LABEL is projected.
facet, value: d.key, label: d.other ? otherLabel() : d.key, count: d.value, frac: d.value / max, level,
other: !!d.other, active: !d.other && bus.isFilterActive(facet, d.key),
// Carried per-ROW rather than looked up in the template, so the zero-row baseline snapshot
// (which spreads a row object) keeps the mark when a row is re-admitted.
@ -328,13 +354,24 @@ function addTrunks(rows) {
// The maps were built inline here, which made this rail the ONLY surface that projected them —
// the filter chip bar rendered the raw code. They now live in useTreeLegendLabels, next to
// useTreeLegendColors, and every People-facing surface reads that one copy.
const { affinityLabelMaps } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
// ★ THE KIT'S OWN NEUTRAL, NOT the research palette's. useTreeLegendColors exports a NEUTRAL with
// the same value today, and importing it would re-create the dependency this landing removes. The
// two are deliberately NOT coupled: a consumer with no palette gets a plainly uncoloured rail, and
// the research palette remains free to change its own neutral without reaching in here.
const KIT_NEUTRAL_SWATCH = '#c4c8c0'
// A dashboard that supplies no `legend` gets an honest degraded rail — uncoloured swatches and raw
// values — rather than a crash. That is the state go-int sees before it wires its own palette, and
// a half-configured dashboard should look plain, not broken.
const legend = computed(() => (cfg && cfg.legend) || null)
const colorFor = (...a) => (legend.value?.colorFor ? legend.value.colorFor(...a) : KIT_NEUTRAL_SWATCH)
const legendLabelFor = (facet, value) => (legend.value?.labelFor ? legend.value.labelFor(facet, value) : value)
// ── COLOURS: sourced from the cap-3 research map's colour strategies ────────────────────────
// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, via
// useTreeLegendColors — so the legend can never drift from the map. Colour is applied AFTER the
// rows exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched.
const { colorFor } = useTreeLegendColors()
// ── COLOURS: still the research map's strategies, now HANDED IN rather than imported ─────────
// Every hex in this rail comes from the SAME strategy modules the map's pins paint with, so the
// legend cannot drift from the map — but the rail no longer reaches for them itself. The shell
// supplies them as dashboardConfig.legend.colorFor (kit landing 4b), which is what lets this file
// become kit code while the DOXA palettes stay in plugin-designs. Colour is applied AFTER the rows
// exist and touches only `swatch`; `facet`/`value` (what the bus filters on) are untouched.
// A row inherits its ANCESTOR's colour, mirroring the map's semantic tree where one hue owns a
// whole subtree (useAffinityBlockLegendData.js:169 assigns one colour per bloc and reuses it for
@ -379,8 +416,14 @@ const sections = computed(() => (cfg.facetRail || []).map((s) => {
// For a tree-backed section (People), swap the aggregated CODE keys for the composable's human
// names — display only; facet/value/filter identity stays the code so filtering matches the map.
if (s.tree === 'affinity') {
const maps = affinityLabelMaps.value
rows = rows.map((r) => (r.other ? r : { ...r, label: (maps[r.facet] && maps[r.facet].get(r.value)) || r.label }))
// labelFor returns the VALUE unchanged when it has no mapping, so "did it map?" is
// `mapped !== r.value` — and an unmapped row keeps the label it already had, exactly as the
// affinityLabelMaps lookup did with its `|| r.label`.
rows = rows.map((r) => {
if (r.other) return r
const mapped = legendLabelFor(r.facet, r.value)
return mapped !== r.value ? { ...r, label: mapped } : r
})
}
rows = addColors(rows, s.axis, religionFamKeys.value)
// Trunks are computed LAST, over the FINAL row list, so the ├/└ glyphs reflect exactly what

View file

@ -1,8 +1,26 @@
<script setup>
/**
* FilterChips — RDASH phase 5: the live filterSet rendered as removable AND-stacked chips
* (Islam ✕ | South Asia ✕ | …) + Clear all. The chip bar IS the live query state (plan §INTERACTION)
* and is what CSV export reads later. Click delegation on the bar (defineCustomElement-safe).
* DashboardFilterChips — the live filterSet rendered as removable AND-stacked chips
* (Islam ✕ | South Asia ✕ | …) + Clear all. The chip bar IS the live query state and is what CSV
* export reads later. Click delegation on the bar, which is what makes it defineCustomElement-safe.
*
* ── KIT LANDING 4c-2: MOVED HERE FROM plugin-designs/research-dashboard/ ──────────────────────
* Nothing about the rendering changed. What made the move possible was landing 4b, which inverted
* the one dependency that blocked it: this file used to import useTreeLegendLabels — the People
* CODE → NAME tables, which are research-specific and STAY in plugin-designs — and a library
* component importing a plugin composable is exactly what check:no-cross-plugin-imports refuses.
* Now the resolver arrives on dashboardConfig.legend and this file needs no research anything.
*
* ★ AN EXPLICIT EXCEPTION TO library/components/WARNING.md, recorded so the next reader does not
* think the rule was missed. That file says a component graduates into the library only once MORE
* THAN ONE map uses it. This one is used by ZERO: the topbar dedup pass unmounted it when
* SearchFilterHeader became the single source of truth for showing and clearing filters, and it has
* stayed unmounted deliberately. It is here because the kit's promise runs the other way round — a
* NEW dashboard mounts kit components, so waiting for a second consumer before providing the first
* one is a deadlock. The honest consequence: its passing gate means "it mounts and degrades", not
* "it looks right", and nobody has SEEN it since the dedup. Anyone wiring it into a dashboard should
* expect to be its first real reader, and must not mount it alongside a SearchFilterHeader that is
* already showing chips — two chip rows over one filterSet is the bug the dedup removed.
*/
import { inject, computed } from 'vue'
import { tStatic } from '@map/i18n/index.js'
@ -12,14 +30,14 @@ import { tStatic } from '@map/i18n/index.js'
// takes the subtree with it.
const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { useTreeLegendLabels } from '../../composables/useTreeLegendLabels.js'
// No research label import (kit landing 4b) — the shell supplies it on dashboardConfig.legend.
const bus = inject('selectionBus', null)
const cfg = inject('dashboardConfig', {})
// Same store the facet rail reads — the source useAffinityBlockLegendData resolves People
// codes to human names from.
const dataStore = inject('dataStore', null)
const searchSourceId = inject('searchSourceId', null)
// `dataStore` and `searchSourceId` were injected here ONLY to construct useTreeLegendLabels, and
// landing 4b removed that construction. They are gone rather than left harmlessly in place: an
// unused inject is a claim about the host, and a kit component that appears to need a research
// dataStore is one a new dashboard will dutifully provide before finding out it was never read.
const filterSet = bus?.filterSet || {}
// ── People codes must not reach this chip ────────────────────────────────────────────────────
@ -29,7 +47,14 @@ const filterSet = bus?.filterSet || {}
// DISPLAY ONLY: `k` (the facet key) and the stored filterSet value are never rewritten — the bus
// matches the accessor's output byte-for-byte, and a chip that merely looked right while the
// table stopped responding would be a worse bug than the one being fixed.
const { labelFor } = useTreeLegendLabels(cfg, dataStore, searchSourceId)
// ★ THE UNCONFIGURED DEFAULT JOINS A LIST, because a filterSet value is an ARRAY once two values
// are selected (spec D1) and 4b's fallback returned it untouched — so `String(value)` printed
// "A012,A013", comma-jammed, while a dashboard that DID supply a resolver got "South Asian Peoples,
// Bengali Peoples". Configured and unconfigured should differ in whether CODES RESOLVE, never in
// punctuation: the second difference reads as a rendering bug rather than as missing configuration.
// Kept as one liftable expression on purpose — check:legend-mirrors pulls it out of this source and
// drives it, so "no resolver yields the raw value" is measured instead of asserted.
const labelFor = (facet, value) => (cfg?.legend?.labelFor ? cfg.legend.labelFor(facet, value) : (Array.isArray(value) ? value.join(', ') : value))
const chips = computed(() => Object.entries(filterSet).map(([k, v]) => ({
k,
text: String(labelFor(k, v)),

View file

@ -0,0 +1,156 @@
<!--
GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
The Driver named these directly: "the banners are the cards that tell you the prayer progress".
Their RegionDetail.vue renders three of them in a row -- Prayer Coverage, Adoption Progress,
Engagement Progress -- identical in structure and different only in their numbers, their bar, and
one dark variant. That is one component with props, which is why it is here and not three copies.
★ NAMED GoalCard, NOT PrayerGoalCard, and the deviation from the name in the order is deliberate:
nothing in this component's props or CSS is about prayer. The shepherd's name described the first
instance; a shared component named after one caller mis-describes it for every later one. Flagged
in the handoff rather than decided silently.
★ THE MIDDLE IS A SLOT. What sits between the figure and the goal line differs per instance -- a
segmented bar with a legend in one, a plain bar in the other two -- so the card does not import a
bar at all. It knows about a title, a figure, and a goal; the caller supplies the rest. This is the
same rule RegionSectionCard follows for its poster wall: one caller's feature must not become every
caller's API.
★ margin-top:auto IS THE WHOLE POINT OF THE LAYOUT, not a detail. It is what makes three cards
holding different amounts of copy align their goal lines flush along the bottom edge. The full
measurement history is on the rules below; it survived a probe that measured the pair as "same".
TWO TEMPLATE DETAILS, documented here rather than in the template, because a comment between nodes
is a rendered node and this card's DOM is compared against the markup it replaced:
• the figure and its caption sit in an UNCLASSED <div>. That grouping is what makes the card's
stack margin fall between the groups rather than between a number and its own label.
• the space after </strong> in the goal line is INTERPOLATED, not written as template whitespace,
so it cannot depend on the compiler's whitespace:condense behaviour.
CONTRACT, two things the host root must provide, exactly as for RegionSectionCard:
• the DX-life token layer (--dx-radius, --dx-elevated, --dx-dark, --spacing-*, --font-size-*).
• for the Cyrillic heading switch, a `lang` attribute on an ancestor INSIDE THE SAME shadow root.
A [lang] selector cannot see the host document's attribute across a shadow boundary, so a
consumer that does not set lang on an ancestor gets the default face and nothing else changes.
No i18n is installed or required: every string arrives pre-localized, including the goal label.
-->
<script setup>
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useCardSurface } from '../../composables/useCardSurface.js'
defineProps({
/** The card heading, already localized. */
title: { type: String, default: '' },
/** The headline figure, pre-formatted by the caller, e.g. "21 / 2,107". */
value: { type: [String, Number], default: '' },
/** The caption under the figure, already localized. */
valueLabel: { type: String, default: '' },
/** The goal sentence, already localized. Pinned to the bottom of the card. */
goal: { type: String, default: '' },
/** The bold lead-in to the goal sentence, e.g. "Goal". Omitted entirely when empty. */
goalLabel: { type: String, default: '' },
/** Follows goalLabel inside the <strong>. A prop because ':' is not universal punctuation. */
labelSeparator: { type: String, default: ':' },
/** 'secondary' is their dark-on-light variant, used by the first of the three cards. */
variant: { type: String, default: 'default' },
})
// .rd-card / --secondary, shared with StatCard under one stable label.
useCardSurface()
useShadowStyles(
// ★ THEIR .stack IS margin-top, AND THAT IS LOAD-BEARING — not a stylistic detail.
// composition/_stack.scss: `.stack > * + * { margin-top: var(--stack-spacing) }`, with
// `.stack--lg` setting that to --spacing-lg. RegionDetail.vue's scoped
// `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child,
// so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE.
// This was ported as `gap`, which margin cannot override: gap AND the auto both applied,
// so every goal card carried ~23.5px of space their cards do not have. The DRIVER saw it
// ('spcing is nto ths ame', naming these three cards). Ported as their mechanism.
'.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' +
'.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' +
// ── the heading. h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl).
// ★ THIS IS A SECOND COPY OF THE PLUGIN'S .rd-h2 RULE, under this card's own class name, and the
// duplication is deliberate rather than overlooked. Using .rd-h2 itself would put two identical
// selectors in one shadow root from two different files -- the shape that drifts silently the first
// time one is tuned. The plugin keeps .rd-h2 for the one heading it still owns (its table title).
// The real fix is a shared ported-globals module beside the token layer, which is a batch of its
// own; carded, not smuggled in here.
'.rd-goal-card__title{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; }' +
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
// GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed
// system face the browser picks.
// BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`,
// and a host page embedding this element may set either. A selector that matched only one would
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
'[lang="ru"] .rd-goal-card__title, [lang="ru-RU"] .rd-goal-card__title{' +
' font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// ── the figure. .font-size-4xl .font-weight-medium / .font-size-lg
// ★ NO line-height HERE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
// .font-size-4xl utility sets ONLY a font-size — so their number inherits body's 1.5. Ours forced
// 1.1, which was compensating for the base line-height we never ported (now on :host in
// dxlife-tokens.js). rega measured the cost: 24px of card height, their number ~12px lower and its
// label ~24px lower. Inheriting is the faithful port.
// ★ A CLAMP, LIKE THEIRS, AND IT RESOLVES THE NOWRAP WORRY RATHER THAN FIGHTING IT.
// utilities/_typography.scss gives .font-size-4xl
// font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
// and ours was a flat var(--font-size-4xl). Identical at 1440 and above (both sit on the ceiling),
// but below that ours never shrank -- MEASURED against their resolved values:
// 390px viewport theirs 38.15 ours 59.60 ours 21.46 too large
// 768px viewport theirs 46.08 ours 59.60 ours 13.52 too large
// 1024px and up theirs 59.60 ours 59.60 identical
// I had reported this as unchangeable because a figure must never split across lines in a 372px
// card. That was backwards: the clamp makes the figure SMALLER exactly where the wrap was observed.
// In a 372px container 6cqw is 22.3px, below the 2xl floor, so it resolves to 38.15px against the
// 59.60px that was breaking. The clamp helps the very problem the flat value was chosen for, and
// the nowrap stays as a belt.
// cqw not vw, consistent with every other clamp in this port (the container-vs-viewport
// substitution the Driver is still ruling on); at 768 that lands 42.4 against their 46.08.
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
' font-weight:500; }' +
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
'.rd-big-label{ margin:0; font-size:var(--font-size-lg); }' +
// ── the goal line. .region-goal — margin-top:auto, spelled at higher specificity than the stack
// rule above so the override is unambiguous rather than depending on source order.
// ★ margin-top:auto, AND IT IS LOAD-BEARING. Theirs is RegionDetail.vue:24 `.region-goal {
// margin-top: auto }` inside `<div class="stack stack--lg | card | text-center">`, and
// composition/_stack.scss makes .stack a flex COLUMN with `> * + * { margin-top:
// var(--stack-spacing) }`. So the auto OVERRIDES that child margin and pushes the goal line to the
// BOTTOM of the card. Ours was margin:0, which left it directly under the text above it.
// The visible consequence, and why it matters for a clone: the three goal cards hold different
// amounts of copy, so theirs align their goal lines flush along the cards' bottom edge while ours
// floated at three different heights.
// FOUND BY READING THEIR MARKUP after rega's spacing probe measured this pair as "same" -- which
// it is, at 1440, because with no free space in the card the auto resolves to 0. An equal computed
// margin hid a real difference, so the probe was right and incomplete at the same time.
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
'.rd-goal-card > .rd-goal{ margin-top:auto; }' +
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the UA's
// bold. The plugin's own `.rd-root strong` STAYS where it is: UupgsList renders a <strong> in the
// same shadow root and depends on it, so this is a second rule for this card's own subtree, not a
// move. Checked by reading the whole root rather than this card's markup -- a file-scoped grep
// would have called the plugin's copy dead and deleted a rule another component needs.
'.rd-goal strong{ font-weight:500; }'
, 'goal-card')
</script>
<template>
<div class="rd-card rd-goal-card" :class="{ 'rd-card--secondary': variant === 'secondary' }">
<h2 class="rd-goal-card__title">{{ title }}</h2>
<div>
<p class="rd-big">{{ value }}</p>
<p class="rd-big-label">{{ valueLabel }}</p>
</div>
<slot />
<p class="rd-goal"><strong v-if="goalLabel">{{ goalLabel }}{{ labelSeparator }}</strong>{{ goalLabel ? ' ' : '' }}{{ goal }}</p>
</div>
</template>

View file

@ -0,0 +1,63 @@
<!--
StatCard.vue — one figure with a caption, on the DX-life card surface.
Their RegionDetail.vue renders a row of these above the map: a big number and a small grey label,
centred on a small-padding card. That is the whole component, and it is the smallest genuinely
reusable piece of the regions dashboard.
★ THE GRID IS NOT IN HERE, AND THAT IS THE POINT. The plugin's `.rd-stats` container
(grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))) stays with the CONSUMER. How many
stats there are and how they wrap is a decision belonging to the page, not to one stat; baking a
grid into a single card would make it impossible to place one on its own, or two side by side, or
in a column. So a consumer supplies its own container and puts as many StatCards in it as it has.
VALUES ARE PRE-FORMATTED BY THE CALLER. `value` is a string or number and is rendered verbatim,
because number formatting is locale-dependent and the caller already knows the locale -- the
regions plugin passes formatNumber(n) through its own i18n. This component installs no i18n and
therefore works on a host that has none.
CONTRACT: the containing shadow root must carry the DX-life token layer (DXLIFE_TOKENS in the
regions plugin) and, for the figure to render in the intended face, the Bebas Neue @font-face --
which is DOCUMENT-scoped and so must be registered on the host document, not in the shadow root.
See useDocumentFont.js. Without the face the declared fallbacks apply; nothing breaks.
-->
<script setup>
import { useShadowStyles } from '../../composables/useShadowStyles.js'
import { useCardSurface } from '../../composables/useCardSurface.js'
defineProps({
/** The figure. Already formatted for the locale by the caller, e.g. "43,045,275". */
value: { type: [String, Number], default: '' },
/** The caption under the figure. Already localized by the caller. */
label: { type: String, default: '' },
})
// .rd-card / --small, shared with GoalCard under one stable label so only one copy is injected.
useCardSurface()
useShadowStyles(
// ── .region-stat: RegionDetail.vue's scoped block ──
'.rd-stat{ text-align:center; }' +
// ★ THE data-len SHRINK IS GONE, AND ITS OWN COMMENT SHOWED WHY. Their .region-stat__value is
// font-family-heading + font-size-3xl + line-height:1 and nothing else -- no such attribute exists
// in their markup. Ours was a shrink-to-fit workaround whose stated premise was "our condensed
// fallback is wider" than Bebas. That is STALE: Bebas is vendored now (assets/fonts/bebas-neue,
// glyph oracle 279 = 279 SAME), so there is no wider fallback to compensate for.
// Its other claim -- "every value DX-life itself renders keeps their size" -- was FALSIFIED by
// measurement: rega found "43,045,275" (10 chars, so it hit the 'long' bucket) rendered at
// 38.15px against their 47.68px on the same-width card, while the other three stats matched
// exactly. A rule whose own invariant is violated in practice is not protecting anything.
// Deleted WITH its mechanism -- the binding and lenBucket() too, not just the rules.
'.rd-stat__value{ margin:0; font-size:var(--font-size-3xl); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; }' +
'.rd-stat__label{ margin:var(--spacing-2xs) 0 0; font-size:var(--font-size-sm); color:var(--dx-muted); }'
, 'stat-card')
</script>
<template>
<div class="rd-card rd-card--small rd-stat">
<p class="rd-stat__value">{{ value }}</p>
<p class="rd-stat__label">{{ label }}</p>
</div>
</template>

View file

@ -0,0 +1,44 @@
/**
* useCardSurface.js — the DX-life card surface, as ONE rule set shared by every card component.
*
* WHY THIS IS A MODULE AND NOT A COPY IN EACH CARD. Vue only injects the ROOT component's <style>
* into a shadow root, so every library component that renders inside one carries its own CSS and
* injects it with useShadowStyles (see that file). StatCard and GoalCard are two components that
* need the SAME surface -- radius, elevation, padding, and the two variants -- and a copy in each is
* a rule set that will drift the first time one of them is tuned.
*
* THE STABLE LABEL IS THE WHOLE TRICK. useShadowStyles dedups on its label, so both cards (and any
* consumer that renders .rd-card markup of its own) can call useCardSurface() and exactly ONE
* <style data-shadow-styles="dxlife-card-surface"> lands per shadow root, no matter how many card
* components mount inside it. Calling it from N components is not N stylesheets.
*
* ★ THE CLASS NAMES KEEP THEIR `rd-` PREFIX, DELIBERATELY. It came from the regions-dashboard port
* and means nothing outside it. Renaming would change no pixel and would cost the traceability from
* each declaration back to the DX-life rule and the measurement that fixed it, which is the only
* reason these values can be defended. The names are internal to the components; a consumer writes
* <StatCard> and never types them. A rename is a safe later cleanup, not a prerequisite for reuse.
*
* CONTRACT: the shadow root must already carry the DX-life token layer (--dx-radius, --dx-elevated,
* --dx-text, --dx-dark, --dx-on-dark, --spacing-*). The regions plugin injects it as DXLIFE_TOKENS.
* Without it these rules resolve to nothing -- they are a skin over that scale, not a replacement.
*/
import { useShadowStyles } from './useShadowStyles.js'
export const CARD_SURFACE_CSS =
// ── .card: blocks/_cards.scss:9-21. radius --border-radius-xlg (35px);
// padding clamp(--spacing-lg, 6vw, --spacing-2xl) clamp(--spacing-lg, 6vw, --spacing-3xl).
'.rd-card{ position:relative; border-radius:var(--dx-radius);' +
' background:var(--dx-elevated); color:var(--dx-text);' +
' padding:clamp(var(--spacing-lg), 6cqw, var(--spacing-2xl)) clamp(var(--spacing-lg), 6cqw, var(--spacing-3xl)); }' +
// .card[padding-small] -> --spacing-lg flat
'.rd-card--small{ padding:var(--spacing-lg); }' +
// .card[data-variant="secondary"] -> surface-brand-dark on text-on-brand-light
'.rd-card--secondary{ background:var(--dx-dark); color:var(--dx-on-dark); }'
/**
* Injects the card surface into the containing shadow root, once, under a stable label.
* Call it at setup() time from any component that renders .rd-card markup.
*/
export function useCardSurface() {
useShadowStyles(CARD_SURFACE_CSS, 'dxlife-card-surface')
}

View file

@ -0,0 +1,58 @@
/**
* useFacetAggregation.js — count rows by a facet accessor, and roll the tail into one bucket.
*
* ★ EXTRACTED FROM useDashboardAggregations (dashboard-kit landing 4c-1). That module has a split
* personality: these two functions are pure counting, while the rest of it — colorFor with its
* hardcoded religion / wagf_region / affinity_bloc branches, the religion palettes, the donut and
* bar builders — is research-specific and STAYS in plugin-designs. Only the generic half could
* move, and the facet rail needs only the generic half, which is what made this the piece blocking
* kit landing 4c.
*
* Bodies are verbatim. Nothing here knows a facet name: the caller supplies the accessor.
*/
import { tStatic } from '@map/i18n/index.js'
/**
* A deliberately NEUTRAL tone for a value this facet has no palette for — matching the tree
* legend's NEUTRAL exactly. An uncoloured slice must read as "no colour strategy for this key",
* never as a confident member of some other family. Critically it is STABLE: the same key gets
* the same neutral every time, so nothing changes colour when a filter reorders the list.
*/
export const NEUTRAL = '#c4c8c0'
export const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
/** Count rows by a key accessor; collapse the long tail into 'Other'. */
export function aggregate(rows, keyFn, topN) {
const m = new Map()
for (const r of rows) { const k = keyFn(r); if (k === null || k === undefined || k === '') continue; m.set(k, (m.get(k) || 0) + 1) }
let arr = [...m.entries()].map(([key, value]) => ({ key, value })).sort((a, b) => b.value - a.value)
if (topN && arr.length > topN) {
const top = arr.slice(0, topN)
const other = arr.slice(topN).reduce((s, x) => s + x.value, 0)
if (other > 0) top.push({ key: 'Other', value: other, other: true })
arr = top
}
return arr
}
/**
* ★ THE ROLLUP BUCKET'S KEY IS A SENTINEL; ITS LABEL IS A TRANSLATION. Keep them apart.
*
* aggregate() pushes `{ key: OTHER_KEY, other: true }` for the tail, and 'Other' was rendered
* directly — so every locale showed the English word, in the facet rail and the charts rail both.
* check:i18n-keys-exist could not see it, because there is no T() call to inspect: the string was
* never a lookup, it was a key being used as a label.
*
* The KEY must not be translated. The rail tests `key === 'Other'` to make the row inert and
* unfilterable, and the charts rail refuses to filter on it — a localized key would make the bucket
* clickable in Arabic and silently filter on a value no row carries. That is the same value/label
* trap as the religion families, arriving in generic code this time, which is worse: unfixed it
* would have been exported to every future dashboard the moment this module became kit code.
*
* So the sentinel stays English and OTHER_LABEL() projects it for a reader, next to the function
* that produces it, so the two cannot drift apart.
*/
export const OTHER_KEY = 'Other'
export const otherLabel = () => tStatic('dashboard.other') || OTHER_KEY

View file

@ -0,0 +1,256 @@
{
"_meta": {
"generated_by": "library/scripts/generate-iso3-to-alpha2.mjs (bun run gen:iso3-to-alpha2)",
"purpose": "alpha-3 -> alpha-2 so Intl.DisplayNames can produce a LOCALIZED country name. Drives no data fetch.",
"not_to_be_confused_with": "library/scripts/research/out/iso3-to-iso2.json — the 176-country research spine that DOES drive fetches",
"source": "CLDR cldr-core 48.2.2, vendored at library/vendor/cldr/codeMappings.json (Unicode-3.0)",
"countries": 242,
"exceptions": {
"KOS": "XK — Kosovo has no ISO 3166-1 assignment; XK is the user-assigned code CLDR itself uses in locale ids (sr_Cyrl_XK)."
}
},
"map": {
"AFG": "AF",
"ALB": "AL",
"DZA": "DZ",
"ASM": "AS",
"AND": "AD",
"AGO": "AO",
"AIA": "AI",
"ATG": "AG",
"ARG": "AR",
"ARM": "AM",
"ABW": "AW",
"AUS": "AU",
"AUT": "AT",
"AZE": "AZ",
"BHR": "BH",
"BGD": "BD",
"BRB": "BB",
"BLR": "BY",
"BEL": "BE",
"BLZ": "BZ",
"BEN": "BJ",
"BMU": "BM",
"BTN": "BT",
"BOL": "BO",
"BES": "BQ",
"BIH": "BA",
"BWA": "BW",
"BRA": "BR",
"BRN": "BN",
"BGR": "BG",
"BFA": "BF",
"BDI": "BI",
"CPV": "CV",
"KHM": "KH",
"CMR": "CM",
"CAN": "CA",
"CYM": "KY",
"CAF": "CF",
"TCD": "TD",
"CHL": "CL",
"CHN": "CN",
"CXR": "CX",
"CCK": "CC",
"COL": "CO",
"COM": "KM",
"COK": "CK",
"CRI": "CR",
"CIV": "CI",
"HRV": "HR",
"CUB": "CU",
"CUW": "CW",
"CYP": "CY",
"DNK": "DK",
"DJI": "DJ",
"DMA": "DM",
"DOM": "DO",
"ECU": "EC",
"EGY": "EG",
"SLV": "SV",
"GNQ": "GQ",
"ERI": "ER",
"EST": "EE",
"SWZ": "SZ",
"ETH": "ET",
"FLK": "FK",
"FRO": "FO",
"FJI": "FJ",
"FIN": "FI",
"FRA": "FR",
"GUF": "GF",
"PYF": "PF",
"GAB": "GA",
"GEO": "GE",
"DEU": "DE",
"GHA": "GH",
"GIB": "GI",
"GRC": "GR",
"GRL": "GL",
"GRD": "GD",
"GLP": "GP",
"GUM": "GU",
"GTM": "GT",
"GGY": "GG",
"GIN": "GN",
"GNB": "GW",
"GUY": "GY",
"HTI": "HT",
"HND": "HN",
"HKG": "HK",
"HUN": "HU",
"ISL": "IS",
"IND": "IN",
"IDN": "ID",
"IRN": "IR",
"IRQ": "IQ",
"IRL": "IE",
"IMN": "IM",
"ISR": "IL",
"ITA": "IT",
"JAM": "JM",
"JPN": "JP",
"JEY": "JE",
"JOR": "JO",
"KAZ": "KZ",
"KEN": "KE",
"KIR": "KI",
"KOS": "XK",
"KWT": "KW",
"KGZ": "KG",
"LAO": "LA",
"LVA": "LV",
"LBN": "LB",
"LSO": "LS",
"LBR": "LR",
"LBY": "LY",
"LIE": "LI",
"LTU": "LT",
"LUX": "LU",
"MAC": "MO",
"MDG": "MG",
"MWI": "MW",
"MYS": "MY",
"MLI": "ML",
"MLT": "MT",
"MTQ": "MQ",
"MRT": "MR",
"MUS": "MU",
"MYT": "YT",
"MEX": "MX",
"MDA": "MD",
"MCO": "MC",
"MNG": "MN",
"MNE": "ME",
"MSR": "MS",
"MAR": "MA",
"MOZ": "MZ",
"MMR": "MM",
"NAM": "NA",
"NRU": "NR",
"NPL": "NP",
"NCL": "NC",
"NZL": "NZ",
"NIC": "NI",
"NER": "NE",
"NGA": "NG",
"NIU": "NU",
"NFK": "NF",
"PRK": "KP",
"MKD": "MK",
"NOR": "NO",
"OMN": "OM",
"PAK": "PK",
"PLW": "PW",
"PSE": "PS",
"PAN": "PA",
"PNG": "PG",
"PRY": "PY",
"PER": "PE",
"POL": "PL",
"PRT": "PT",
"PRI": "PR",
"QAT": "QA",
"REU": "RE",
"ROU": "RO",
"RWA": "RW",
"BLM": "BL",
"SHN": "SH",
"KNA": "KN",
"LCA": "LC",
"MAF": "MF",
"SPM": "PM",
"VCT": "VC",
"WSM": "WS",
"SMR": "SM",
"STP": "ST",
"SAU": "SA",
"SEN": "SN",
"SRB": "RS",
"SYC": "SC",
"SLE": "SL",
"SGP": "SG",
"SXM": "SX",
"SVK": "SK",
"SVN": "SI",
"SOM": "SO",
"ZAF": "ZA",
"KOR": "KR",
"SSD": "SS",
"ESP": "ES",
"LKA": "LK",
"SDN": "SD",
"SUR": "SR",
"SJM": "SJ",
"SWE": "SE",
"CHE": "CH",
"SYR": "SY",
"TWN": "TW",
"TJK": "TJ",
"TZA": "TZ",
"THA": "TH",
"BHS": "BS",
"VGB": "VG",
"CZE": "CZ",
"COD": "CD",
"FSM": "FM",
"GMB": "GM",
"MDV": "MV",
"MHL": "MH",
"NLD": "NL",
"MNP": "MP",
"PHL": "PH",
"PCN": "PN",
"COG": "CG",
"RUS": "RU",
"SLB": "SB",
"TCA": "TC",
"ARE": "AE",
"GBR": "GB",
"USA": "US",
"VIR": "VI",
"TLS": "TL",
"TGO": "TG",
"TKL": "TK",
"TON": "TO",
"TTO": "TT",
"TUN": "TN",
"TUR": "TR",
"TKM": "TM",
"TUV": "TV",
"UGA": "UG",
"UKR": "UA",
"URY": "UY",
"UZB": "UZ",
"VUT": "VU",
"VAT": "VA",
"VEN": "VE",
"VNM": "VN",
"WLF": "WF",
"ESH": "EH",
"YEM": "YE",
"ZMB": "ZM",
"ZWE": "ZW"
}
}

View file

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

View file

@ -160,7 +160,135 @@
"Bible video": "فيديو كتابي",
"Gospel presentation": "عرض للإنجيل",
"Countries": "البلدان",
"People groups": "الشعوب"
"People groups": "الشعوب",
"People Group": "الشعب",
"Basis": "مستوى البيانات",
"Bridge ISO": "ISO اللغة الجسرية",
"Bridge language": "اللغة الجسرية",
"Film": "فيلم",
"Groups with nothing": "شعوب بلا شيء",
"People with nothing": "أشخاص بلا شيء",
"Pools checked": "المصادر المفحوصة",
"Audio": "صوتي",
"Bridge candidate": "لغة جسرية مرشحة",
"Bridge film": "فيلم باللغة الجسرية",
"Bridge scripture": "الكتاب المقدس باللغة الجسرية",
"Bridge strength": "قوة الجسر",
"Est. deaths/day": "الوفيات المقدَّرة يوميًا",
"Film link": "رابط الفيلم",
"Full Bible": "الكتاب المقدس كاملًا",
"NO RESOURCE": "لا يوجد أي مورد",
"Nobody praying": "لا أحد يصلّي",
"People": "عدد الأشخاص",
"Pools NOT checked": "المصادر التي لم تُفحص",
"Scripture link": "رابط الكتاب المقدس",
"Sign language": "لغة الإشارة",
"Status": "الحالة",
"Training": "تدريب على التلمذة",
"% with nothing": "% بلا شيء",
"ALIGNED": "الشروط مستوفاة",
"Audio link": "رابط الصوت",
"Band": "الفئة",
"Bible stories": "قصص الكتاب المقدس",
"Can already hear": "يسمعون بالفعل",
"Candidate % of country": "نسبة المرشحة في البلد %",
"Candidate ISO": "ISO المرشحة",
"Candidate is official": "المرشحة لغة رسمية",
"Carries a resource": "تحمل موردًا",
"Channel": "القناة",
"Channel source": "مصدر القناة",
"Code": "الرمز",
"Code note": "ملاحظة الرمز",
"Code unlookupable": "الرمز غير مفهرس في أي مصدر",
"Code unmatchable": "رمز يتعذر مطابقته",
"Deaf bridge": "جسر الصم",
"Deaf bridge link": "رابط جسر الصم",
"Deaf bridge publisher": "ناشر جسر الصم",
"Deaf programme in country": "برنامج للصم في البلد",
"Death rate /1000": "معدل الوفيات /1000",
"Est. deaths/yr": "الوفيات المقدَّرة سنويًا",
"Example group": "شعب على سبيل المثال",
"Film in this language": "فيلم بهذه اللغة",
"GAP": "فجوة",
"GROWING + UNREACHED": "متزايدون + غير مبلَّغين",
"Gospel audio": "صوتيات الإنجيل",
"Groups": "الشعوب",
"Groups sharing bridge": "شعوب تشترك في الجسر",
"Groups speaking": "الشعوب الناطقة",
"Groups speaking it": "شعوب تتحدثها",
"Groups the map link shows": "الشعوب التي يعرضها رابط الخريطة",
"Groups with Scripture": "شعوب لديها الكتاب المقدس",
"Groups with film": "شعوب لديها الفيلم",
"Growing & unreached": "متزايدون وغير مبلَّغين",
"Growth %/yr": "النمو %/سنة",
"Has anything": "لديه أي شيء",
"Heart film": "فيلم باللغة الأم",
"Heart language": "اللغة الأم",
"Heart scripture": "الكتاب المقدس باللغة الأم",
"Internet": "الإنترنت",
"Internet %": "الإنترنت %",
"Its population": "عدد سكانه",
"JESUS Film": "فيلم يسوع",
"Kind of gap": "نوع الفجوة",
"Kind of way in": "نوع المدخل",
"Languages": "اللغات",
"Languages here": "اللغات هنا",
"Languages they speak": "اللغات التي يتحدثونها",
"Largest group": "أكبر شعب",
"Largest groups": "أكبر الشعوب",
"Largest with nothing": "الأكبر بلا شيء",
"Lead status": "حالة الدليل الأولي",
"Link": "الرابط",
"Method": "المنهجية",
"NT": "العهد الجديد",
"Note": "ملاحظة",
"OPPORTUNITY": "فرصة",
"Of those with a prayer page": "من بين من لديهم صفحة صلاة",
"On the map": "على الخريطة",
"Open": "فتح",
"People groups here": "الشعوب هنا",
"People nobody is praying for": "أشخاص لا يصلّي أحد لأجلهم",
"People praying": "المصلّون",
"Place": "المكان",
"Placement approximate": "الموقع تقريبي",
"Pools not checked": "المصادر غير المفحوصة",
"Pray": "صلِّ",
"Primary religion": "الديانة الرئيسية",
"Province": "المقاطعة",
"Rank": "الترتيب",
"Resource": "المورد",
"Scope": "مدى التغطية",
"Scripture in this language": "الكتاب المقدس بهذه اللغة",
"Signed film": "فيلم بلغة الإشارة",
"Signed film link": "رابط فيلم لغة الإشارة",
"Source": "المصدر",
"Speakers": "المتحدثون",
"Their language": "لغتهم",
"These peoples on the map": "هذه الشعوب على الخريطة",
"Training in the bridge": "تدريب باللغة الجسرية",
"Translated by": "ترجمة",
"Type": "النوع",
"UPG population": "سكان الشعوب غير المبلَّغة",
"Urban %": "الحضر %",
"Ways in": "المداخل",
"Ways in (country-level)": "المداخل (على مستوى البلد)",
"Year": "السنة"
},
"tableTitles": {
"doxa": "شعوب دوكسا — ما المتوفر من الإنجيل، وما الذي ينقص",
"links": "موارد الإنجيل — كل رابط، ومن يصل إليه",
"nothing": "حيث لا يوجد شيء",
"countrygap": "الفجوة حسب البلد",
"translation": "أولوية الترجمة — يسمعون، ولم يُعثر على نص",
"translationcountry": "أولوية الترجمة حسب البلد — يسمعون، ولم يُعثر على نص",
"gaps": "الفجوات والفرص",
"bridges": "فرص جسور المدن",
"deaf": "فجوة الصم",
"resources": "الشعوب × موارد الإنجيل",
"languages": "اللغات حسب البلد (مرتبة) × الموارد",
"growth": "الوصول إلى الإنجيل مقابل نمو السكان",
"province": "الوصول إلى الإنجيل حسب المقاطعة — الهند والصين",
"bridgereach": "لغة واحدة، كم شعبًا — المدخل، مرتبًا حسب مدى الانتشار"
},
"charts": {
"scriptureAny": "الكتاب المقدس (أي نوع)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "قائمتي UPG 100 — قوائم الصلاة والملصقات",
"upg100WallTag": "تعبئة للصلاة",
"upg100WallIntro": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم <strong>باسم شعبهم</strong>.",
"upg100WallIntroA": "وزّع مهمة الصلاة في مجموعات من خمسين ومئة — كما أطعم يسوع الخمسة آلاف (لوقا ٩:١٤). كل ملصق هو قائمة من 100 يمكنك أن تصلي بها وتطبعها وتعلّقها على الحائط — مصلّيًا لأجلهم ",
"upg100WallIntroEmph": "باسم شعبهم",
"upg100WallIntroB": ".",
"loadingNamed": "جارٍ تحميل {name}…",
"couldNotLoadNamed": "تعذّر تحميل {name}.",
"engineMissing": "لم يتم تحميل محرك الخرائط {engine}.",

View file

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

View file

@ -160,7 +160,135 @@
"Bible video": "Bibelvideo",
"Gospel presentation": "Evangeliums-Darstellung",
"Countries": "Länder",
"People groups": "Volksgruppen"
"People groups": "Volksgruppen",
"People Group": "Volksgruppe",
"Basis": "Bezugsebene",
"Bridge ISO": "Brücken-ISO",
"Bridge language": "Brückensprache",
"Film": "Film",
"Groups with nothing": "Gruppen ohne etwas",
"People with nothing": "Menschen ohne etwas",
"Pools checked": "Geprüfte Quellen",
"Audio": "Audio",
"Bridge candidate": "Brückenkandidat",
"Bridge film": "Brücken-Film",
"Bridge scripture": "Brücken-Bibeltext",
"Bridge strength": "Brückenstärke",
"Est. deaths/day": "Geschätzte Todesfälle/Tag",
"Film link": "Film-Link",
"Full Bible": "Ganze Bibel",
"NO RESOURCE": "KEINE RESSOURCE",
"Nobody praying": "Niemand betet",
"People": "Menschen",
"Pools NOT checked": "NICHT geprüfte Quellen",
"Scripture link": "Bibeltext-Link",
"Sign language": "Gebärdensprache",
"Status": "Status",
"Training": "Jüngerschaftstraining",
"% with nothing": "% ohne etwas",
"ALIGNED": "ALLE BEDINGUNGEN ERFÜLLT",
"Audio link": "Audio-Link",
"Band": "Stufe",
"Bible stories": "Bibelgeschichten",
"Can already hear": "Können schon hören",
"Candidate % of country": "Kandidat % des Landes",
"Candidate ISO": "Kandidaten-ISO",
"Candidate is official": "Kandidat ist Amtssprache",
"Carries a resource": "Hat eine Ressource",
"Channel": "Kanal",
"Channel source": "Kanalquelle",
"Code": "Code",
"Code note": "Code-Hinweis",
"Code unlookupable": "Code in keiner Quelle indexiert",
"Code unmatchable": "Code nicht zuordenbar",
"Deaf bridge": "Gehörlosen-Brücke",
"Deaf bridge link": "Link Gehörlosen-Brücke",
"Deaf bridge publisher": "Herausgeber Gehörlosen-Brücke",
"Deaf programme in country": "Gehörlosenprogramm im Land",
"Death rate /1000": "Sterberate /1000",
"Est. deaths/yr": "Geschätzte Todesfälle/Jahr",
"Example group": "Beispielgruppe",
"Film in this language": "Film in dieser Sprache",
"GAP": "LÜCKE",
"GROWING + UNREACHED": "WACHSEND + UNERREICHT",
"Gospel audio": "Evangeliums-Audio",
"Groups": "Gruppen",
"Groups sharing bridge": "Gruppen mit gleicher Brücke",
"Groups speaking": "Sprechende Gruppen",
"Groups speaking it": "Gruppen, die sie sprechen",
"Groups the map link shows": "Gruppen im Karten-Link",
"Groups with Scripture": "Gruppen mit Bibeltext",
"Groups with film": "Gruppen mit Film",
"Growing & unreached": "Wachsend & unerreicht",
"Growth %/yr": "Wachstum %/Jahr",
"Has anything": "Hat etwas",
"Heart film": "Film in eigener Sprache",
"Heart language": "Eigene Sprache",
"Heart scripture": "Bibeltext in eigener Sprache",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Ihre Bevölkerung",
"JESUS Film": "JESUS Film",
"Kind of gap": "Art der Lücke",
"Kind of way in": "Art des Zugangs",
"Languages": "Sprachen",
"Languages here": "Sprachen hier",
"Languages they speak": "Ihre Sprachen",
"Largest group": "Größte Gruppe",
"Largest groups": "Größte Gruppen",
"Largest with nothing": "Größte ohne etwas",
"Lead status": "Status des Hinweises",
"Link": "Link",
"Method": "Methode",
"NT": "NT",
"Note": "Hinweis",
"OPPORTUNITY": "CHANCE",
"Of those with a prayer page": "Davon mit Gebetsseite",
"On the map": "Auf der Karte",
"Open": "Öffnen",
"People groups here": "Volksgruppen hier",
"People nobody is praying for": "Menschen, für die niemand betet",
"People praying": "Betende",
"Place": "Ort",
"Placement approximate": "Verortung ungefähr",
"Pools not checked": "Nicht geprüfte Quellen",
"Pray": "Beten",
"Primary religion": "Hauptreligion",
"Province": "Provinz",
"Rank": "Rang",
"Resource": "Ressource",
"Scope": "Umfang",
"Scripture in this language": "Bibeltext in dieser Sprache",
"Signed film": "Film in Gebärdensprache",
"Signed film link": "Link Film in Gebärdensprache",
"Source": "Quelle",
"Speakers": "Sprecher",
"Their language": "Ihre Sprache",
"These peoples on the map": "Diese Völker auf der Karte",
"Training in the bridge": "Training in der Brückensprache",
"Translated by": "Übersetzt von",
"Type": "Typ",
"UPG population": "Bevölkerung unerreichter Volksgruppen",
"Urban %": "Städtisch %",
"Ways in": "Zugänge",
"Ways in (country-level)": "Zugänge (Landesebene)",
"Year": "Jahr"
},
"tableTitles": {
"doxa": "Doxa-Volksgruppen — worin das Evangelium verfügbar ist und was fehlt",
"links": "Evangeliums-Ressourcen — jeder Link und wen er erreicht",
"nothing": "Wo nichts gefunden wurde",
"countrygap": "Die Lücke nach Land",
"translation": "Übersetzungspriorität — können hören, kein Text gefunden",
"translationcountry": "Übersetzungspriorität nach Land — können hören, kein Text gefunden",
"gaps": "Lücken und Chancen",
"bridges": "Chancen für Stadt-Brücken",
"deaf": "Die Gehörlosen-Lücke",
"resources": "Volksgruppen × Evangeliums-Ressourcen",
"languages": "Sprachen nach Land (nach Rang) x Ressourcen",
"growth": "Zugang zum Evangelium vs. Bevölkerungswachstum",
"province": "Zugang zum Evangelium nach Provinz — Indien und China",
"bridgereach": "Eine Sprache, wie viele Völker — der Zugang, nach Reichweite geordnet"
},
"charts": {
"scriptureAny": "Bibeltext (beliebig)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Meine UPG-100-Liste — Gebetslisten und Poster",
"upg100WallTag": "Gebetsmobilisierung",
"upg100WallIntro": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie <strong>mit dem Namen ihrer Volksgruppe</strong> beten.",
"upg100WallIntroA": "Teilen Sie die Gebetsaufgabe in Gruppen zu fünfzig und hundert auf — so wie Jesus die 5.000 speiste (Lukas 9,14). Jedes Poster ist eine 100er-Liste, die Sie beten, drucken und an die Wand hängen können — und für sie ",
"upg100WallIntroEmph": "mit dem Namen ihrer Volksgruppe",
"upg100WallIntroB": " beten.",
"loadingNamed": "{name} wird geladen…",
"couldNotLoadNamed": "{name} konnte nicht geladen werden.",
"engineMissing": "Die Karten-Engine {engine} wurde nicht geladen.",

View file

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

View file

@ -160,7 +160,135 @@
"Bible video": "Bible video",
"Gospel presentation": "Gospel presentation",
"Countries": "Countries",
"People groups": "People groups"
"People groups": "People groups",
"People Group": "People Group",
"Basis": "Basis",
"Bridge ISO": "Bridge ISO",
"Bridge language": "Bridge language",
"Film": "Film",
"Groups with nothing": "Groups with nothing",
"People with nothing": "People with nothing",
"Pools checked": "Pools checked",
"Audio": "Audio",
"Bridge candidate": "Bridge candidate",
"Bridge film": "Bridge film",
"Bridge scripture": "Bridge scripture",
"Bridge strength": "Bridge strength",
"Est. deaths/day": "Est. deaths/day",
"Film link": "Film link",
"Full Bible": "Full Bible",
"NO RESOURCE": "NO RESOURCE",
"Nobody praying": "Nobody praying",
"People": "People",
"Pools NOT checked": "Pools NOT checked",
"Scripture link": "Scripture link",
"Sign language": "Sign language",
"Status": "Status",
"Training": "Training",
"% with nothing": "% with nothing",
"ALIGNED": "ALIGNED",
"Audio link": "Audio link",
"Band": "Band",
"Bible stories": "Bible stories",
"Can already hear": "Can already hear",
"Candidate % of country": "Candidate % of country",
"Candidate ISO": "Candidate ISO",
"Candidate is official": "Candidate is official",
"Carries a resource": "Carries a resource",
"Channel": "Channel",
"Channel source": "Channel source",
"Code": "Code",
"Code note": "Code note",
"Code unlookupable": "Code unlookupable",
"Code unmatchable": "Code unmatchable",
"Deaf bridge": "Deaf bridge",
"Deaf bridge link": "Deaf bridge link",
"Deaf bridge publisher": "Deaf bridge publisher",
"Deaf programme in country": "Deaf programme in country",
"Death rate /1000": "Death rate /1000",
"Est. deaths/yr": "Est. deaths/yr",
"Example group": "Example group",
"Film in this language": "Film in this language",
"GAP": "GAP",
"GROWING + UNREACHED": "GROWING + UNREACHED",
"Gospel audio": "Gospel audio",
"Groups": "Groups",
"Groups sharing bridge": "Groups sharing bridge",
"Groups speaking": "Groups speaking",
"Groups speaking it": "Groups speaking it",
"Groups the map link shows": "Groups the map link shows",
"Groups with Scripture": "Groups with Scripture",
"Groups with film": "Groups with film",
"Growing & unreached": "Growing & unreached",
"Growth %/yr": "Growth %/yr",
"Has anything": "Has anything",
"Heart film": "Heart film",
"Heart language": "Heart language",
"Heart scripture": "Heart scripture",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Its population",
"JESUS Film": "JESUS Film",
"Kind of gap": "Kind of gap",
"Kind of way in": "Kind of way in",
"Languages": "Languages",
"Languages here": "Languages here",
"Languages they speak": "Languages they speak",
"Largest group": "Largest group",
"Largest groups": "Largest groups",
"Largest with nothing": "Largest with nothing",
"Lead status": "Lead status",
"Link": "Link",
"Method": "Method",
"NT": "NT",
"Note": "Note",
"OPPORTUNITY": "OPPORTUNITY",
"Of those with a prayer page": "Of those with a prayer page",
"On the map": "On the map",
"Open": "Open",
"People groups here": "People groups here",
"People nobody is praying for": "People nobody is praying for",
"People praying": "People praying",
"Place": "Place",
"Placement approximate": "Placement approximate",
"Pools not checked": "Pools not checked",
"Pray": "Pray",
"Primary religion": "Primary religion",
"Province": "Province",
"Rank": "Rank",
"Resource": "Resource",
"Scope": "Scope",
"Scripture in this language": "Scripture in this language",
"Signed film": "Signed film",
"Signed film link": "Signed film link",
"Source": "Source",
"Speakers": "Speakers",
"Their language": "Their language",
"These peoples on the map": "These peoples on the map",
"Training in the bridge": "Training in the bridge",
"Translated by": "Translated by",
"Type": "Type",
"UPG population": "UPG population",
"Urban %": "Urban %",
"Ways in": "Ways in",
"Ways in (country-level)": "Ways in (country-level)",
"Year": "Year"
},
"tableTitles": {
"doxa": "Doxa people groups — what the gospel is available in, and what is missing",
"links": "Gospel Resources — every link, and who it reaches",
"nothing": "Where Nothing Was Found",
"countrygap": "The gap by country",
"translation": "Translation priority — can hear it, no text found",
"translationcountry": "Translation priority by country — can hear it, no text found",
"gaps": "Gaps and Opportunities",
"bridges": "City Bridge Opportunities",
"deaf": "The Deaf Gap",
"resources": "People Groups × Gospel Resources",
"languages": "Languages by Country (ranked) x Resources",
"growth": "Gospel Access vs Population Growth",
"province": "Gospel access by province — India and China",
"bridgereach": "One language, how many peoples — the way in, ranked by reach"
},
"charts": {
"scriptureAny": "Scripture (any)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "My UPG 100 List — Prayer Lists & Posters",
"upg100WallTag": "Prayer Mobilization",
"upg100WallIntro": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them <strong>by their people group name</strong>.",
"upg100WallIntroA": "Hand away the prayer task in clusters of fifties and hundreds — the way Jesus fed the 5,000 (Luke 9:14). Each poster is a 100-list you can pray, print, and put on a wall — praying for them ",
"upg100WallIntroEmph": "by their people group name",
"upg100WallIntroB": ".",
"loadingNamed": "Loading {name}…",
"couldNotLoadNamed": "Could not load {name}.",
"engineMissing": "The {engine} map engine did not load.",

View file

@ -77,5 +77,6 @@
"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",
"n_shown": "{n} mostrados"
"n_shown": "{n} mostrados",
"other": "Otros"
}

View file

@ -160,7 +160,135 @@
"Bible video": "Video bíblico",
"Gospel presentation": "Presentación del evangelio",
"Countries": "Países",
"People groups": "Pueblos"
"People groups": "Pueblos",
"People Group": "Pueblo",
"Basis": "Basado en",
"Bridge ISO": "ISO puente",
"Bridge language": "Lengua puente",
"Film": "Película",
"Groups with nothing": "Pueblos sin nada",
"People with nothing": "Personas sin nada",
"Pools checked": "Fuentes revisadas",
"Audio": "Audio",
"Bridge candidate": "Candidata a puente",
"Bridge film": "Película en lengua puente",
"Bridge scripture": "Escrituras en lengua puente",
"Bridge strength": "Fuerza del puente",
"Est. deaths/day": "Muertes estimadas/día",
"Film link": "Enlace de película",
"Full Bible": "Biblia completa",
"NO RESOURCE": "NINGÚN RECURSO",
"Nobody praying": "Nadie ora",
"People": "Personas",
"Pools NOT checked": "Fuentes NO revisadas",
"Scripture link": "Enlace de Escrituras",
"Sign language": "Lengua de señas",
"Status": "Estado",
"Training": "Formación de discipulado",
"% with nothing": "% sin nada",
"ALIGNED": "CONDICIONES CUMPLIDAS",
"Audio link": "Enlace de audio",
"Band": "Nivel",
"Bible stories": "Historias bíblicas",
"Can already hear": "Ya pueden oír",
"Candidate % of country": "Candidata % del país",
"Candidate ISO": "ISO candidata",
"Candidate is official": "Candidata es oficial",
"Carries a resource": "Tiene un recurso",
"Channel": "Canal",
"Channel source": "Fuente del canal",
"Code": "Código",
"Code note": "Nota del código",
"Code unlookupable": "Código sin índice en ninguna fuente",
"Code unmatchable": "Código sin correspondencia",
"Deaf bridge": "Puente para sordos",
"Deaf bridge link": "Enlace del puente para sordos",
"Deaf bridge publisher": "Editor del puente para sordos",
"Deaf programme in country": "Programa para sordos en el país",
"Death rate /1000": "Tasa de mortalidad /1000",
"Est. deaths/yr": "Muertes estimadas/año",
"Example group": "Pueblo de ejemplo",
"Film in this language": "Película en este idioma",
"GAP": "BRECHA",
"GROWING + UNREACHED": "EN CRECIMIENTO + NO ALCANZADO",
"Gospel audio": "Audio del evangelio",
"Groups": "Pueblos",
"Groups sharing bridge": "Pueblos que comparten puente",
"Groups speaking": "Pueblos hablantes",
"Groups speaking it": "Pueblos que la hablan",
"Groups the map link shows": "Pueblos que muestra el enlace del mapa",
"Groups with Scripture": "Pueblos con Escrituras",
"Groups with film": "Pueblos con película",
"Growing & unreached": "En crecimiento y no alcanzados",
"Growth %/yr": "Crecimiento %/año",
"Has anything": "Tiene algo",
"Heart film": "Película en idioma propio",
"Heart language": "Idioma propio",
"Heart scripture": "Escrituras en idioma propio",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Su población",
"JESUS Film": "Película JESÚS",
"Kind of gap": "Tipo de brecha",
"Kind of way in": "Tipo de vía de acceso",
"Languages": "Idiomas",
"Languages here": "Idiomas aquí",
"Languages they speak": "Idiomas que hablan",
"Largest group": "Pueblo más grande",
"Largest groups": "Pueblos más grandes",
"Largest with nothing": "Más grande sin nada",
"Lead status": "Estado de la pista",
"Link": "Enlace",
"Method": "Método",
"NT": "NT",
"Note": "Nota",
"OPPORTUNITY": "OPORTUNIDAD",
"Of those with a prayer page": "De los que tienen página de oración",
"On the map": "En el mapa",
"Open": "Abrir",
"People groups here": "Pueblos aquí",
"People nobody is praying for": "Personas por las que nadie ora",
"People praying": "Personas orando",
"Place": "Lugar",
"Placement approximate": "Ubicación aproximada",
"Pools not checked": "Fuentes no revisadas",
"Pray": "Orar",
"Primary religion": "Religión principal",
"Province": "Provincia",
"Rank": "Posición",
"Resource": "Recurso",
"Scope": "Extensión",
"Scripture in this language": "Escrituras en este idioma",
"Signed film": "Película en lengua de señas",
"Signed film link": "Enlace de película en lengua de señas",
"Source": "Fuente",
"Speakers": "Hablantes",
"Their language": "Su idioma",
"These peoples on the map": "Estos pueblos en el mapa",
"Training in the bridge": "Formación en la lengua puente",
"Translated by": "Traducido por",
"Type": "Tipo",
"UPG population": "Población de pueblos no alcanzados",
"Urban %": "Urbano %",
"Ways in": "Vías de acceso",
"Ways in (country-level)": "Vías de acceso (nivel país)",
"Year": "Año"
},
"tableTitles": {
"doxa": "Pueblos de Doxa — en qué está disponible el evangelio y qué falta",
"links": "Recursos del evangelio — cada enlace y a quién alcanza",
"nothing": "Donde no se encontró nada",
"countrygap": "La brecha por país",
"translation": "Prioridad de traducción — pueden oírlo, no se encontró texto",
"translationcountry": "Prioridad de traducción por país — pueden oírlo, no se encontró texto",
"gaps": "Brechas y oportunidades",
"bridges": "Oportunidades de puentes urbanos",
"deaf": "La brecha sorda",
"resources": "Pueblos × recursos del evangelio",
"languages": "Idiomas por país (clasificados) x recursos",
"growth": "Acceso al evangelio vs crecimiento de la población",
"province": "Acceso al Evangelio por provincia — India y China",
"bridgereach": "Una lengua, cuántos pueblos — la vía de acceso, ordenada por alcance"
},
"charts": {
"scriptureAny": "Escrituras (cualquiera)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Mi lista UPG 100 — Listas de oración y carteles",
"upg100WallTag": "Movilización de oración",
"upg100WallIntro": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos <strong>por el nombre de su pueblo</strong>.",
"upg100WallIntroA": "Reparte la tarea de la oración en grupos de cincuenta y de cien, como Jesús alimentó a los 5.000 (Lucas 9:14). Cada cartel es una lista de 100 que puedes orar, imprimir y poner en la pared — orando por ellos ",
"upg100WallIntroEmph": "por el nombre de su pueblo",
"upg100WallIntroB": ".",
"loadingNamed": "Cargando {name}…",
"couldNotLoadNamed": "No se pudo cargar {name}.",
"engineMissing": "El motor de mapas {engine} no se cargó.",

View file

@ -77,5 +77,6 @@
"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",
"n_shown": "{n} affichés"
"n_shown": "{n} affichés",
"other": "Autres"
}

View file

@ -160,7 +160,135 @@
"Bible video": "Vidéo biblique",
"Gospel presentation": "Présentation de l'Évangile",
"Countries": "Pays",
"People groups": "Peuples"
"People groups": "Peuples",
"People Group": "Peuple",
"Basis": "Basé sur",
"Bridge ISO": "ISO passerelle",
"Bridge language": "Langue passerelle",
"Film": "Film",
"Groups with nothing": "Peuples sans rien",
"People with nothing": "Personnes sans rien",
"Pools checked": "Sources consultées",
"Audio": "Audio",
"Bridge candidate": "Candidate passerelle",
"Bridge film": "Film en langue passerelle",
"Bridge scripture": "Écritures en langue passerelle",
"Bridge strength": "Force de la passerelle",
"Est. deaths/day": "Décès estimés/jour",
"Film link": "Lien du film",
"Full Bible": "Bible complète",
"NO RESOURCE": "AUCUNE RESSOURCE",
"Nobody praying": "Personne ne prie",
"People": "Personnes",
"Pools NOT checked": "Sources NON consultées",
"Scripture link": "Lien des Écritures",
"Sign language": "Langue des signes",
"Status": "Statut",
"Training": "Formation au discipulat",
"% with nothing": "% sans rien",
"ALIGNED": "CONDITIONS RÉUNIES",
"Audio link": "Lien audio",
"Band": "Niveau",
"Bible stories": "Histoires bibliques",
"Can already hear": "Entendent déjà",
"Candidate % of country": "Candidate % du pays",
"Candidate ISO": "ISO candidate",
"Candidate is official": "Candidate officielle",
"Carries a resource": "A une ressource",
"Channel": "Canal",
"Channel source": "Source du canal",
"Code": "Code",
"Code note": "Note sur le code",
"Code unlookupable": "Code absent de toute source",
"Code unmatchable": "Code sans correspondance",
"Deaf bridge": "Passerelle pour sourds",
"Deaf bridge link": "Lien passerelle pour sourds",
"Deaf bridge publisher": "Éditeur passerelle pour sourds",
"Deaf programme in country": "Programme pour sourds dans le pays",
"Death rate /1000": "Taux de mortalité /1000",
"Est. deaths/yr": "Décès estimés/an",
"Example group": "Peuple exemple",
"Film in this language": "Film dans cette langue",
"GAP": "ÉCART",
"GROWING + UNREACHED": "EN CROISSANCE + NON ATTEINT",
"Gospel audio": "Audio de l'Évangile",
"Groups": "Peuples",
"Groups sharing bridge": "Peuples partageant la passerelle",
"Groups speaking": "Peuples locuteurs",
"Groups speaking it": "Peuples qui la parlent",
"Groups the map link shows": "Peuples affichés par le lien de carte",
"Groups with Scripture": "Peuples avec Écritures",
"Groups with film": "Peuples avec film",
"Growing & unreached": "En croissance et non atteints",
"Growth %/yr": "Croissance %/an",
"Has anything": "A quelque chose",
"Heart film": "Film en langue propre",
"Heart language": "Langue propre",
"Heart scripture": "Écritures en langue propre",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Sa population",
"JESUS Film": "Film JÉSUS",
"Kind of gap": "Type d'écart",
"Kind of way in": "Type de porte d'entrée",
"Languages": "Langues",
"Languages here": "Langues ici",
"Languages they speak": "Langues parlées",
"Largest group": "Plus grand peuple",
"Largest groups": "Plus grands peuples",
"Largest with nothing": "Plus grand sans rien",
"Lead status": "Statut de la piste",
"Link": "Lien",
"Method": "Méthode",
"NT": "NT",
"Note": "Note",
"OPPORTUNITY": "OPPORTUNITÉ",
"Of those with a prayer page": "Parmi ceux avec une page de prière",
"On the map": "Sur la carte",
"Open": "Ouvrir",
"People groups here": "Peuples ici",
"People nobody is praying for": "Personnes pour qui personne ne prie",
"People praying": "Personnes qui prient",
"Place": "Lieu",
"Placement approximate": "Emplacement approximatif",
"Pools not checked": "Sources non consultées",
"Pray": "Prier",
"Primary religion": "Religion principale",
"Province": "Province",
"Rank": "Rang",
"Resource": "Ressource",
"Scope": "Étendue",
"Scripture in this language": "Écritures dans cette langue",
"Signed film": "Film en langue des signes",
"Signed film link": "Lien du film en langue des signes",
"Source": "Source",
"Speakers": "Locuteurs",
"Their language": "Leur langue",
"These peoples on the map": "Ces peuples sur la carte",
"Training in the bridge": "Formation dans la langue passerelle",
"Translated by": "Traduit par",
"Type": "Type",
"UPG population": "Population des peuples non atteints",
"Urban %": "Urbain %",
"Ways in": "Portes d'entrée",
"Ways in (country-level)": "Portes d'entrée (niveau pays)",
"Year": "Année"
},
"tableTitles": {
"doxa": "Peuples Doxa — ce qui existe de l'Évangile, et ce qui manque",
"links": "Ressources de l'Évangile — chaque lien, et qui il atteint",
"nothing": "Où rien n'a été trouvé",
"countrygap": "L'écart par pays",
"translation": "Priorité de traduction — peuvent l'entendre, aucun texte trouvé",
"translationcountry": "Priorité de traduction par pays — peuvent l'entendre, aucun texte trouvé",
"gaps": "Écarts et opportunités",
"bridges": "Opportunités de ponts urbains",
"deaf": "L'écart sourd",
"resources": "Peuples × ressources de l'Évangile",
"languages": "Langues par pays (classées) x ressources",
"growth": "Accès à l'Évangile vs croissance démographique",
"province": "Accès à l'Évangile par province — Inde et Chine",
"bridgereach": "Une langue, combien de peuples — la porte d'entrée, classée par portée"
},
"charts": {
"scriptureAny": "Écritures (toutes)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Ma liste UPG 100 — Listes de prière et affiches",
"upg100WallTag": "Mobilisation pour la prière",
"upg100WallIntro": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux <strong>par le nom de leur peuple</strong>.",
"upg100WallIntroA": "Répartissez la tâche de la prière par groupes de cinquante et de cent — comme Jésus a nourri les 5 000 (Luc 9:14). Chaque affiche est une liste de 100 que vous pouvez prier, imprimer et afficher au mur — en priant pour eux ",
"upg100WallIntroEmph": "par le nom de leur peuple",
"upg100WallIntroB": ".",
"loadingNamed": "Chargement de {name}…",
"couldNotLoadNamed": "Impossible de charger {name}.",
"engineMissing": "Le moteur de carte {engine} ne s’est pas chargé.",

View file

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

View file

@ -160,7 +160,135 @@
"Bible video": "बाइबल वीडियो",
"Gospel presentation": "सुसमाचार प्रस्तुति",
"Countries": "देश",
"People groups": "जनसमूह"
"People groups": "जनसमूह",
"People Group": "जनसमूह",
"Basis": "आधार स्तर",
"Bridge ISO": "सेतु ISO",
"Bridge language": "सेतु भाषा",
"Film": "फ़िल्म",
"Groups with nothing": "कुछ नहीं वाले जनसमूह",
"People with nothing": "कुछ नहीं वाले लोग",
"Pools checked": "जाँचे गए स्रोत",
"Audio": "ऑडियो",
"Bridge candidate": "संभावित सेतु",
"Bridge film": "सेतु फ़िल्म",
"Bridge scripture": "सेतु पवित्र शास्त्र",
"Bridge strength": "सेतु की मज़बूती",
"Est. deaths/day": "अनुमानित मृत्यु/दिन",
"Film link": "फ़िल्म लिंक",
"Full Bible": "पूरी बाइबल",
"NO RESOURCE": "कोई संसाधन नहीं",
"Nobody praying": "कोई प्रार्थना नहीं कर रहा",
"People": "लोग",
"Pools NOT checked": "नहीं जाँचे गए स्रोत",
"Scripture link": "पवित्र शास्त्र लिंक",
"Sign language": "सांकेतिक भाषा",
"Status": "स्थिति",
"Training": "शिष्यत्व प्रशिक्षण",
"% with nothing": "% के पास कुछ नहीं",
"ALIGNED": "सभी शर्तें पूरी",
"Audio link": "ऑडियो लिंक",
"Band": "श्रेणी",
"Bible stories": "बाइबल की कहानियाँ",
"Can already hear": "पहले से सुन सकते हैं",
"Candidate % of country": "संभावित भाषा देश का %",
"Candidate ISO": "संभावित ISO",
"Candidate is official": "संभावित आधिकारिक है",
"Carries a resource": "संसाधन उपलब्ध",
"Channel": "चैनल",
"Channel source": "चैनल स्रोत",
"Code": "कोड",
"Code note": "कोड टिप्पणी",
"Code unlookupable": "कोड किसी स्रोत में नहीं",
"Code unmatchable": "कोड मेल नहीं खाता",
"Deaf bridge": "बधिर सेतु",
"Deaf bridge link": "बधिर सेतु लिंक",
"Deaf bridge publisher": "बधिर सेतु प्रकाशक",
"Deaf programme in country": "देश में बधिर कार्यक्रम",
"Death rate /1000": "मृत्यु दर /1000",
"Est. deaths/yr": "अनुमानित मृत्यु/वर्ष",
"Example group": "उदाहरण जनसमूह",
"Film in this language": "इस भाषा में फ़िल्म",
"GAP": "कमी",
"GROWING + UNREACHED": "बढ़ते + अनपहुँचे",
"Gospel audio": "सुसमाचार ऑडियो",
"Groups": "जनसमूह",
"Groups sharing bridge": "सेतु साझा करने वाले जनसमूह",
"Groups speaking": "बोलने वाले जनसमूह",
"Groups speaking it": "इसे बोलने वाले जनसमूह",
"Groups the map link shows": "मानचित्र लिंक में दिखने वाले जनसमूह",
"Groups with Scripture": "पवित्र शास्त्र वाले जनसमूह",
"Groups with film": "फ़िल्म वाले जनसमूह",
"Growing & unreached": "बढ़ते और अनपहुँचे",
"Growth %/yr": "वृद्धि %/वर्ष",
"Has anything": "कुछ उपलब्ध",
"Heart film": "अपनी भाषा में फ़िल्म",
"Heart language": "अपनी भाषा",
"Heart scripture": "अपनी भाषा में पवित्र शास्त्र",
"Internet": "इंटरनेट",
"Internet %": "इंटरनेट %",
"Its population": "उसकी जनसंख्या",
"JESUS Film": "यीशु फ़िल्म",
"Kind of gap": "कमी का प्रकार",
"Kind of way in": "रास्ते का प्रकार",
"Languages": "भाषाएँ",
"Languages here": "यहाँ की भाषाएँ",
"Languages they speak": "उनकी भाषाएँ",
"Largest group": "सबसे बड़ा जनसमूह",
"Largest groups": "सबसे बड़े जनसमूह",
"Largest with nothing": "कुछ नहीं वाला सबसे बड़ा",
"Lead status": "सुराग की स्थिति",
"Link": "लिंक",
"Method": "विधि",
"NT": "नया नियम",
"Note": "टिप्पणी",
"OPPORTUNITY": "अवसर",
"Of those with a prayer page": "प्रार्थना पेज वालों में से",
"On the map": "मानचित्र पर",
"Open": "खोलें",
"People groups here": "यहाँ के जनसमूह",
"People nobody is praying for": "जिनके लिए कोई प्रार्थना नहीं कर रहा",
"People praying": "प्रार्थना करने वाले",
"Place": "स्थान",
"Placement approximate": "स्थान अनुमानित",
"Pools not checked": "नहीं जाँचे गए स्रोत",
"Pray": "प्रार्थना करें",
"Primary religion": "मुख्य धर्म",
"Province": "प्रांत",
"Rank": "क्रम",
"Resource": "संसाधन",
"Scope": "विस्तार",
"Scripture in this language": "इस भाषा में पवित्र शास्त्र",
"Signed film": "सांकेतिक फ़िल्म",
"Signed film link": "सांकेतिक फ़िल्म लिंक",
"Source": "स्रोत",
"Speakers": "बोलने वाले",
"Their language": "उनकी भाषा",
"These peoples on the map": "मानचित्र पर ये जनसमूह",
"Training in the bridge": "सेतु भाषा में प्रशिक्षण",
"Translated by": "अनुवादक",
"Type": "प्रकार",
"UPG population": "अनपहुँचे जनसमूहों की जनसंख्या",
"Urban %": "शहरी %",
"Ways in": "रास्ते",
"Ways in (country-level)": "रास्ते (देश स्तर)",
"Year": "वर्ष"
},
"tableTitles": {
"doxa": "दोक्सा जनसमूह — सुसमाचार किसमें उपलब्ध है, और क्या कमी है",
"links": "सुसमाचार संसाधन — हर लिंक, और वह किस तक पहुँचता है",
"nothing": "जहाँ कुछ नहीं मिला",
"countrygap": "देश अनुसार कमी",
"translation": "अनुवाद प्राथमिकता — सुन सकते हैं, कोई पाठ नहीं मिला",
"translationcountry": "देश अनुसार अनुवाद प्राथमिकता — सुन सकते हैं, कोई पाठ नहीं मिला",
"gaps": "कमियाँ और अवसर",
"bridges": "नगर सेतु अवसर",
"deaf": "बधिर कमी",
"resources": "जनसमूह × सुसमाचार संसाधन",
"languages": "देश अनुसार भाषाएँ (क्रमबद्ध) x संसाधन",
"growth": "सुसमाचार पहुँच बनाम जनसंख्या वृद्धि",
"province": "प्रांत के अनुसार सुसमाचार तक पहुँच — भारत और चीन",
"bridgereach": "एक भाषा, कितने जनसमूह — पहुँचने का रास्ता, पहुँच के क्रम में"
},
"charts": {
"scriptureAny": "पवित्र शास्त्र (कोई भी)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "मेरी UPG 100 सूची — प्रार्थना सूचियाँ और पोस्टर",
"upg100WallTag": "प्रार्थना लामबंदी",
"upg100WallIntro": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए <strong>उनके जनसमूह के नाम से</strong> प्रार्थना करते हुए।",
"upg100WallIntroA": "प्रार्थना के काम को पचास-पचास और सौ-सौ के समूहों में बाँटें — जैसे यीशु ने 5,000 लोगों को खिलाया (लूका 9:14)। हर पोस्टर 100 की एक सूची है जिसके लिए आप प्रार्थना कर सकते हैं, छाप सकते हैं और दीवार पर लगा सकते हैं — उनके लिए ",
"upg100WallIntroEmph": "उनके जनसमूह के नाम से",
"upg100WallIntroB": " प्रार्थना करते हुए।",
"loadingNamed": "{name} लोड हो रहा है…",
"couldNotLoadNamed": "{name} लोड नहीं हो सका।",
"engineMissing": "{engine} नक्शा इंजन लोड नहीं हुआ।",

View file

@ -77,5 +77,6 @@
"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",
"n_shown": "{n} mostrati"
"n_shown": "{n} mostrati",
"other": "Altri"
}

View file

@ -160,7 +160,135 @@
"Bible video": "Video biblico",
"Gospel presentation": "Presentazione del Vangelo",
"Countries": "Paesi",
"People groups": "Popoli"
"People groups": "Popoli",
"People Group": "Popolo",
"Basis": "Basato su",
"Bridge ISO": "ISO ponte",
"Bridge language": "Lingua ponte",
"Film": "Film",
"Groups with nothing": "Popoli senza nulla",
"People with nothing": "Persone senza nulla",
"Pools checked": "Fonti controllate",
"Audio": "Audio",
"Bridge candidate": "Candidata ponte",
"Bridge film": "Film in lingua ponte",
"Bridge scripture": "Scritture in lingua ponte",
"Bridge strength": "Forza del ponte",
"Est. deaths/day": "Decessi stimati/giorno",
"Film link": "Link al film",
"Full Bible": "Bibbia completa",
"NO RESOURCE": "NESSUNA RISORSA",
"Nobody praying": "Nessuno prega",
"People": "Persone",
"Pools NOT checked": "Fonti NON controllate",
"Scripture link": "Link alle Scritture",
"Sign language": "Lingua dei segni",
"Status": "Stato",
"Training": "Formazione al discepolato",
"% with nothing": "% senza nulla",
"ALIGNED": "CONDIZIONI SODDISFATTE",
"Audio link": "Link audio",
"Band": "Livello",
"Bible stories": "Storie bibliche",
"Can already hear": "Già possono ascoltare",
"Candidate % of country": "Candidata % del paese",
"Candidate ISO": "ISO candidata",
"Candidate is official": "Candidata ufficiale",
"Carries a resource": "Ha una risorsa",
"Channel": "Canale",
"Channel source": "Fonte del canale",
"Code": "Codice",
"Code note": "Nota sul codice",
"Code unlookupable": "Codice assente da ogni fonte",
"Code unmatchable": "Codice senza corrispondenza",
"Deaf bridge": "Ponte per sordi",
"Deaf bridge link": "Link ponte per sordi",
"Deaf bridge publisher": "Editore ponte per sordi",
"Deaf programme in country": "Programma per sordi nel paese",
"Death rate /1000": "Tasso di mortalità /1000",
"Est. deaths/yr": "Decessi stimati/anno",
"Example group": "Popolo di esempio",
"Film in this language": "Film in questa lingua",
"GAP": "DIVARIO",
"GROWING + UNREACHED": "IN CRESCITA + NON RAGGIUNTO",
"Gospel audio": "Audio del Vangelo",
"Groups": "Popoli",
"Groups sharing bridge": "Popoli che condividono il ponte",
"Groups speaking": "Popoli parlanti",
"Groups speaking it": "Popoli che la parlano",
"Groups the map link shows": "Popoli mostrati dal link alla mappa",
"Groups with Scripture": "Popoli con Scritture",
"Groups with film": "Popoli con film",
"Growing & unreached": "In crescita e non raggiunti",
"Growth %/yr": "Crescita %/anno",
"Has anything": "Ha qualcosa",
"Heart film": "Film nella lingua propria",
"Heart language": "Lingua propria",
"Heart scripture": "Scritture nella lingua propria",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Sua popolazione",
"JESUS Film": "Film GESÙ",
"Kind of gap": "Tipo di divario",
"Kind of way in": "Tipo di via d'accesso",
"Languages": "Lingue",
"Languages here": "Lingue qui",
"Languages they speak": "Lingue parlate",
"Largest group": "Popolo più grande",
"Largest groups": "Popoli più grandi",
"Largest with nothing": "Più grande senza nulla",
"Lead status": "Stato della pista",
"Link": "Link",
"Method": "Metodo",
"NT": "NT",
"Note": "Nota",
"OPPORTUNITY": "OPPORTUNITÀ",
"Of those with a prayer page": "Di quelli con una pagina di preghiera",
"On the map": "Sulla mappa",
"Open": "Apri",
"People groups here": "Popoli qui",
"People nobody is praying for": "Persone per cui nessuno prega",
"People praying": "Persone che pregano",
"Place": "Luogo",
"Placement approximate": "Posizione approssimativa",
"Pools not checked": "Fonti non controllate",
"Pray": "Prega",
"Primary religion": "Religione principale",
"Province": "Provincia",
"Rank": "Classifica",
"Resource": "Risorsa",
"Scope": "Estensione",
"Scripture in this language": "Scritture in questa lingua",
"Signed film": "Film in lingua dei segni",
"Signed film link": "Link film in lingua dei segni",
"Source": "Fonte",
"Speakers": "Parlanti",
"Their language": "La loro lingua",
"These peoples on the map": "Questi popoli sulla mappa",
"Training in the bridge": "Formazione nella lingua ponte",
"Translated by": "Tradotto da",
"Type": "Tipo",
"UPG population": "Popolazione dei popoli non raggiunti",
"Urban %": "Urbano %",
"Ways in": "Vie d'accesso",
"Ways in (country-level)": "Vie d'accesso (livello paese)",
"Year": "Anno"
},
"tableTitles": {
"doxa": "Popoli Doxa — in cosa è disponibile il Vangelo, e cosa manca",
"links": "Risorse del Vangelo — ogni link, e chi raggiunge",
"nothing": "Dove non si è trovato nulla",
"countrygap": "Il divario per paese",
"translation": "Priorità di traduzione — possono ascoltarlo, nessun testo trovato",
"translationcountry": "Priorità di traduzione per paese — possono ascoltarlo, nessun testo trovato",
"gaps": "Divari e opportunità",
"bridges": "Opportunità di ponti urbani",
"deaf": "Il divario sordo",
"resources": "Popoli × risorse del Vangelo",
"languages": "Lingue per paese (in classifica) x risorse",
"growth": "Accesso al Vangelo vs crescita della popolazione",
"province": "Accesso al Vangelo per provincia — India e Cina",
"bridgereach": "Una lingua, quanti popoli — la via d'accesso, ordinata per portata"
},
"charts": {
"scriptureAny": "Scritture (qualsiasi)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "La mia lista UPG 100 — Liste di preghiera e poster",
"upg100WallTag": "Mobilitazione alla preghiera",
"upg100WallIntro": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro <strong>con il nome del loro popolo</strong>.",
"upg100WallIntroA": "Distribuisci il compito della preghiera in gruppi di cinquanta e di cento — come Gesù sfamò i 5.000 (Luca 9:14). Ogni poster è una lista di 100 che puoi pregare, stampare e appendere al muro — pregando per loro ",
"upg100WallIntroEmph": "con il nome del loro popolo",
"upg100WallIntroB": ".",
"loadingNamed": "Caricamento di {name}…",
"couldNotLoadNamed": "Impossibile caricare {name}.",
"engineMissing": "Il motore di mappa {engine} non è stato caricato.",

View file

@ -77,5 +77,6 @@
"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",
"n_shown": "{n} apresentados"
"n_shown": "{n} apresentados",
"other": "Outros"
}

View file

@ -160,7 +160,135 @@
"Bible video": "Vídeo bíblico",
"Gospel presentation": "Apresentação do evangelho",
"Countries": "Países",
"People groups": "Povos"
"People groups": "Povos",
"People Group": "Povo",
"Basis": "Com base em",
"Bridge ISO": "ISO da ponte",
"Bridge language": "Língua-ponte",
"Film": "Filme",
"Groups with nothing": "Povos sem nada",
"People with nothing": "Pessoas sem nada",
"Pools checked": "Fontes verificadas",
"Audio": "Áudio",
"Bridge candidate": "Candidata a ponte",
"Bridge film": "Filme na língua-ponte",
"Bridge scripture": "Escrituras na língua-ponte",
"Bridge strength": "Força da ponte",
"Est. deaths/day": "Mortes estimadas/dia",
"Film link": "Link do filme",
"Full Bible": "Bíblia completa",
"NO RESOURCE": "NENHUM RECURSO",
"Nobody praying": "Ninguém orando",
"People": "Pessoas",
"Pools NOT checked": "Fontes NÃO verificadas",
"Scripture link": "Link das Escrituras",
"Sign language": "Língua de sinais",
"Status": "Status",
"Training": "Treinamento de discipulado",
"% with nothing": "% sem nada",
"ALIGNED": "CONDIÇÕES ATENDIDAS",
"Audio link": "Link do áudio",
"Band": "Nível",
"Bible stories": "Histórias bíblicas",
"Can already hear": "Já podem ouvir",
"Candidate % of country": "Candidata % do país",
"Candidate ISO": "ISO da candidata",
"Candidate is official": "Candidata é oficial",
"Carries a resource": "Tem um recurso",
"Channel": "Canal",
"Channel source": "Fonte do canal",
"Code": "Código",
"Code note": "Nota do código",
"Code unlookupable": "Código em nenhuma fonte",
"Code unmatchable": "Código sem correspondência",
"Deaf bridge": "Ponte para surdos",
"Deaf bridge link": "Link da ponte para surdos",
"Deaf bridge publisher": "Editora da ponte para surdos",
"Deaf programme in country": "Programa para surdos no país",
"Death rate /1000": "Taxa de mortalidade /1000",
"Est. deaths/yr": "Mortes estimadas/ano",
"Example group": "Povo de exemplo",
"Film in this language": "Filme neste idioma",
"GAP": "LACUNA",
"GROWING + UNREACHED": "EM CRESCIMENTO + NÃO ALCANÇADO",
"Gospel audio": "Áudio do evangelho",
"Groups": "Povos",
"Groups sharing bridge": "Povos que compartilham a ponte",
"Groups speaking": "Povos falantes",
"Groups speaking it": "Povos que a falam",
"Groups the map link shows": "Povos mostrados pelo link do mapa",
"Groups with Scripture": "Povos com Escrituras",
"Groups with film": "Povos com filme",
"Growing & unreached": "Em crescimento e não alcançados",
"Growth %/yr": "Crescimento %/ano",
"Has anything": "Tem algo",
"Heart film": "Filme no idioma próprio",
"Heart language": "Idioma próprio",
"Heart scripture": "Escrituras no idioma próprio",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Sua população",
"JESUS Film": "Filme JESUS",
"Kind of gap": "Tipo de lacuna",
"Kind of way in": "Tipo de porta de entrada",
"Languages": "Idiomas",
"Languages here": "Idiomas aqui",
"Languages they speak": "Idiomas que falam",
"Largest group": "Maior povo",
"Largest groups": "Maiores povos",
"Largest with nothing": "Maior sem nada",
"Lead status": "Status da pista",
"Link": "Link",
"Method": "Método",
"NT": "NT",
"Note": "Nota",
"OPPORTUNITY": "OPORTUNIDADE",
"Of those with a prayer page": "Dos que têm página de oração",
"On the map": "No mapa",
"Open": "Abrir",
"People groups here": "Povos aqui",
"People nobody is praying for": "Pessoas por quem ninguém ora",
"People praying": "Pessoas orando",
"Place": "Local",
"Placement approximate": "Localização aproximada",
"Pools not checked": "Fontes não verificadas",
"Pray": "Orar",
"Primary religion": "Religião principal",
"Province": "Província",
"Rank": "Posição",
"Resource": "Recurso",
"Scope": "Abrangência",
"Scripture in this language": "Escrituras neste idioma",
"Signed film": "Filme em língua de sinais",
"Signed film link": "Link do filme em língua de sinais",
"Source": "Fonte",
"Speakers": "Falantes",
"Their language": "Seu idioma",
"These peoples on the map": "Estes povos no mapa",
"Training in the bridge": "Treinamento na língua-ponte",
"Translated by": "Traduzido por",
"Type": "Tipo",
"UPG population": "População dos povos não alcançados",
"Urban %": "Urbano %",
"Ways in": "Portas de entrada",
"Ways in (country-level)": "Portas de entrada (nível país)",
"Year": "Ano"
},
"tableTitles": {
"doxa": "Povos Doxa — em que o evangelho está disponível, e o que falta",
"links": "Recursos do evangelho — cada link, e quem ele alcança",
"nothing": "Onde nada foi encontrado",
"countrygap": "A lacuna por país",
"translation": "Prioridade de tradução — podem ouvir, nenhum texto encontrado",
"translationcountry": "Prioridade de tradução por país — podem ouvir, nenhum texto encontrado",
"gaps": "Lacunas e oportunidades",
"bridges": "Oportunidades de pontes urbanas",
"deaf": "A lacuna surda",
"resources": "Povos × recursos do evangelho",
"languages": "Idiomas por país (classificados) x recursos",
"growth": "Acesso ao evangelho vs crescimento populacional",
"province": "Acesso ao Evangelho por província — Índia e China",
"bridgereach": "Uma língua, quantos povos — a porta de entrada, ordenada por alcance"
},
"charts": {
"scriptureAny": "Escrituras (qualquer)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "A minha lista UPG 100 — Listas de oração e cartazes",
"upg100WallTag": "Mobilização de oração",
"upg100WallIntro": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles <strong>pelo nome do seu povo</strong>.",
"upg100WallIntroA": "Reparta a tarefa da oração em grupos de cinquenta e de cem — como Jesus alimentou os 5.000 (Lucas 9:14). Cada cartaz é uma lista de 100 que pode orar, imprimir e pôr na parede — orando por eles ",
"upg100WallIntroEmph": "pelo nome do seu povo",
"upg100WallIntroB": ".",
"loadingNamed": "Carregando {name}…",
"couldNotLoadNamed": "Não foi possível carregar {name}.",
"engineMissing": "O motor de mapa {engine} não foi carregado.",

View file

@ -77,5 +77,6 @@
"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",
"n_shown": "{n} afișate"
"n_shown": "{n} afișate",
"other": "Altele"
}

View file

@ -160,7 +160,135 @@
"Bible video": "Video biblic",
"Gospel presentation": "Prezentarea Evangheliei",
"Countries": "Țări",
"People groups": "Popoare"
"People groups": "Popoare",
"People Group": "Popor",
"Basis": "Pe baza",
"Bridge ISO": "ISO punte",
"Bridge language": "Limbă-punte",
"Film": "Film",
"Groups with nothing": "Popoare fără nimic",
"People with nothing": "Persoane fără nimic",
"Pools checked": "Surse verificate",
"Audio": "Audio",
"Bridge candidate": "Candidată punte",
"Bridge film": "Film în limba-punte",
"Bridge scripture": "Scripturi în limba-punte",
"Bridge strength": "Puterea punții",
"Est. deaths/day": "Decese estimate/zi",
"Film link": "Link film",
"Full Bible": "Biblia completă",
"NO RESOURCE": "NICIO RESURSĂ",
"Nobody praying": "Nimeni nu se roagă",
"People": "Persoane",
"Pools NOT checked": "Surse NEVERIFICATE",
"Scripture link": "Link Scripturi",
"Sign language": "Limba semnelor",
"Status": "Stare",
"Training": "Instruire în ucenicie",
"% with nothing": "% fără nimic",
"ALIGNED": "CONDIȚII ÎNDEPLINITE",
"Audio link": "Link audio",
"Band": "Nivel",
"Bible stories": "Povestiri biblice",
"Can already hear": "Pot deja auzi",
"Candidate % of country": "Candidată % din țară",
"Candidate ISO": "ISO candidată",
"Candidate is official": "Candidată e oficială",
"Carries a resource": "Are o resursă",
"Channel": "Canal",
"Channel source": "Sursa canalului",
"Code": "Cod",
"Code note": "Notă cod",
"Code unlookupable": "Cod absent din orice sursă",
"Code unmatchable": "Cod fără corespondență",
"Deaf bridge": "Punte pentru surzi",
"Deaf bridge link": "Link punte pentru surzi",
"Deaf bridge publisher": "Editor punte pentru surzi",
"Deaf programme in country": "Program pentru surzi în țară",
"Death rate /1000": "Rata mortalității /1000",
"Est. deaths/yr": "Decese estimate/an",
"Example group": "Popor exemplu",
"Film in this language": "Film în această limbă",
"GAP": "GOL",
"GROWING + UNREACHED": "ÎN CREȘTERE + NEATINS",
"Gospel audio": "Audio cu Evanghelia",
"Groups": "Popoare",
"Groups sharing bridge": "Popoare cu aceeași punte",
"Groups speaking": "Popoare vorbitoare",
"Groups speaking it": "Popoare care o vorbesc",
"Groups the map link shows": "Popoare afișate de linkul hărții",
"Groups with Scripture": "Popoare cu Scripturi",
"Groups with film": "Popoare cu film",
"Growing & unreached": "În creștere și neatinse",
"Growth %/yr": "Creștere %/an",
"Has anything": "Are ceva",
"Heart film": "Film în limba proprie",
"Heart language": "Limba proprie",
"Heart scripture": "Scripturi în limba proprie",
"Internet": "Internet",
"Internet %": "Internet %",
"Its population": "Populația sa",
"JESUS Film": "Filmul ISUS",
"Kind of gap": "Tipul golului",
"Kind of way in": "Tipul căii de acces",
"Languages": "Limbi",
"Languages here": "Limbi aici",
"Languages they speak": "Limbile vorbite",
"Largest group": "Cel mai mare popor",
"Largest groups": "Cele mai mari popoare",
"Largest with nothing": "Cel mai mare fără nimic",
"Lead status": "Stadiul pistei",
"Link": "Link",
"Method": "Metodă",
"NT": "NT",
"Note": "Notă",
"OPPORTUNITY": "OPORTUNITATE",
"Of those with a prayer page": "Dintre cele cu pagină de rugăciune",
"On the map": "Pe hartă",
"Open": "Deschide",
"People groups here": "Popoare aici",
"People nobody is praying for": "Persoane pentru care nu se roagă nimeni",
"People praying": "Persoane care se roagă",
"Place": "Loc",
"Placement approximate": "Amplasare aproximativă",
"Pools not checked": "Surse neverificate",
"Pray": "Roagă-te",
"Primary religion": "Religia principală",
"Province": "Provincie",
"Rank": "Rang",
"Resource": "Resursă",
"Scope": "Acoperire",
"Scripture in this language": "Scripturi în această limbă",
"Signed film": "Film în limba semnelor",
"Signed film link": "Link film în limba semnelor",
"Source": "Sursă",
"Speakers": "Vorbitori",
"Their language": "Limba lor",
"These peoples on the map": "Aceste popoare pe hartă",
"Training in the bridge": "Instruire în limba-punte",
"Translated by": "Tradus de",
"Type": "Tip",
"UPG population": "Populația popoarelor neatinse",
"Urban %": "Urban %",
"Ways in": "Căi de acces",
"Ways in (country-level)": "Căi de acces (la nivel de țară)",
"Year": "An"
},
"tableTitles": {
"doxa": "Popoare Doxa — în ce este disponibilă Evanghelia și ce lipsește",
"links": "Resurse ale Evangheliei — fiecare link și pe cine atinge",
"nothing": "Unde nu s-a găsit nimic",
"countrygap": "Golul pe țări",
"translation": "Prioritate de traducere — pot auzi, niciun text găsit",
"translationcountry": "Prioritate de traducere pe țări — pot auzi, niciun text găsit",
"gaps": "Goluri și oportunități",
"bridges": "Oportunități de punți urbane",
"deaf": "Golul surd",
"resources": "Popoare × resurse ale Evangheliei",
"languages": "Limbi pe țări (clasate) x resurse",
"growth": "Accesul la Evanghelie vs creșterea populației",
"province": "Accesul la Evanghelie pe provincii — India și China",
"bridgereach": "O limbă, câte popoare — calea de acces, ordonată după acoperire"
},
"charts": {
"scriptureAny": "Scripturi (oricare)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Lista mea UPG 100 — Liste de rugăciune și afișe",
"upg100WallTag": "Mobilizare pentru rugăciune",
"upg100WallIntro": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei <strong>pe numele poporului lor</strong>.",
"upg100WallIntroA": "Împarte sarcina rugăciunii în grupuri de câte cincizeci și o sută — așa cum Isus a hrănit cei 5.000 (Luca 9:14). Fiecare afiș este o listă de 100 pe care o poți ruga, tipări și pune pe perete — rugându-te pentru ei ",
"upg100WallIntroEmph": "pe numele poporului lor",
"upg100WallIntroB": ".",
"loadingNamed": "Se încarcă {name}…",
"couldNotLoadNamed": "Nu s-a putut încărca {name}.",
"engineMissing": "Motorul de hartă {engine} nu s-a încărcat.",

View file

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

View file

@ -160,7 +160,135 @@
"Bible video": "Библейское видео",
"Gospel presentation": "Изложение Евангелия",
"Countries": "Страны",
"People groups": "Народы"
"People groups": "Народы",
"People Group": "Народ",
"Basis": "Уровень данных",
"Bridge ISO": "ISO посредника",
"Bridge language": "Язык-посредник",
"Film": "Фильм",
"Groups with nothing": "Народов без ресурсов",
"People with nothing": "Людей без ресурсов",
"Pools checked": "Проверенные источники",
"Audio": "Аудио",
"Bridge candidate": "Кандидат в посредники",
"Bridge film": "Фильм на языке-посреднике",
"Bridge scripture": "Писание на языке-посреднике",
"Bridge strength": "Сила посредника",
"Est. deaths/day": "Оценка смертей в день",
"Film link": "Ссылка на фильм",
"Full Bible": "Полная Библия",
"NO RESOURCE": "НЕТ РЕСУРСОВ",
"Nobody praying": "Никто не молится",
"People": "Людей",
"Pools NOT checked": "НЕпроверенные источники",
"Scripture link": "Ссылка на Писание",
"Sign language": "Жестовый язык",
"Status": "Статус",
"Training": "Обучение ученичеству",
"% with nothing": "% без ресурсов",
"ALIGNED": "УСЛОВИЯ ВЫПОЛНЕНЫ",
"Audio link": "Ссылка на аудио",
"Band": "Уровень",
"Bible stories": "Библейские истории",
"Can already hear": "Уже могут услышать",
"Candidate % of country": "Кандидат, % страны",
"Candidate ISO": "ISO кандидата",
"Candidate is official": "Кандидат официальный",
"Carries a resource": "Есть ресурс",
"Channel": "Канал",
"Channel source": "Источник канала",
"Code": "Код",
"Code note": "Примечание к коду",
"Code unlookupable": "Кода нет ни в одном источнике",
"Code unmatchable": "Код без соответствия",
"Deaf bridge": "Посредник для глухих",
"Deaf bridge link": "Ссылка: посредник для глухих",
"Deaf bridge publisher": "Издатель: посредник для глухих",
"Deaf programme in country": "Программа для глухих в стране",
"Death rate /1000": "Смертность /1000",
"Est. deaths/yr": "Оценка смертей в год",
"Example group": "Пример народа",
"Film in this language": "Фильм на этом языке",
"GAP": "ПРОБЕЛ",
"GROWING + UNREACHED": "РАСТУЩИЕ + НЕДОСТИГНУТЫЕ",
"Gospel audio": "Аудио с Евангелием",
"Groups": "Народов",
"Groups sharing bridge": "Народов с тем же посредником",
"Groups speaking": "Говорящих народов",
"Groups speaking it": "Народов, говорящих на нём",
"Groups the map link shows": "Народы по ссылке на карту",
"Groups with Scripture": "Народов с Писанием",
"Groups with film": "Народов с фильмом",
"Growing & unreached": "Растущие и недостигнутые",
"Growth %/yr": "Рост %/год",
"Has anything": "Есть что-то",
"Heart film": "Фильм на родном языке",
"Heart language": "Родной язык",
"Heart scripture": "Писание на родном языке",
"Internet": "Интернет",
"Internet %": "Интернет %",
"Its population": "Его численность",
"JESUS Film": "Фильм «Иисус»",
"Kind of gap": "Вид пробела",
"Kind of way in": "Вид пути",
"Languages": "Языки",
"Languages here": "Языки здесь",
"Languages they speak": "Их языки",
"Largest group": "Крупнейший народ",
"Largest groups": "Крупнейшие народы",
"Largest with nothing": "Крупнейший без ресурсов",
"Lead status": "Статус наводки",
"Link": "Ссылка",
"Method": "Метод",
"NT": "НЗ",
"Note": "Примечание",
"OPPORTUNITY": "ВОЗМОЖНОСТЬ",
"Of those with a prayer page": "Из тех, у кого есть страница молитвы",
"On the map": "На карте",
"Open": "Открыть",
"People groups here": "Народы здесь",
"People nobody is praying for": "Люди, за которых никто не молится",
"People praying": "Молящиеся",
"Place": "Место",
"Placement approximate": "Расположение приблизительное",
"Pools not checked": "Непроверенные источники",
"Pray": "Молиться",
"Primary religion": "Основная религия",
"Province": "Провинция",
"Rank": "Ранг",
"Resource": "Ресурс",
"Scope": "Охват",
"Scripture in this language": "Писание на этом языке",
"Signed film": "Фильм на жестовом языке",
"Signed film link": "Ссылка на фильм на жестовом языке",
"Source": "Источник",
"Speakers": "Носители",
"Their language": "Их язык",
"These peoples on the map": "Эти народы на карте",
"Training in the bridge": "Обучение на языке-посреднике",
"Translated by": "Переводчик",
"Type": "Тип",
"UPG population": "Численность недостигнутых народов",
"Urban %": "Городское население %",
"Ways in": "Пути",
"Ways in (country-level)": "Пути (на уровне страны)",
"Year": "Год"
},
"tableTitles": {
"doxa": "Народы Doxa — что из Евангелия доступно и чего не хватает",
"links": "Ресурсы Евангелия — каждая ссылка и кого она достигает",
"nothing": "Где ничего не найдено",
"countrygap": "Пробел по странам",
"translation": "Приоритет перевода — могут услышать, текст не найден",
"translationcountry": "Приоритет перевода по странам — могут услышать, текст не найден",
"gaps": "Пробелы и возможности",
"bridges": "Возможности городских мостов",
"deaf": "Пробел у глухих",
"resources": "Народы × ресурсы Евангелия",
"languages": "Языки по странам (по рангу) x ресурсы",
"growth": "Доступ к Евангелию и рост населения",
"province": "Доступ к Евангелию по провинциям — Индия и Китай",
"bridgereach": "Один язык — сколько народов: путь, по охвату"
},
"charts": {
"scriptureAny": "Писание (любое)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "Мой список UPG 100 — молитвенные списки и плакаты",
"upg100WallTag": "Молитвенная мобилизация",
"upg100WallIntro": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них <strong>по имени их народа</strong>.",
"upg100WallIntroA": "Разделите молитвенный труд на группы по пятьдесят и по сто — так Иисус накормил 5000 (Луки 9:14). Каждый плакат — это список из 100, о котором можно молиться, распечатать и повесить на стену, молясь о них ",
"upg100WallIntroEmph": "по имени их народа",
"upg100WallIntroB": ".",
"loadingNamed": "Загрузка {name}…",
"couldNotLoadNamed": "Не удалось загрузить {name}.",
"engineMissing": "Картографический движок {engine} не загрузился.",

View file

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

View file

@ -160,7 +160,135 @@
"Bible video": "圣经视频",
"Gospel presentation": "福音简介",
"Countries": "国家",
"People groups": "族群"
"People groups": "族群",
"People Group": "族群",
"Basis": "数据层级",
"Bridge ISO": "桥梁语言 ISO",
"Bridge language": "桥梁语言",
"Film": "影片",
"Groups with nothing": "一无所有的族群",
"People with nothing": "一无所有的人数",
"Pools checked": "已查资源库",
"Audio": "音频",
"Bridge candidate": "候选桥梁语言",
"Bridge film": "桥梁语言影片",
"Bridge scripture": "桥梁语言圣经",
"Bridge strength": "桥梁强度",
"Est. deaths/day": "每日估计死亡人数",
"Film link": "影片链接",
"Full Bible": "全本圣经",
"NO RESOURCE": "无资源",
"Nobody praying": "无人代祷",
"People": "人数",
"Pools NOT checked": "未查的资源库",
"Scripture link": "圣经链接",
"Sign language": "手语",
"Status": "状态",
"Training": "门徒训练",
"% with nothing": "% 一无所有",
"ALIGNED": "条件齐备",
"Audio link": "音频链接",
"Band": "等级",
"Bible stories": "圣经故事",
"Can already hear": "已能听到",
"Candidate % of country": "候选语言占全国 %",
"Candidate ISO": "候选 ISO",
"Candidate is official": "候选为官方语言",
"Carries a resource": "有资源",
"Channel": "渠道",
"Channel source": "渠道来源",
"Code": "代码",
"Code note": "代码说明",
"Code unlookupable": "代码未被任何资源库收录",
"Code unmatchable": "代码无法匹配",
"Deaf bridge": "聋人桥梁",
"Deaf bridge link": "聋人桥梁链接",
"Deaf bridge publisher": "聋人桥梁发布方",
"Deaf programme in country": "国内聋人事工",
"Death rate /1000": "死亡率 /1000",
"Est. deaths/yr": "每年估计死亡人数",
"Example group": "示例族群",
"Film in this language": "该语言影片",
"GAP": "缺口",
"GROWING + UNREACHED": "增长中 + 未接触",
"Gospel audio": "福音音频",
"Groups": "族群数",
"Groups sharing bridge": "共用桥梁的族群",
"Groups speaking": "使用族群",
"Groups speaking it": "使用该语言的族群",
"Groups the map link shows": "地图链接显示的族群",
"Groups with Scripture": "有圣经的族群",
"Groups with film": "有影片的族群",
"Growing & unreached": "增长中且未接触",
"Growth %/yr": "增长率 %/年",
"Has anything": "有任何资源",
"Heart film": "自己语言的影片",
"Heart language": "自己的语言",
"Heart scripture": "自己语言的圣经",
"Internet": "互联网",
"Internet %": "互联网 %",
"Its population": "其人口",
"JESUS Film": "《耶稣传》",
"Kind of gap": "缺口类型",
"Kind of way in": "切入点类型",
"Languages": "语言",
"Languages here": "本地语言",
"Languages they speak": "所用语言",
"Largest group": "最大族群",
"Largest groups": "最大的族群",
"Largest with nothing": "一无所有的最大族群",
"Lead status": "线索状态",
"Link": "链接",
"Method": "方法",
"NT": "新约",
"Note": "备注",
"OPPORTUNITY": "机会",
"Of those with a prayer page": "其中有祷告页面的",
"On the map": "在地图上",
"Open": "打开",
"People groups here": "本地族群",
"People nobody is praying for": "无人代祷的人数",
"People praying": "代祷人数",
"Place": "地点",
"Placement approximate": "位置为近似",
"Pools not checked": "未查资源库",
"Pray": "代祷",
"Primary religion": "主要宗教",
"Province": "省份",
"Rank": "排名",
"Resource": "资源",
"Scope": "涵盖程度",
"Scripture in this language": "该语言圣经",
"Signed film": "手语影片",
"Signed film link": "手语影片链接",
"Source": "来源",
"Speakers": "使用人数",
"Their language": "他们的语言",
"These peoples on the map": "地图上的这些民族",
"Training in the bridge": "桥梁语言培训",
"Translated by": "译者",
"Type": "类型",
"UPG population": "未接触族群人口",
"Urban %": "城镇 %",
"Ways in": "切入点",
"Ways in (country-level)": "切入点(国家层面)",
"Year": "年份"
},
"tableTitles": {
"doxa": "Doxa 族群 — 福音有哪些形式可用,还缺什么",
"links": "福音资源 — 每个链接及其覆盖的人群",
"nothing": "一无所获之处",
"countrygap": "各国缺口",
"translation": "翻译优先级 — 能听,未找到文字",
"translationcountry": "各国翻译优先级 — 能听,未找到文字",
"gaps": "缺口与机会",
"bridges": "城市桥梁机会",
"deaf": "聋人缺口",
"resources": "族群 × 福音资源",
"languages": "各国语言(排名)x 资源",
"growth": "福音可及性与人口增长",
"province": "按省份划分的福音可及性 — 印度和中国",
"bridgereach": "一种语言,覆盖多少民族 — 切入点,按覆盖面排序"
},
"charts": {
"scriptureAny": "圣经(任意)",

View file

@ -6,6 +6,9 @@
"upg100WallHeading": "我的 UPG 100 名單 — 禱告名單與海報",
"upg100WallTag": "禱告動員",
"upg100WallIntro": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — <strong>按著他們族群的名字</strong>為他們禱告。",
"upg100WallIntroA": "將禱告的工作分成五十人、一百人一組 — 就像耶穌餵飽五千人那樣(路加福音 9:14)。每一張海報都是一份 100 人名單,你可以為他們禱告、列印出來、貼在牆上 — ",
"upg100WallIntroEmph": "按著他們族群的名字",
"upg100WallIntroB": "為他們禱告。",
"loadingNamed": "正在加载{name}…",
"couldNotLoadNamed": "无法加载{name}。",
"engineMissing": "{engine} 地图引擎未能加载。",

View file

@ -0,0 +1,122 @@
#!/usr/bin/env node
// generate-iso3-to-alpha2.mjs — `bun run gen:iso3-to-alpha2`. Offline, no network, no dependency.
//
// Writes library/data/iso3-to-alpha2.json: the alpha-2 code for each of the 242 alpha-3 codes in
// library/data/countries.json, so a country NAME can be localized. Intl.DisplayNames('region') takes
// alpha-2 or UN M49 only -- it does NOT accept alpha-3 -- and our data speaks alpha-3 throughout, so
// without this join every country name is stuck in English.
//
// ★ THIS IS NOT THE RESEARCH MAP. library/scripts/research/out/iso3-to-iso2.json is a different file
// with a different job: the 176-country spine that drives the data FETCHES. Expanding that one changes
// what gets fetched, which is a data-scope decision for the Driver (dx-shepherd, REG242B) and must not
// happen as a side effect of adding country names. This map drives no fetch. The two are deliberately
// not interchangeable, which is why they have different names, different shapes and this paragraph.
//
// ★ IT GRADES ITS OWN OUTPUT AND EXITS NON-ZERO. A generator that writes whatever it derived is a
// generator that will one day write a hole. Coverage, injectivity and the single documented exception
// are asserted here, so a bad refresh of the vendored CLDR cannot land quietly.
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO || resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
const read = (p) => JSON.parse(readFileSync(join(REPO, p), 'utf8'))
const VENDOR = 'library/vendor/cldr/codeMappings.json'
const OUT = 'library/data/iso3-to-alpha2.json'
// ── the one exception, and why it is not a fudge ─────────────────────────────────────────────────
// Kosovo has NO ISO 3166-1 assignment, so CLDR's codeMappings cannot carry it and no amount of
// re-downloading will add it. XK is the USER-ASSIGNED code the EU, the IMF, Unicode's own CLDR
// locale data (sr_Cyrl_XK) and every major platform settled on. It is recorded here as a named
// exception with its reason rather than patched into the vendored file, so the vendored bytes stay
// exactly upstream's and the deviation stays visible to the next reader.
const EXCEPTIONS = {
KOS: { alpha2: 'XK', why: 'Kosovo has no ISO 3166-1 assignment; XK is the user-assigned code CLDR itself uses in locale ids (sr_Cyrl_XK).' },
}
const cldr = read(VENDOR).supplemental.codeMappings
const countries = read('library/data/countries.json')
// CLDR is keyed by alpha-2 with an _alpha3 inside; we need the inverse.
const byAlpha3 = new Map()
const cldrCollisions = []
for (const [alpha2, v] of Object.entries(cldr)) {
const a3 = v && v._alpha3
if (!a3) continue
if (byAlpha3.has(a3)) cldrCollisions.push(`${a3} claimed by ${byAlpha3.get(a3)} and ${alpha2}`)
else byAlpha3.set(a3, alpha2)
}
const map = {}
const missing = []
for (const { value: iso3, label } of countries) {
const fromCldr = byAlpha3.get(iso3)
const exc = EXCEPTIONS[iso3]
if (fromCldr) map[iso3] = fromCldr
else if (exc) map[iso3] = exc.alpha2
else missing.push(`${iso3} (${label})`)
}
// ── assertions: the generator refuses to ship a map it cannot defend ────────────────────────────
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
ok(countries.length === 242, `the country list holds 242 codes (found ${countries.length})`)
ok(missing.length === 0, missing.length
? `${missing.length} code(s) have NO alpha-2 and no documented exception: ${missing.join(', ')}`
: 'every code resolved, via CLDR or a documented exception')
const seen = new Map()
const dupes = []
for (const [iso3, a2] of Object.entries(map)) {
if (seen.has(a2)) dupes.push(`${a2} claimed by ${seen.get(a2)} and ${iso3}`)
else seen.set(a2, iso3)
}
ok(dupes.length === 0, dupes.length
? `alpha-2 collisions -- at least one country would take another's name: ${dupes.join('; ')}`
: `all ${Object.keys(map).length} codes map to a DISTINCT alpha-2`)
ok(Object.entries(map).every(([, a2]) => /^[A-Z]{2}$/.test(a2)), 'every alpha-2 is two uppercase letters')
// Every exception must still BE one. If CLDR ever assigns KOS, this fires and the entry should go.
for (const [iso3, e] of Object.entries(EXCEPTIONS)) {
ok(!byAlpha3.has(iso3),
`the ${iso3} exception is still needed -- CLDR ${byAlpha3.has(iso3) ? `now assigns it ${byAlpha3.get(iso3)}, so DELETE the exception` : 'still has no assignment for it'}`)
}
// ── cross-check the research map rather than touching it ────────────────────────────────────────
// CLDR is an independent source for the 176 values that map already ships. Disagreements are reported,
// never written: that file drives fetches and is not this generator's to edit.
let disagreements = 0
try {
const research = read('library/scripts/research/out/iso3-to-iso2.json')
for (const [iso3, a2] of Object.entries(research)) {
const c = byAlpha3.get(iso3)
if (c && c !== a2) { console.log(` FAIL - research map says ${iso3} -> ${a2}, CLDR says ${c}`); disagreements++ }
}
ok(disagreements === 0,
`CLDR agrees with all ${Object.keys(research).length} values in the research spine map (reported, never rewritten -- that file drives fetches)`)
} catch (e) {
console.log(` note - research map not readable here (${e.code || e.message}); cross-check skipped`)
}
if (fails) {
console.log(`\nISO3-TO-ALPHA2: ${fails} FAIL(s) — nothing written`)
process.exit(1)
}
const cldrVersion = '48.2.2' // keep in step with library/vendor/cldr/README.md
writeFileSync(join(REPO, OUT), JSON.stringify({
_meta: {
generated_by: 'library/scripts/generate-iso3-to-alpha2.mjs (bun run gen:iso3-to-alpha2)',
purpose: 'alpha-3 -> alpha-2 so Intl.DisplayNames can produce a LOCALIZED country name. Drives no data fetch.',
not_to_be_confused_with: 'library/scripts/research/out/iso3-to-iso2.json — the 176-country research spine that DOES drive fetches',
source: `CLDR cldr-core ${cldrVersion}, vendored at ${VENDOR} (Unicode-3.0)`,
countries: Object.keys(map).length,
exceptions: Object.fromEntries(Object.entries(EXCEPTIONS).map(([k, v]) => [k, `${v.alpha2} — ${v.why}`])),
},
map,
}, null, 2) + '\n')
console.log(`\nISO3-TO-ALPHA2: wrote ${OUT} — ${Object.keys(map).length} countries, ${Object.keys(EXCEPTIONS).length} documented exception(s)`)

View file

@ -118,6 +118,7 @@
"NFK": "NF",
"NGA": "NG",
"NIU": "NU",
"NOR": "NO",
"NPL": "NP",
"NZL": "NZ",
"OMN": "OM",
@ -137,7 +138,7 @@
"SDN": "SD",
"SEN": "SN",
"SHN": "SH",
"SJM": "NO",
"SJM": "SJ",
"SLB": "SB",
"SLE": "SL",
"SMR": "SM",

View file

@ -62,7 +62,17 @@ else ok('every value is a well-formed 2-letter uppercase code');
// 3. SPOT-CHECK the codes this corpus actually got wrong, so a regression is named
// rather than merely counted. These are the sovereign states whose alpha-2 was
// previously their neighbour's or their former administrator's.
const MUST = { NER: 'NE', NGA: 'NG', PSE: 'PS', ISR: 'IL', HKG: 'HK', MAC: 'MO', ABW: 'AW' };
// SJM belongs here for the same reason PSE and HKG do: it was mapped to its administrator's code.
// SJM -> NO served NORWAY's data under the name "Svalbard and Jan Mayen", and INJECTIVITY COULD NOT
// SEE IT -- the check passes as long as no two codes share an ISO2, and NOR is absent from this map
// entirely, so "NO" had exactly one claimant and looked clean. A uniqueness test cannot tell a right
// code from a wrong one that nobody else wanted; only a named expectation can.
// NOR belongs here as the OTHER HALF of the SJM defect. Norway was absent from this map entirely,
// which is why SJM -> NO looked injective and why Norway appeared in no downstream table at all --
// consumers filter on `ISO3_2[iso3]`, so a missing code is not an error, it is a silent omission.
// Added from ISO 3166-1 (NO/NOR), which is what CLDR's codeMappings encodes; the full CLDR vendor
// still comes with BUILD-ISO3-TABLE-COVERS-ALL-242-COUNTRIES and will re-derive this alongside 66 others.
const MUST = { NER: 'NE', NGA: 'NG', PSE: 'PS', ISR: 'IL', HKG: 'HK', MAC: 'MO', ABW: 'AW', SJM: 'SJ', NOR: 'NO' };
// ABSENT MUST FAIL, NOT PASS. The first version read `map[k] && map[k] !== v`, so a
// code MISSING ENTIRELY was falsey and slipped through — deleting NER outright passed
// all three checks and this line still printed "NER … all correct" about a code that
@ -77,7 +87,9 @@ if (!missing.length && !wrong.length) ok('previously-wrong codes (NER, PSE, HKG,
// The map must also stay COMPLETE. A silent shrink (175 -> 174) means a country
// stopped being fetched at all, which no per-code check can see.
const EXPECTED_MIN = 175;
// 175 -> 176 with NOR. The floor RISES when a code is added, or the check stops being able to see the
// shrink it exists for: left at 175 it would have called a regression back to the pre-NOR map clean.
const EXPECTED_MIN = 176;
if (Object.keys(map).length < EXPECTED_MIN) {
fail(`map has ${Object.keys(map).length} codes, expected at least ${EXPECTED_MIN} — a country has DISAPPEARED`);
} else ok(`${Object.keys(map).length} codes present (>= ${EXPECTED_MIN})`);

View file

@ -0,0 +1,100 @@
/**
* countryName.js — a country's name in the reader's language.
*
* Our data speaks ISO 3166-1 ALPHA-3 ("DEU"). Intl.DisplayNames('region') accepts alpha-2 or UN M49
* and NOT alpha-3, so every country name was stuck in English until the two were joined. The join is
* library/data/iso3-to-alpha2.json, derived from vendored CLDR by generate-iso3-to-alpha2.mjs.
*
* ★ Intl.DisplayNames THROWS on a missing code. `dn.of(undefined)` is a RangeError, not a null return,
* so every path here checks the alpha-2 before asking. That is the single defect this file exists to
* not have: a country absent from the map would otherwise take down whatever rendered it.
*
* ★ AND IT THROWS ON A MALFORMED ONE TOO, which is why the regex guard is not redundant with the
* lookup. dn.of('XYZ') is also a RangeError -- 'structurally invalid' is a different failure from
* 'not found', and only the second returns the input unchanged.
*
* FALLBACK ORDER, each step a real answer rather than a placeholder:
* 1. the localized name from Intl.DisplayNames
* 2. the canonical ENGLISH name from library/data/countries.json — a real name in the wrong language
* beats a bare code for a reader, and it is what our own tables already show
* 3. the alpha-3 code itself, so something identifiable always renders
*
* No i18n framework is required: this reads a locale STRING the caller already has.
*
* ★★ WHERE NOT TO USE THIS, MEASURED. It has NO consumer yet on purpose. Intl's English names
* differ from the canonical names in library/data/countries.json for 42 OF 242 COUNTRIES -- "Antigua &
* Barbuda" vs "Antigua and Barbuda", "Cape Verde" vs "Cabo Verde", "Hong Kong SAR China" vs "Hong
* Kong", "Myanmar (Burma)" vs "Myanmar", "Caribbean Netherlands" vs "Bonaire, Sint Eustatius, and
* Saba". So this is NOT a drop-in replacement for a name we already display:
*
* ✗ do NOT swap it in where an API already returns a LOCALIZED label. region-dashboards.vue:370
* prefers the API's label deliberately, and the regions surfaces are measured against live
* doxa.life -- switching the source would change 42 English names and break clone parity, which
* is a product decision about which name is canonical, not a refactor.
* ✓ DO use it where the only thing in hand is an alpha-3 and there is no label at all, or where
* a label arrives in English regardless of the reader's locale. That is the gap it was built for.
*
* Stated here rather than left to be discovered, because the failure mode is silent: the names all
* still look like names.
*/
import table from '../data/iso3-to-alpha2.json'
import countries from '../data/countries.json'
const ALPHA2 = table.map
const ENGLISH = new Map(countries.map((c) => [c.value, c.label]))
/** The alpha-2 for an alpha-3 code, or undefined. Exported because callers building URLs and flag
* assets need the same lookup, and a second copy of it would drift. */
export function alpha2Of(iso3) {
return iso3 ? ALPHA2[String(iso3).toUpperCase()] : undefined
}
/** The canonical English name, or undefined. */
export function englishName(iso3) {
return iso3 ? ENGLISH.get(String(iso3).toUpperCase()) : undefined
}
// One DisplayNames per locale. Constructing it is not cheap and a country list rebuilds it per row;
// a Map keyed by locale is the whole optimisation. A locale that throws is cached as null so the
// failure costs one construction, not one per call.
const cache = new Map()
function displayNames(locale) {
const key = locale || 'en'
if (cache.has(key)) return cache.get(key)
let dn = null
try {
dn = new Intl.DisplayNames([key], { type: 'region' })
} catch (_) {
dn = null // an unsupported locale tag, or no Intl.DisplayNames at all
}
cache.set(key, dn)
return dn
}
/**
* @param {string} iso3 ISO 3166-1 alpha-3, e.g. 'DEU'. Case-insensitive.
* @param {string} [locale] BCP-47 tag, e.g. 'ar'. Defaults to English.
* @returns {string} the country's name in that locale, its English name, or the code.
*/
export function countryName(iso3, locale) {
const code = iso3 ? String(iso3).toUpperCase() : ''
const a2 = ALPHA2[code]
const fallback = ENGLISH.get(code) || code
// Guard BEFORE asking: dn.of(undefined) and dn.of('XYZ') both throw.
if (!a2 || !/^[A-Z]{2}$/.test(a2)) return fallback
const dn = displayNames(locale)
if (!dn) return fallback
try {
const name = dn.of(a2)
// An UNKNOWN-but-well-formed region comes back as the input string. That is not a name, so it
// must not win over our English label -- this is the difference between "no translation exists"
// and "the translation is literally 'DE'".
return name && name !== a2 ? name : fallback
} catch (_) {
return fallback
}
}
export default countryName

41
library/vendor/cldr/LICENSE vendored Normal file
View file

@ -0,0 +1,41 @@
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2015-2024 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0

42
library/vendor/cldr/README.md vendored Normal file
View file

@ -0,0 +1,42 @@
# Vendored CLDR — `codeMappings.json`
The ISO 3166-1 alpha-2 / alpha-3 / numeric correspondences, used to turn the alpha-3 codes our data
speaks into the alpha-2 codes `Intl.DisplayNames` requires to produce a LOCALIZED country name.
| | |
|---|---|
| Package | `cldr-core` (unicode-org/cldr-json) |
| Version | **48.2.2** |
| Source | `https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-core/supplemental/codeMappings.json` |
| Licence | **Unicode-3.0** — full text in `LICENSE` beside this file |
| Bytes | 35,948 |
| sha256 | `0d1ef50b92c1140e5847d22d96faf1a9c35543b0dedf8e9a2fcb87e4c51b9ed6` |
| Vendored | 2026-09-27 |
## Why vendored rather than a dependency
No npm package is added for this. A dependency would pull CLDR's whole supplemental tree, and it would
make the derivation of a shipped data file depend on an install step. The file is 35KB, it is
licensed for redistribution, and vendoring it means `bun run gen:iso3-to-alpha2` reproduces the derived
map OFFLINE — which is the actual point of vendoring, not merely having a copy.
## What is derived from it
`library/scripts/generate-iso3-to-alpha2.mjs` joins this file against the 242 alpha-3 codes in
`library/data/countries.json` and writes `library/data/iso3-to-alpha2.json`. The generator asserts its
own output (242 coverage, injectivity, the one documented exception) and exits non-zero if any of it
fails, so a bad refresh cannot land quietly.
## DO NOT CONFUSE THIS WITH THE RESEARCH MAP
`library/scripts/research/out/iso3-to-iso2.json` is a DIFFERENT map with a different job: it is the
176-country research spine that drives the data fetches, and expanding it changes what gets fetched —
a data-scope decision that belongs to the Driver, not a side effect of adding country names. The map
derived here is for DISPLAY NAMES ONLY and drives no fetch. CLDR agrees with all 176 values in the
research map, which is a useful independent check on it, but the two files are not interchangeable.
## Refreshing
Re-download the URL above, update the version, bytes and sha256 in this table, then run the generator
and let its assertions grade the result. If CLDR ever disagrees with a value in the research map, the
generator says so rather than silently overwriting anything.

1934
library/vendor/cldr/codeMappings.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -162,6 +162,7 @@
"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:package-json-unique-keys": "node internal/check-package-json-unique-keys.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",
@ -195,7 +196,9 @@
"check:scrim-viewport:self-test": "node internal/check-scrim-covers-viewport.mjs --self-test",
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:settled": "node internal/settled-tree-gate.mjs",
"check:setup-order": "node internal/check-setup-order.mjs",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:shadow-css-parses": "node internal/check-shadow-css-parses.mjs",
"check:regions-uses-library": "node plugin-designs/regions-dashboard/scripts/check-regions-uses-library.mjs",
"check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
@ -234,8 +237,9 @@
"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 check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses",
"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 check:setup-order check:package-json-unique-keys",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
"lists": "node library/scripts/people-groups/build-lists.mjs",
@ -339,7 +343,7 @@
"check:heartbeat:self-test": "THREE regression cases, each required to fire ITS OWN assertion: [literal] resolver replaced by the old hard-coded array -> B; [shipped-default] dashboard.config.js heartbeatThreshold changed -> A2; [shipped-mode] heartbeatMode flipped to quantile -> A2. Poisons IN MEMORY, never the file. Testing that the FEATURE works is not testing that the REGRESSION is caught — this gate shipped with only the first and missed the second.",
"check:hover-channel": "SPEC-DASHBOARD-KIT D3: the bus's third coordinated channel points at a record without committing to one. Hover looks like a weaker selection, and the cheap implementation is to put the key in selectionSet 'just while hovering' - every consequence of which is bad and none obvious at the call site: useSelectionCamera watches selectionVersion, so a hover would fly the camera at every mousemove (the resize-strobe class, on a photosensitive Driver); the CSV exporter reads selection > filter > all, so a hover would change what an export contains; the table's selection pill and the shell's live region would both narrate the pointer. Runs the real bus: hover holds its own ref, never enters the selection, never bumps the version, never touches the filters, a repeat emits once (hover fires on every mousemove across 2,107 rows), and the three channels clear independently. Does NOT check which panels light up - D3 says panels opt in, and the first opt-in belongs with a consumer that has a reason. No browser.",
"check:host-locale-contract": "a HOST's profile-config.locale reaches every leaf: each leaf page is loaded with ONLY \"locale\":\"ar\" added to its profile-config (no ?lang=, the page keeps lang=\"en\") and must draw at least half the Arabic text the same page draws at ?lang=ar. regions-dashboard/regions-list is the positive control (NOT MEASURED if it does not enter Arabic). Guards ProfileLoader's inject('appI18n'): a bundle that never provides appI18n silently ignores the host. --dir <tree> for a before/after, --only a/b,c/d to narrow. Through browser-slot.sh.",
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run.",
"check:i18n-keys-exist": "every literal catalogue key named in code exists in library/i18n/locales/en/. A localized CALL SITE is not a localized STRING: the dashboard's five resize handles all read T('aria.resizeFiltersRail', ...) and look correct in review, while aria.json held eleven keys and not one was a resize key - so every handle announced itself in English in all eleven locales, and the fallback that makes a missing key degrade to readable English is exactly what kept it silent. No other gate asks this: i18n-parity is satisfied because the key is missing from ALL locales equally, and english-leaks and composite-locale-leaks both need the catalogue to HOLD the string before an appearance counts as a leak. Matches tStatic/$t/t and the house-pattern T( alias, all of which take a full dotted key; prefix helpers like L( and any concatenated or template-literal key are COUNTED as unresolved and never guessed at. Found 22 missing keys on its first run. ROUND 2, after go-int planted T('poison_key') behind `const T = useGiT()` and this gate reported the tree CLEAN -- the one failure a check is not forgiven, because a red gate gets fixed and a green one that should be red gets believed. THREE independent blindnesses stacked, and any one alone hid the key: the file never writes the word tStatic so the precondition skipped it before anything ran; useGiT is not tStatic so no alias was found; and the alias is PREFIXED (useOptionalT carries a namespace) while its call sites pass BARE keys, which the full-key pattern cannot match because it requires a dot. Now a translator that arrives from a COMPOSABLE is resolved like any other, with the namespace READ from the factory's one-line body rather than listed here -- useOptionalT/useOptionalI18n directly, plus any `export function useXxxT() { return useOptionalT('ns') }`, which is how useNeighborT and usePplrT are written. Because qualify() prefixes a bare key and leaves an already-qualified one alone, one binding registers as BOTH shapes. MEASURED before and after with the same poison on disk: the old version PASSES it, this one fails it as 'neighbor.zzz_not_a_real_key (via nt())'; coverage went 683 -> 743 full-key and 49 -> 56 prefixed call sites, so 67 sites were being scored clean without being read. A self-test now asserts all three fixes separately and exits 3 if any goes blind, because this gate guards goal (d) and had already been trusted once while lying. Empty-namespace bare keys stay outside the model and are COUNTED rather than assumed away; measured at zero today, since all five such sites pass dotted keys.",
"check:i18n-never-translated": "a key PRESENT in all 11 catalogs but IDENTICAL in all of them is copied, not translated. check:i18n-parity asserts presence; presence and translatedness are different properties. Carries an allowlist for keys correctly identical everywhere (pure format strings, brand names, ISO designations) — each with a reason, so the list records decisions rather than silencing findings.",
"check:i18n-stopped-scope": "createAppI18n() must succeed while a STOPPED effect scope is current: the Driver's P0 (Plugin Studio gallery, every tab after the first threw 'SyntaxError: Unexpected error' from createI18n, because Vue 3.5 makes a new scope inactive under a stopped one). A bare createI18n() in the same scope must throw first, or the harness proves nothing. Also asserts a bundle's second provide('appI18n') raises no warning.",
"check:i18n-treeshake": "each built bundle carries every translation area its code asks for: every area-prefixed key literal in <bundle>.js belongs to an area the build included (the build narrows library/i18n's catalogue glob per bundle, internal/i18n-areas.mjs). Headless.",
@ -378,6 +382,7 @@
"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:package-json-unique-keys": "no object in package.json may carry the same key twice: JSON.parse keeps the last silently, so a duplicate kills the first entry while every gate that reads the PARSED object passes (a //scripts note sat twice after a merge). A small scanner tracks keys per object; its self-test runs every time.",
"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.",
@ -398,7 +403,9 @@
"check:religion-label-not-value": "the library's two RELIGION_FAMILIES sites (useLegendData, useSearch) show religionFamilyLabel() to the reader, while the value a filter or search matches on stays the canonical English family name: a translated label must never become a wire value.",
"check:scrim-viewport": "a full-screen SCRIM must cover the VIEWPORT, not just the content pane. A position:fixed;inset:0 scrim is PANE-relative wherever an ancestor sets contain/transform — VerticalTabsLayout sets contain:layout on .vtl-content — so a scrim dimmed 1024x793 @ (256,107) at a 1280x900 viewport and left the 256px rail CLICKABLE behind a modal. A source grep cannot see it: the declaration is correct, the ANCESTOR is the variable. Needs `bun run dev`; browser gate, not in build:unlocked.",
"check:scrim-viewport:self-test": "reverts each scrim to plain inset:0 and requires EVERY case to FAIL — a gate that cannot fail is worse than no gate, and this one measures a property that looks fine in a screenshot either way.",
"check:setup-order": "a line's POSITION in a setup block is not the MOMENT it runs, and two P0s from the Driver's console came from that gap. ARM A: `watch(..., { immediate: true })` runs during setup, so anything it reaches must be declared ABOVE it - research-map.vue threw `Cannot access '_lastFitKey' before initialization` and blanked the whole map, because the watcher called a HOISTED function 350 lines down whose own constants were still in their temporal dead zone. A hoisted function promises that its NAME resolves early, not that its BODY can run early, and that asymmetry is invisible from both ends. ARM B: a lifecycle registrar closes over Vue's current instance, which exists only while setup is on the stack, so `onMounted(async () => { await init(); onBeforeUnmount(clean) })` drops the registration silently - DashboardMapPanel.vue leaked a ResizeObserver on document.body plus a window listener, warning only, feature working, and research-map.vue had a second copy this gate found. The fix is never 'move the line': declare the handle at setup scope and tear it down in a hook registered before the first await. Its own false positives are the interesting part - it went 6 hits -> 1 over three rounds, and every one it dropped was prose or shadowing read as code: `onBeforeUnmount` inside a console.error STRING, `table` inside `new Error(`table not found (${r.status})`)` in four files, and a `const [pins, byCountry]` destructured INSIDE the function, shadowing a computed 220 lines away. So it blanks comments, string text and template text (keeping ${...}, which is code) and subtracts every name a span declares for itself. Self-tests on both shipped defects, both fixes, and all three decoys; exits 3 if any arm goes blind.",
"check:sfc-parse": "every .vue script block must parse as the BUILD parses it — catches the LIVE-CSS backtick that check:template-literal cannot see (it reads CSS comments only). 0 false positives on 83 files, where 3 lookahead heuristics scored 98 and 136.",
"check:shadow-css-parses": "the CSS we assemble as JS STRINGS must parse as CSS — brace balance and declarations stranded outside any rule. check:sfc-parse proves only that the JS parses, so it passed a stylesheet with 9 orphaned declarations and braces at 12 open / 21 close (REGLIB batch 1); nothing else in the tree validates CSS syntax. Reads every useShadowStyles() argument off the @babel/parser AST the build already uses, because three hand-rolled lexers each produced a confident FALSE RED: a /* CSS comment */ read as a declaration, an apostrophe in a .vue prose header opening a phantom string literal, and a regex literal containing a quote. 9 self-tests run inline on every invocation, including the exact batch-1 damage, so it cannot silently stop being able to fail.",
"check:shared-reached": "a shared library fix is only as deployed as the last rebuild of EVERY bundle that consumes it. Each bundle ships its own COMPILED copy, so a one-line fix in library/ is live in whichever bundles happened to be rebuilt after it and silently absent from the rest — and every other gate measures ONE leaf, which renders the old behaviour perfectly. Asserts a distinctive marker string per tracked change; consumers are DERIVED from imports, never hardcoded.",
"check:shot-distinctness": "two README slots must not show the SAME view. README shipped \"List — page 1\" and \"page 2\" as the same picture: the shot chased a Next control and a scroll that DO NOT EXIST, every attempt inside try/catch so the miss was silent, and the PNGs differed by 0.16% render noise so no checksum caught them. Keys on url+VIEWPORT — same url at a different vp (deploy2 mobile) is intended.",
"check:shot-fidelity": "warns when THIS machine cannot RENDER what the shots must SHOW. `bun run shots` printed 13x OK while every emoji rendered as a tofu box — a glyph fallback loads perfectly (0 console errors, 0 HTTP>=400), so the generator cannot see it. Reports by default; the `check:shot-fidelity:strict` script to block once an emoji font is installed.",
@ -422,6 +429,7 @@
"check:zone-census": "every zone a USER CAN SELECT must have a poster (asks the LEGEND, not clusters.json — two collapsed deaf lists are unreachable and not a gap). CONDITIONAL: SKIPs without out/posters/, which is gitignored — run `bun run posters` first. It cannot be fixtured like check:packets-all, because it asserts COVERAGE of real generated output and a fixture would only assert the fixture is complete.",
"deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST=<dir> overrides the destination",
"gallery": "writes public/__gallery/index.html — the ARCHIVED bundle-index gallery, from public/doxa-maps/manifest.json. It is NO LONGER the root page: as of 2026-09-22 `/` mounts <plugin-studio> in BOTH trees (the built root page is written by `bun run tree`), and the gallery is the opt-in index at /__gallery. Archived, not deleted — the dev server imports renderGallery()/readBuildConfig() from this module.",
"gen:iso3-to-alpha2": "derive library/data/iso3-to-alpha2.json from vendored CLDR (48.2.2, Unicode-3.0) joined against the 242 codes in library/data/countries.json, so Intl.DisplayNames can produce a LOCALIZED country name — it takes alpha-2 and our data speaks alpha-3. Offline, no dependency. GRADES ITS OWN OUTPUT and writes nothing on failure: 242 coverage, injectivity, and KOS -> XK as the one documented exception (Kosovo has no ISO assignment). Cross-checks the 176-country research spine map and REPORTS disagreements without rewriting it — that file drives fetches and is not this generator's to edit.",
"internet:adm1": "regenerate data/tables/internet-by-adm1.json (internet use by first-level area; India from NFHS-5 2019-21, CC BY 4.0) from data/internet/ through a strict name crosswalk to the shipped gb-IND-ADM1 boundaries. Fails on an unmatched name, never guesses. Generic rows (iso3, adm1, measure, sex, area, value, source, year) so other plugins and go-int can reuse it.",
"library-index": "regenerate documentation/reference-library-index.md — the DERIVED menu of the 84 reusable library pieces an author can import instead of rebuilding. Was UNREACHABLE, and the index did not exist at all: its header promised 'can't drift from the code' while nothing ever ran it.",
"lists": "regenerate data/people-groups/lists/<id>.json, the DOXA slugs in each people-group list of library/lists/lists.json, from the IMB CSV and the DOXA snapshot. Re-run after either changes.",

View file

@ -5,7 +5,6 @@
"dashboard-plus-grid/dashboard-plus-grid",
"research-map-plus-grid/research-map-plus-grid",
"regions-dashboard/regions-mfe",
"gospel-internet/board",
"my-upg-100-list/upg-100-mfe",
"doxa-simple-map/doxa-simple-map",
"cities-internet/connectivity-map",

View file

@ -230,9 +230,6 @@ const NAV_MODEL = [
// worth more than a complete feature nobody can open, and its absence was the one real gap
// between the Driver's seven and the six that shipped.
{ id: 'regions-dashboard', icon: 'map' },
// 4. Gospel x Internet — the Driver asked for it INSIDE the gallery as one of its dashboards
// (2026-09-27): gospel access by language x how much of each country is online.
{ id: 'gospel-internet', icon: 'chart' },
] },
// Prayer Mobilization — now TWO rows only: the onboarding ramp's steps 2 and 3. Big Picture (former
// step 1) moved to Dashboards above; Gospel Resources (former step 4) moved to Gospel Research below
@ -416,10 +413,6 @@ const TAB_DEFS = [
id: 'regions-dashboard', label: 'Regions', sub: 'regions and their countries', emoji: '🌐',
bundle: 'regions-dashboard', profile: 'regions-mfe'
},
{
id: 'gospel-internet', label: 'Gospel x Internet', sub: 'gospel access through the internet', emoji: '📶',
bundle: 'gospel-internet', profile: 'board'
},
{
id: 'upg-100', label: 'UPG-100 List', sub: 'my unreached list + posters', emoji: '📋',
bundle: 'my-upg-100-list', profile: 'upg-100-mfe'
@ -456,7 +449,7 @@ const HEAVY_ELEMENTS = new Set([
'my-upg-100-list-map', 'doxa-map', 'doxa-maps-spa',
// composites that NEST a map, so they own a context too (measured, see above)
'dashboard-plus-grid', 'research-dashboard-map', 'demo-map', 'all-people-groups',
'country-posters-map', 'key-cities-map', 'research-map-plus-grid', 'gospel-internet-board'
'country-posters-map', 'key-cities-map', 'research-map-plus-grid'
])
// SCROLL-STACK ELEMENTS — the rows this shell mounts WITHOUT the fixed-viewport inline style

View file

@ -24,7 +24,6 @@ export const STRINGS = {
'tab.cities-internet': 'Cities with Internet', 'sub.cities-internet': 'connectivity of key cities',
'tab.gospel-resources': 'Gospel Resources', 'sub.gospel-resources': 'resources by place',
'tab.regions-dashboard': 'Regions', 'sub.regions-dashboard': 'regions and their countries',
'tab.gospel-internet': "Gospel x Internet", 'sub.gospel-internet': "gospel access through the internet",
'tab.upg-100': 'UPG-100 List', 'sub.upg-100': 'my unreached list + posters',
navLabel: 'Research maps', meta: '{n} maps', loading: 'Loading {label}…', loadFailed: 'Could not load {label}.',
manifestHint: 'This tab is composed from manifest.json — build the tree, or serve the built tree, to load it.',
@ -45,7 +44,6 @@ export const STRINGS = {
'tab.cities-internet': 'Ciudades con Internet', 'sub.cities-internet': 'conectividad de ciudades clave',
'tab.gospel-resources': 'Recursos del Evangelio', 'sub.gospel-resources': 'recursos por lugar',
'tab.regions-dashboard': 'Regiones', 'sub.regions-dashboard': 'regiones y sus países',
'tab.gospel-internet': "Evangelio e Internet", 'sub.gospel-internet': "acceso al evangelio por internet",
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'mi lista de no alcanzados + carteles',
navLabel: 'Mapas de investigación', meta: '{n} mapas', loading: 'Cargando {label}…', loadFailed: 'No se pudo cargar {label}.',
manifestHint: 'Esta pestaña se compone a partir de manifest.json: compila el árbol o sirve el árbol compilado para cargarla.',
@ -66,7 +64,6 @@ export const STRINGS = {
'tab.cities-internet': 'Villes avec Internet', 'sub.cities-internet': 'connectivité des villes clés',
'tab.gospel-resources': "Ressources de l'Évangile", 'sub.gospel-resources': 'ressources par lieu',
'tab.regions-dashboard': 'Régions', 'sub.regions-dashboard': 'régions et leurs pays',
'tab.gospel-internet': "Évangile et Internet", 'sub.gospel-internet': "l’accès à l’Évangile par internet",
'tab.upg-100': 'Liste UPG-100', 'sub.upg-100': 'ma liste des non atteints + affiches',
navLabel: 'Cartes de recherche', meta: '{n} cartes', loading: 'Chargement de {label}…', loadFailed: 'Impossible de charger {label}.',
manifestHint: "Cet onglet est composé à partir de manifest.json : construisez l'arborescence ou servez l'arborescence construite pour le charger.",
@ -87,7 +84,6 @@ export const STRINGS = {
'tab.cities-internet': 'Städte mit Internet', 'sub.cities-internet': 'Konnektivität wichtiger Städte',
'tab.gospel-resources': 'Evangeliumsressourcen', 'sub.gospel-resources': 'Ressourcen nach Ort',
'tab.regions-dashboard': 'Regionen', 'sub.regions-dashboard': 'Regionen und ihre Länder',
'tab.gospel-internet': "Evangelium × Internet", 'sub.gospel-internet': "Zugang zum Evangelium über das Internet",
'tab.upg-100': 'UPG-100-Liste', 'sub.upg-100': 'meine Liste der Unerreichten + Poster',
navLabel: 'Forschungskarten', meta: '{n} Karten', loading: '{label} wird geladen…', loadFailed: '{label} konnte nicht geladen werden.',
manifestHint: 'Dieser Tab wird aus manifest.json zusammengesetzt – bauen Sie den Baum oder stellen Sie den gebauten Baum bereit, um ihn zu laden.',
@ -108,7 +104,6 @@ export const STRINGS = {
'tab.cities-internet': 'Cidades com Internet', 'sub.cities-internet': 'conectividade das principais cidades',
'tab.gospel-resources': 'Recursos do Evangelho', 'sub.gospel-resources': 'recursos por lugar',
'tab.regions-dashboard': 'Regiões', 'sub.regions-dashboard': 'regiões e seus países',
'tab.gospel-internet': "Evangelho e Internet", 'sub.gospel-internet': "acesso ao evangelho pela internet",
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'minha lista de não alcançados + cartazes',
navLabel: 'Mapas de pesquisa', meta: '{n} mapas', loading: 'Carregando {label}…', loadFailed: 'Não foi possível carregar {label}.',
manifestHint: 'Esta aba é composta a partir do manifest.json — compile a árvore ou sirva a árvore compilada para carregá-la.',
@ -131,7 +126,6 @@ export const STRINGS = {
'tab.cities-internet': 'Città con Internet', 'sub.cities-internet': 'connettività delle città chiave',
'tab.gospel-resources': 'Risorse del Vangelo', 'sub.gospel-resources': 'risorse per luogo',
'tab.regions-dashboard': 'Regioni', 'sub.regions-dashboard': 'regioni e i loro paesi',
'tab.gospel-internet': "Vangelo e Internet", 'sub.gospel-internet': "l’accesso al vangelo tramite internet",
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'la mia lista dei non raggiunti + poster',
navLabel: 'Mappe di ricerca', meta: '{n} mappe', loading: 'Caricamento di {label}…', loadFailed: 'Impossibile caricare {label}.',
manifestHint: "Questa scheda è composta da manifest.json: compila l'albero o pubblica l'albero compilato per caricarla.",
@ -152,7 +146,6 @@ export const STRINGS = {
'tab.cities-internet': 'Orașe cu internet', 'sub.cities-internet': 'conectivitatea orașelor-cheie',
'tab.gospel-resources': 'Resurse ale Evangheliei', 'sub.gospel-resources': 'resurse după loc',
'tab.regions-dashboard': 'Regiuni', 'sub.regions-dashboard': 'regiunile și țările lor',
'tab.gospel-internet': "Evanghelie și Internet", 'sub.gospel-internet': "accesul la Evanghelie prin internet",
'tab.upg-100': 'Lista UPG-100', 'sub.upg-100': 'lista mea de neatinși + afișe',
navLabel: 'Hărți de cercetare', meta: '{n} hărți', loading: 'Se încarcă {label}…', loadFailed: '{label} nu a putut fi încărcat.',
manifestHint: 'Această filă este compusă din manifest.json — construiți arborele sau serviți arborele construit pentru a o încărca.',
@ -173,7 +166,6 @@ export const STRINGS = {
'tab.cities-internet': 'Города с интернетом', 'sub.cities-internet': 'подключённость ключевых городов',
'tab.gospel-resources': 'Евангельские ресурсы', 'sub.gospel-resources': 'ресурсы по местам',
'tab.regions-dashboard': 'Регионы', 'sub.regions-dashboard': 'регионы и их страны',
'tab.gospel-internet': "Евангелие и интернет", 'sub.gospel-internet': "доступ к Евангелию через интернет",
'tab.upg-100': 'Список UPG-100', 'sub.upg-100': 'мой список недостигнутых + плакаты',
navLabel: 'Исследовательские карты', meta: 'Карт: {n}', loading: 'Загрузка: {label}…', loadFailed: 'Не удалось загрузить: {label}.',
manifestHint: 'Эта вкладка собирается из manifest.json — соберите дерево или разместите собранное дерево, чтобы загрузить её.',
@ -194,7 +186,6 @@ export const STRINGS = {
'tab.cities-internet': 'مدن متصلة بالإنترنت', 'sub.cities-internet': 'اتصال المدن الرئيسية',
'tab.gospel-resources': 'موارد الإنجيل', 'sub.gospel-resources': 'الموارد حسب المكان',
'tab.regions-dashboard': 'المناطق', 'sub.regions-dashboard': 'المناطق ودولها',
'tab.gospel-internet': "الإنجيل والإنترنت", 'sub.gospel-internet': "الوصول إلى الإنجيل عبر الإنترنت",
'tab.upg-100': 'قائمة UPG-100', 'sub.upg-100': 'قائمتي لغير المُبشَّرين + ملصقات',
navLabel: 'خرائط البحث', meta: 'عدد الخرائط: {n}', loading: 'جارٍ تحميل {label}…', loadFailed: 'تعذّر تحميل {label}.',
manifestHint: 'تُبنى علامة التبويب هذه من manifest.json — ابنِ الشجرة أو قدّم الشجرة المبنية لتحميلها.',
@ -215,7 +206,6 @@ export const STRINGS = {
'tab.cities-internet': 'इंटरनेट वाले शहर', 'sub.cities-internet': 'प्रमुख शहरों की कनेक्टिविटी',
'tab.gospel-resources': 'सुसमाचार संसाधन', 'sub.gospel-resources': 'स्थान के अनुसार संसाधन',
'tab.regions-dashboard': 'क्षेत्र', 'sub.regions-dashboard': 'क्षेत्र और उनके देश',
'tab.gospel-internet': "सुसमाचार और इंटरनेट", 'sub.gospel-internet': "इंटरनेट के माध्यम से सुसमाचार तक पहुँच",
'tab.upg-100': 'UPG-100 सूची', 'sub.upg-100': 'मेरी अप्राप्त सूची + पोस्टर',
navLabel: 'शोध मानचित्र', meta: '{n} मानचित्र', loading: '{label} लोड हो रहा है…', loadFailed: '{label} लोड नहीं हो सका।',
manifestHint: 'यह टैब manifest.json से बनता है — इसे लोड करने के लिए ट्री बनाएँ या बना हुआ ट्री सर्व करें।',
@ -236,7 +226,6 @@ export const STRINGS = {
'tab.cities-internet': '有互联网的城市', 'sub.cities-internet': '主要城市的网络连接',
'tab.gospel-resources': '福音资源', 'sub.gospel-resources': '按地点划分的资源',
'tab.regions-dashboard': '区域', 'sub.regions-dashboard': '区域及其国家',
'tab.gospel-internet': "福音与互联网", 'sub.gospel-internet': "通过互联网接触福音",
'tab.upg-100': 'UPG-100 名单', 'sub.upg-100': '我的未得之民名单 + 海报',
navLabel: '研究地图', meta: '{n} 张地图', loading: '正在加载{label}…', loadFailed: '无法加载{label}。',
manifestHint: '此标签页由 manifest.json 组成——请构建目录树或提供已构建的目录树以加载它。',

View file

@ -2012,6 +2012,22 @@ watch(() => mapStore.filters, () => {
// would mean guessing a mapping, and a wrong link is worse than an honest gap.
let applyingExternal = false
// ─── FIT THE MAP TO A SET OF PEOPLES: state, declared here on purpose ────────
// These belong with applyFitSlugs() further down, and that is where they used to be. They cannot
// live there: the watcher immediately below is `immediate: true`, so it runs DURING setup and
// calls applyFitSlugs(raw.fitSlugs) — while a `let`/`const` further down the file is still in its
// temporal dead zone. `function applyFitSlugs` hoists; its constants do not, and reading
// _lastFitKey threw `Cannot access '_lastFitKey' before initialization`, which killed the whole
// setup and blanked the map. Anything an immediate watcher can reach must be declared above it.
const FIT_PADDING_PX = 70
const FIT_MAX_ZOOM = 5.5 // several peoples: stop at regional framing, never street level
const SINGLE_ROW_ZOOM = 6 // one people group: its coordinate is a country-scale centroid
const DEGENERATE_SPAN_DEG = 0.01 // a cluster this tight is a point by another route
const WIDE_SPAN_DEG = 110 // past this the set wraps the globe; frame the world instead
const WORLD_ZOOM = 1.45
const FLY_DURATION_MS = 900
let _lastFitKey = ''
watch(() => profileConfig?.value?.externalFilters, (raw) => {
if (!raw || typeof raw !== 'object' || !mapStore) return
applyingExternal = true
@ -2419,15 +2435,9 @@ watch(() => uiStore.religionFilter, (key) => {
else applyDimFilter({ kind: null })
})
// ─── FIT THE MAP TO A SET OF PEOPLES (see the fitSlugs note in the externalFilters watcher) ───
const FIT_PADDING_PX = 70
const FIT_MAX_ZOOM = 5.5 // several peoples: stop at regional framing, never street level
const SINGLE_ROW_ZOOM = 6 // one people group: its coordinate is a country-scale centroid
const DEGENERATE_SPAN_DEG = 0.01 // a cluster this tight is a point by another route
const WIDE_SPAN_DEG = 110 // past this the set wraps the globe; frame the world instead
const WORLD_ZOOM = 1.45
const FLY_DURATION_MS = 900
let _lastFitKey = ''
// ─── FIT THE MAP TO A SET OF PEOPLES ─────────────────────────────────────────
// Its constants and the _lastFitKey guard are declared ABOVE the externalFilters watcher, not here
// beside applyFitSlugs() — see the note there.
function prefersReducedMotion() {
try {
@ -2620,6 +2630,11 @@ let _containerObserver = null
// the scrollport settles (a lazy-mounted host has no final height at any fixed timeout; see the
// comment on _syncScrollZoomToHost above). Torn down in onBeforeUnmount.
let _scrollZoomObserver = null
// Its window-'resize' twin, declared out here for the same reason the observer is: both are created
// after onMounted's `await initializeMap()`, which is past the point where a lifecycle hook can
// still attach. The observer was already torn down below; the listener was not, because its
// onBeforeUnmount sat beside the code that added it and was silently never registered.
let _resyncScrollZoom = null
function applyContainerScale(width) {
const el = rmRoot.value
if (!el) return
@ -2729,7 +2744,7 @@ onMounted(async () => {
})
// See _syncScrollZoomToHost above (BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID, mobile pair).
_syncScrollZoomToHost(map.value)
const _resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
_resyncScrollZoom = () => _syncScrollZoomToHost(map.value)
try {
_scrollZoomObserver = new ResizeObserver(_resyncScrollZoom)
const host = (map.value.getContainer && map.value.getContainer()) || null
@ -2739,7 +2754,6 @@ onMounted(async () => {
if (document.body) _scrollZoomObserver.observe(document.body)
} catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ }
window.addEventListener('resize', _resyncScrollZoom)
onBeforeUnmount(() => window.removeEventListener('resize', _resyncScrollZoom))
})
onBeforeUnmount(() => {
@ -2747,6 +2761,7 @@ onBeforeUnmount(() => {
window.removeEventListener('legend:highlight', onLegendHighlight)
if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null }
if (_scrollZoomObserver) { _scrollZoomObserver.disconnect(); _scrollZoomObserver = null }
if (_resyncScrollZoom) { window.removeEventListener('resize', _resyncScrollZoom); _resyncScrollZoom = null }
mapLayers.stopPrayerGlow()
clustering.cleanup?.()
selectedPin.cleanup?.()

View file

@ -47,6 +47,12 @@ import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colo
// component's shadow CSS so every var() below resolves inside the shadow root.
import { DXLIFE_TOKENS } from './dxlife-tokens.js'
import RegionCountryMap from './src/RegionCountryMap.vue'
// REGLIB batch 2: the stat card, the three goal cards and their bars are library components now.
// They carry their own shadow CSS (useShadowStyles) and the card surface they share, so the rules
// for .rd-card / .rd-stat* / .rd-goal* / .rd-big* / .rd-bar* / .rd-legend are no longer below.
import StatCard from '@map/components/regions/StatCard.vue'
import GoalCard from '@map/components/regions/GoalCard.vue'
import ProgressBar from '@map/components/charts/ProgressBar.vue'
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
import { summarizeRegions, emptyStats, toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
@ -255,12 +261,16 @@ const tiles = computed(() => {
const partialPrayerCount = computed(() => Math.max(0, (subject.value.stats.withPrayer || 0) - (subject.value.stats.withFullPrayer || 0)))
const noPrayerCount = computed(() => Math.max(0, (subject.value.stats.count || 0) - (subject.value.stats.withPrayer || 0)))
// RegionDetail.vue:28-30. Capped at 100 for the same reason theirs is: a bar wider than its
// track is a rendering bug, not a data story.
function percentOfSubject(count) {
const total = subject.value.stats.count || 0
return total ? Math.min(100, (count / total) * 100) : 0
}
// The three prayer-coverage bands as ONE list, feeding both the bar widths and the legend numbers.
// ProgressBar divides each `value` by its `max`, so a width and the figure printed beside it come
// from the same number and cannot drift apart -- before extraction they were computed separately.
// The colours are the very tokens the deleted .rd-seg--full/--partial/--none rules set, so every
// rendered colour is unchanged; only the mechanism moved from a class to a value.
const prayerSegments = computed(() => [
{ value: subject.value.stats.withFullPrayer || 0, color: 'var(--dx-accent)', label: t('100+ People Praying') },
{ value: partialPrayerCount.value, color: 'var(--dx-partial)', label: t('1+ People Praying') },
{ value: noPrayerCount.value, color: 'var(--dx-track)', label: t('No One Praying') },
])
// Their four headline stats, RegionDetail.vue:52-69: People Groups / Countries / Population /
// Unengaged. A COUNTRY subject has no countryCount, and their own country page does not show one
@ -280,8 +290,6 @@ const totalCountryCount = computed(() => regions.value.reduce((n, r) => n + (r.c
// Their three goal cards, RegionDetail.vue:88-141. The goal SENTENCES are theirs verbatim --
// they are the DOXA goals, not copy we get to reword.
const adoptedPct = computed(() => percentOfSubject(subject.value.stats.adopted || 0))
const engagedPct = computed(() => percentOfSubject(subject.value.stats.engaged || 0))
// Their per-country table, RegionDetail.vue:144-171 -- five columns, same order.
// A country subject keeps its REGION's rows with itself highlighted, for the reason the chart
@ -833,35 +841,17 @@ useShadowStyles(
'.rd-summary{ color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rd-prompt{ margin:0; color:var(--dx-muted); font-size:var(--font-size-sm); }' +
// ── .card: blocks/_cards.scss:9-21. radius --border-radius-xlg (35px);
// padding clamp(--spacing-lg, 6vw, --spacing-2xl) clamp(--spacing-lg, 6vw, --spacing-3xl).
'.rd-card{ position:relative; border-radius:var(--dx-radius);' +
' background:var(--dx-elevated); color:var(--dx-text);' +
' padding:clamp(var(--spacing-lg), 6cqw, var(--spacing-2xl)) clamp(var(--spacing-lg), 6cqw, var(--spacing-3xl)); }' +
// .card[padding-small] -> --spacing-lg flat
'.rd-card--small{ padding:var(--spacing-lg); }' +
// .card[data-variant="secondary"] -> surface-brand-dark on text-on-brand-light
'.rd-card--secondary{ background:var(--dx-dark); color:var(--dx-on-dark); }' +
// ── .card (blocks/_cards.scss:9-21) lives in library/composables/useCardSurface.js now: StatCard
// and GoalCard both need it, and both call useCardSurface(), which injects ONE copy per shadow
// root under a stable label. This element renders no .rd-card markup of its own any more, so it
// does not call it. If markup here ever needs the surface again, call useCardSurface() -- do not
// re-declare the rules, or there will be two copies to keep in step.
// ── .region-stats + .region-stat: RegionDetail.vue's scoped block ──
// ── .region-stats: RegionDetail.vue's scoped block. The GRID stays here and the CARD went to the
// library, deliberately: how many stats there are and how they wrap is this page's decision,
// while the look of one stat is the reusable part. See library/components/regions/StatCard.vue.
'.rd-stats{ display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));' +
' gap:var(--spacing-md); }' +
'.rd-stat{ text-align:center; }' +
// ★ THE data-len SHRINK IS GONE, AND ITS OWN COMMENT SHOWED WHY. Their .region-stat__value is
// font-family-heading + font-size-3xl + line-height:1 and nothing else -- no such attribute exists
// in their markup. Ours was a shrink-to-fit workaround whose stated premise was "our condensed
// fallback is wider" than Bebas. That is STALE: Bebas is vendored now (assets/fonts/bebas-neue,
// glyph oracle 279 = 279 SAME), so there is no wider fallback to compensate for.
// Its other claim -- "every value DX-life itself renders keeps their size" -- was FALSIFIED by
// measurement: rega found "43,045,275" (10 chars, so it hit the 'long' bucket) rendered at
// 38.15px against their 47.68px on the same-width card, while the other three stats matched
// exactly. A rule whose own invariant is violated in practice is not protecting anything.
// Deleted WITH its mechanism -- the binding and lenBucket() too, not just the rules.
'.rd-stat__value{ margin:0; font-size:var(--font-size-3xl); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; }' +
'.rd-stat__label{ margin:var(--spacing-2xs) 0 0; font-size:var(--font-size-sm); color:var(--dx-muted); }' +
'.rd-chart{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-xl)); }' +
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
@ -925,18 +915,9 @@ useShadowStyles(
// substituted face can render in their card. Pinning it there keeps THEIR LAYOUT (three
// cards across, heading on two lines) which is what the clone is actually for. The table
// heading keeps the full clamp -- it has 1200px and no such constraint.
// .stack--lg (var(--spacing-lg)) inside each card, centred; .region-goal pins to the bottom
// (margin-top:auto) so three cards of different heights still align their goal lines.
// ★ THEIR .stack IS margin-top, AND THAT IS LOAD-BEARING — not a stylistic detail.
// composition/_stack.scss: `.stack > * + * { margin-top: var(--stack-spacing) }`, with
// `.stack--lg` setting that to --spacing-lg. RegionDetail.vue's scoped
// `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child,
// so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE.
// This was ported as `gap`, which margin cannot override: gap AND the auto both applied,
// so every goal card carried ~23.5px of space their cards do not have. The DRIVER saw it
// ('spcing is nto ths ame', naming these three cards). Ported as their mechanism.
'.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' +
'.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' +
// The three goal cards are library/components/regions/GoalCard.vue now, and the .stack--lg /
// margin-top:auto layout that aligns their goal lines went with them. The .rd-h2 rule below is
// still here because this element keeps ONE heading of its own -- the breakdown table's title.
// h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl) = clamp(var(--font-size-2xl),_,var(--font-size-4xl))
'.rd-h2{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
@ -955,78 +936,19 @@ useShadowStyles(
'[lang="ru"] .rd-title, [lang="ru-RU"] .rd-title,' +
'[lang="ru"] .rd-h2, [lang="ru-RU"] .rd-h2{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .font-size-4xl .font-weight-medium / .font-size-lg
// Their .font-size-4xl (var(--font-size-4xl)) verified live. Same substitution consequence as the h2:
// in our wider stand-in face 'Adoption Progress' 21 / 2,107 breaks after the slash inside a
// 372px card, which reads as two numbers. Stepped to --font-size-3xl (the next rung down on
// THEIR scale) and pinned nowrap so a figure can never be split across lines. The stat-card
// values keep the full var(--font-size-3xl) -- those cards are wider and were measured to fit.
// ★ NO line-height HERE ANY MORE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
// .font-size-4xl utility sets ONLY a font-size — so their number inherits body's 1.5. Ours forced
// 1.1, which was compensating for the base line-height we never ported (now on :host in
// dxlife-tokens.js). rega measured the cost: 24px of card height, their number ~12px lower and its
// label ~24px lower. Inheriting is the faithful port.
// ★ NOW A CLAMP, LIKE THEIRS, AND IT RESOLVES THE NOWRAP WORRY RATHER THAN FIGHTING IT.
// utilities/_typography.scss gives .font-size-4xl
// font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
// and ours was a flat var(--font-size-4xl). Identical at 1440 and above (both sit on the ceiling),
// but below that ours never shrank -- MEASURED against their resolved values:
// 390px viewport theirs 38.15 ours 59.60 ours 21.46 too large
// 768px viewport theirs 46.08 ours 59.60 ours 13.52 too large
// 1024px and up theirs 59.60 ours 59.60 identical
// I had reported this as unchangeable because of the nowrap note above -- a figure must never
// split across lines in a 372px card. That was backwards: the clamp makes the figure SMALLER
// exactly where the wrap was observed. In a 372px container 6cqw is 22.3px, below the 2xl floor,
// so it resolves to 38.15px against the 59.60px that was breaking. The clamp helps the very
// problem the flat value was chosen for, and the nowrap stays as a belt.
// cqw not vw, consistent with every other clamp in this element (the container-vs-viewport
// substitution the Driver is still ruling on); at 768 that lands 42.4 against their 46.08, which
// is 3.68 off instead of 13.52.
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
' font-weight:500; }' +
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
'.rd-big-label{ margin:0; font-size:var(--font-size-lg); }' +
// .stack .stack--xs, inside the Prayer Coverage card.
'.rd-stack-xs{ display:flex; flex-direction:column; }' +
'.rd-stack-xs > * + *{ margin-top:10px; }' +
// .region-goal — margin-top:auto, spelled at higher specificity than the stack rule above
// so the override is unambiguous rather than depending on source order.
// ★ margin-top:auto, AND IT IS LOAD-BEARING. Theirs is RegionDetail.vue:24 `.region-goal {
// margin-top: auto }` inside `<div class="stack stack--lg | card | text-center">`, and
// composition/_stack.scss makes .stack a flex COLUMN with `> * + * { margin-top:
// var(--stack-spacing) }`. So the auto OVERRIDES that child margin and pushes the goal line to the
// BOTTOM of the card. Ours was margin:0, which left it directly under the text above it.
// The visible consequence, and why it matters for a clone: the three goal cards hold different
// amounts of copy, so theirs align their goal lines flush along the cards' bottom edge while ours
// floated at three different heights.
// FOUND BY READING THEIR MARKUP after rega's spacing probe measured this pair as "same" -- which
// it is, at 1440, because with no free space in the card the auto resolves to 0. An equal computed
// margin hid a real difference, so the probe was right and incomplete at the same time.
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
// .rd-big / .rd-big-label / .rd-goal and the .stack--xs that separates a bar from its legend are
// in library/components/regions/GoalCard.vue and library/components/charts/ProgressBar.vue now,
// together with the measurement history that fixed each value. The `strong` rule below STAYS:
// UupgsList renders a <strong> in this same shadow root and depends on it.
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the
// UA's bold. The 'Goal:' label in all three cards is a <strong>, so ours was rendering 700.
'.rd-root strong{ font-weight:500; }' +
'.rd-goal-card > .rd-goal{ margin-top:auto; }' +
// ── .progress-bar: blocks/_progress-bar.scss. The HEIGHT comes from padding-top, not height
// -- clamp(15px, 6%, 25px) at [data-size="md"] -- so the track scales with its own width.
'.rd-bar{ position:relative; overflow:hidden; width:100%; border-radius:100px;' +
' background:var(--dx-track); border:none; padding-top:clamp(15px, 6%, 25px); }' +
'.rd-bar__slider{ position:absolute; top:0; height:100%; width:0; border-radius:100px;' +
' background:var(--dx-accent); transition:width 1s ease-out; }' +
// .prayer-coverage-segments overrides that padding with a real height (their own scoped rule)
'.rd-bar--segments{ display:flex; height:clamp(15px, 6cqw, 25px); padding-top:0; }' +
'.rd-seg{ height:100%; transition:width 1s ease-out; }' +
'.rd-seg--full{ background:var(--dx-accent); }' +
'.rd-seg--partial{ background:var(--dx-partial); }' +
'.rd-seg--none{ background:var(--dx-track); }' +
'.rd-legend{ display:grid; justify-content:center; gap:3.5px; font-size:var(--font-size-sm);' +
' font-weight:600; text-align:center; }' +
'.rd-legend span{ display:inline-flex; align-items:center; justify-content:center; gap:4px; }' +
'.rd-key{ display:inline-block; flex:0 0 auto; width:7.5px; height:7.5px; border-radius:100px; }' +
'.rd-key--full{ background:var(--dx-accent); }' +
'.rd-key--partial{ background:var(--dx-partial); }' +
'.rd-key--none{ background:var(--dx-track); }' +
// ── .progress-bar (blocks/_progress-bar.scss) + their .prayer-coverage-segments override and
// legend are library/components/charts/ProgressBar.vue now. The three --full/--partial/--none
// colour rules are GONE rather than left behind: a segment's colour is a prop there, passed as
// the same var(--dx-accent) / var(--dx-partial) / var(--dx-track) tokens these rules set, so a
// shared bar needs no vocabulary about prayer. A rule no selector can reach is not a fallback.
// ── the breakdown table: RegionDetail.vue's scoped block. min-width 560px inside an
// overflow-x:auto wrapper is how they keep five columns readable on a phone instead of
@ -1152,10 +1074,7 @@ useShadowStyles(
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
<div v-if="subject.kind !== 'country'" class="rd-stats">
<div v-for="st in stats" :key="st.label" class="rd-card rd-card--small rd-stat">
<p class="rd-stat__value">{{ st.value }}</p>
<p class="rd-stat__label">{{ st.label }}</p>
</div>
<StatCard v-for="st in stats" :key="st.label" :value="st.value" :label="st.label" />
</div>
<!-- RegionDetail.vue:72-86 and CountryDetail.vue:47-61 — a .stack--sm of the note and the
@ -1209,46 +1128,42 @@ useShadowStyles(
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
<div v-if="subject.kind === 'region'" class="rd-switcher">
<div class="rd-card rd-card--secondary rd-goal-card">
<h2 class="rd-h2">{{ t('Prayer Coverage') }}</h2>
<div>
<p class="rd-big">{{ formatNumber(subject.stats.peopleCommitted) }}</p>
<p class="rd-big-label">{{ t('People committed to praying') }}</p>
</div>
<div class="rd-stack-xs">
<div class="rd-bar rd-bar--segments" :aria-label="t('Prayer coverage progress')">
<div class="rd-seg rd-seg--full" :style="{ width: percentOfSubject(subject.stats.withFullPrayer) + '%' }" />
<div class="rd-seg rd-seg--partial" :style="{ width: percentOfSubject(partialPrayerCount) + '%' }" />
<div class="rd-seg rd-seg--none" :style="{ width: percentOfSubject(noPrayerCount) + '%' }" />
</div>
<div class="rd-legend">
<span><i class="rd-key rd-key--full" />{{ t('100+ People Praying') }}: {{ formatNumber(subject.stats.withFullPrayer) }}</span>
<span><i class="rd-key rd-key--partial" />{{ t('1+ People Praying') }}: {{ formatNumber(partialPrayerCount) }}</span>
<span><i class="rd-key rd-key--none" />{{ t('No One Praying') }}: {{ formatNumber(noPrayerCount) }}</span>
</div>
</div>
<p class="rd-goal"><strong>{{ t('Goal') }}:</strong> {{ t('Mobilize 100+ people praying daily for each DOXA people group') }}</p>
</div>
<GoalCard
variant="secondary"
:title="t('Prayer Coverage')"
:value="formatNumber(subject.stats.peopleCommitted)"
:value-label="t('People committed to praying')"
:goal-label="t('Goal')"
:goal="t('Mobilize 100+ people praying daily for each DOXA people group')"
>
<ProgressBar
:max="subject.stats.count"
:segments="prayerSegments"
legend
:format="formatNumber"
:label="t('Prayer coverage progress')"
/>
</GoalCard>
<div class="rd-card rd-goal-card">
<h2 class="rd-h2">{{ t('Adoption Progress') }}</h2>
<div>
<p class="rd-big">{{ formatNumber(subject.stats.adopted) }} / {{ formatNumber(subject.stats.count) }}</p>
<p class="rd-big-label">{{ t('people groups adopted') }}</p>
</div>
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: adoptedPct + '%' }" /></div>
<p class="rd-goal"><strong>{{ t('Goal') }}:</strong> {{ t('Every unengaged people group adopted by a church committed to prayer, giving, and sending') }}</p>
</div>
<GoalCard
:title="t('Adoption Progress')"
:value="formatNumber(subject.stats.adopted) + ' / ' + formatNumber(subject.stats.count)"
:value-label="t('people groups adopted')"
:goal-label="t('Goal')"
:goal="t('Every unengaged people group adopted by a church committed to prayer, giving, and sending')"
>
<ProgressBar :value="subject.stats.adopted" :max="subject.stats.count" />
</GoalCard>
<div class="rd-card rd-goal-card">
<h2 class="rd-h2">{{ t('Engagement Progress') }}</h2>
<div>
<p class="rd-big">{{ formatNumber(subject.stats.engaged) }} / {{ formatNumber(subject.stats.count) }}</p>
<p class="rd-big-label">{{ t('people groups engaged') }}</p>
</div>
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: engagedPct + '%' }" /></div>
<p class="rd-goal"><strong>{{ t('Goal') }}:</strong> {{ t('No unengaged people groups by 2033') }}</p>
</div>
<GoalCard
:title="t('Engagement Progress')"
:value="formatNumber(subject.stats.engaged) + ' / ' + formatNumber(subject.stats.count)"
:value-label="t('people groups engaged')"
:goal-label="t('Goal')"
:goal="t('No unengaged people groups by 2033')"
>
<ProgressBar :value="subject.stats.engaged" :max="subject.stats.count" />
</GoalCard>
</div>
<!-- RegionDetail.vue:144-171 — the five-column breakdown. CountryDetail has no table. -->

View file

@ -20,7 +20,9 @@ let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
// area, library file, the plugin file that must consume it, the classes the library file now owns
// area, library file, the plugin file that must consume it, the classes the library file now owns.
// `css` names the file(s) allowed to carry those rules -- normally the component itself, but a rule set
// two components share (the card surface) legitimately lives in one module both of them call.
const EXTRACTED = [
{
lib: 'library/components/regions/RegionSectionCard.vue',
@ -28,13 +30,33 @@ const EXTRACTED = [
tag: 'RegionSectionCard',
owns: ['region-card', 'countries-grid', 'country-link'],
},
// ── REGLIB batch 2: the Driver's "banners/cards that tell you the prayer progress" ─────────────
{
lib: 'library/components/regions/StatCard.vue',
consumer: 'plugin-designs/regions-dashboard/region-dashboards.vue',
tag: 'StatCard',
owns: ['rd-stat'],
},
{
lib: 'library/components/regions/GoalCard.vue',
consumer: 'plugin-designs/regions-dashboard/region-dashboards.vue',
tag: 'GoalCard',
owns: ['rd-goal-card', 'rd-big', 'rd-goal'],
},
{
lib: 'library/components/charts/ProgressBar.vue',
consumer: 'plugin-designs/regions-dashboard/region-dashboards.vue',
tag: 'ProgressBar',
owns: ['rd-bar', 'rd-seg', 'rd-legend', 'rd-key'],
},
]
for (const e of EXTRACTED) {
const name = e.lib.split('/').pop()
const dir = e.lib.split('/').slice(-2, -1)[0]
ok(existsSync(resolve(ROOT, e.lib)), `${name} exists in library/`)
const plugin = read(e.consumer)
ok(plugin.includes(`@map/components/regions/${name}`), `the plugin IMPORTS it from @map/, not a local path`)
ok(plugin.includes(`@map/components/${dir}/${name}`), `${name}: the plugin IMPORTS it from @map/, not a local path`)
ok(new RegExp(`<${e.tag}[\\s>]`).test(plugin), `and actually RENDERS <${e.tag}>`)
// the half that makes it real: the plugin must no longer own what it delegated.
@ -52,6 +74,47 @@ for (const e of EXTRACTED) {
}
}
// ── the card surface: ONE rule set, two components, one injected copy ───────────────────────────
// StatCard and GoalCard both need .rd-card. A copy in each is the shape that drifts, so it lives in a
// composable they both call, dedup'd on a stable label. This asserts that arrangement rather than
// trusting it: the module carries the rules, both cards call it, and the plugin has neither.
{
const SURFACE = 'library/composables/useCardSurface.js'
ok(existsSync(resolve(ROOT, SURFACE)), 'useCardSurface.js exists')
const surface = read(SURFACE)
for (const cls of ['rd-card', 'rd-card--small', 'rd-card--secondary']) {
ok(new RegExp(`'\\.${cls}[{ ]`).test(surface), `the shared surface carries .${cls}`)
}
ok(/useShadowStyles\(CARD_SURFACE_CSS, 'dxlife-card-surface'\)/.test(surface),
'and injects it under a STABLE label, so N calling components still put exactly one <style> in the root')
for (const f of ['library/components/regions/StatCard.vue', 'library/components/regions/GoalCard.vue']) {
ok(/useCardSurface\(\)/.test(read(f)), `${f.split('/').pop()} calls useCardSurface()`)
}
const dash = read('plugin-designs/regions-dashboard/region-dashboards.vue')
ok(!/'\.rd-card[{ -]/.test(dash), 'the plugin ships NO .rd-card CSS of its own any more')
ok(!/class="[^"]*\brd-card\b/.test(dash.slice(dash.indexOf('<template>'))),
'and renders no .rd-card markup of its own -- all four cards are library components')
}
// ── the ONE substitution in the port, asserted rather than described ────────────────────────────
// A segment's colour moved from three prayer-specific classes (.rd-seg--full/--partial/--none) to a
// prop. That is only faithful if the VALUES are unchanged, so: the classes are gone tree-wide, and the
// consumer passes the very tokens they used to set.
{
const bar = read('library/components/charts/ProgressBar.vue')
const dash = read('plugin-designs/regions-dashboard/region-dashboards.vue')
for (const dead of ['rd-seg--full', 'rd-seg--partial', 'rd-seg--none', 'rd-key--full', 'rd-key--partial', 'rd-key--none']) {
ok(!new RegExp(`'\\.${dead}[{ ]`).test(bar) && !new RegExp(`'\\.${dead}[{ ]`).test(dash),
`.${dead} is DELETED, not left behind unreachable`)
}
for (const token of ['var(--dx-accent)', 'var(--dx-partial)', 'var(--dx-track)']) {
ok(dash.includes(`color: '${token}'`), `the consumer still passes ${token} -- the colour is unchanged, only its mechanism moved`)
}
ok(/segments: \{ type: Array/.test(bar), 'ProgressBar takes segments as a prop, so it needs no vocabulary about prayer')
ok(/prefers-reduced-motion/.test(bar),
'and carries its own reduced-motion guard -- the plugin\'s reset is scoped to .rd-root and would not follow it to another consumer')
}
// NEG CONTROLS: each assertion must be able to fail.
ok(/class="[^"]*\brl-root\b/.test(read('plugin-designs/regions-dashboard/regions-list.vue')),
'NEG CONTROL: the plugin still uses its OWN classes (.rl-root), so the "no longer uses" checks above are not vacuously true')

View file

@ -52,7 +52,7 @@ import { parse } from '@vue/compiler-sfc'
import { TREE_NAME } from '../../../internal/_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const CHIPS = join(ROOT, 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue')
const CHIPS = join(ROOT, 'library/components/dashboards/DashboardFilterChips.vue') // moved by kit landing 4c-2
const SHELL = join(ROOT, 'plugin-designs/research-dashboard/research-dashboard.vue')
// ★ THE CHIPS THE USER ACTUALLY TOUCHES ARE NOT IN DashboardFilterChips.vue.
// That component is exported from the kit's index.js and mounted by NOBODY — DashboardTopBar's

View file

@ -143,6 +143,157 @@ if (!fails.some((f) => f.startsWith('1'))) notes.push(`insets: ${SURFACES.length
}
}
// ── 3. THE INVERTED DEPENDENCY, AND THE NO-LEGEND FALLBACK (kit landing 4b) ───────────────────
// The rail and the chips used to IMPORT useTreeLegendColors / useTreeLegendLabels — the DOXA
// palettes and the People code tables, which are research-specific and stay in plugin-designs.
// That import is what stopped the rail from being able to move into the kit at all: a library
// component importing a plugin composable is what check:no-cross-plugin-imports refuses. The shell
// now supplies both as dashboardConfig.legend, so the panels declare what they need and the research
// plugin keeps owning its colours.
//
// ★ WHAT THIS ARM DOES AND DOES NOT DO, stated because the difference matters. It drives the
// FALLBACK EXPRESSIONS lifted out of the SFC — so "no legend yields a neutral swatch and a raw
// value" is measured, not assumed — and it asserts the imports are gone. It does NOT mount the
// component: mounting an SFC here needs the Vite/no-op-renderer harness check:composites-mount
// carries, and a no-op renderer cannot show a colour anyway, so a mount would prove only "it did
// not throw" at the cost of a dev server in a gate that otherwise runs in milliseconds. The
// rendered result is rega's, on the NEEDS-EYES row for this landing.
{
const RAIL = 'library/components/dashboards/DashboardFacetRail.vue' // moved by kit landing 4c-3
const CHIP = 'library/components/dashboards/DashboardFilterChips.vue' // moved by kit landing 4c-2
for (const [rel, what] of [[RAIL, 'the facet rail'], [CHIP, 'the chip bar']]) {
const src = read(rel)
if (!src) { fails.push(`3 ${rel} is missing.`); continue }
const code = src.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
if (/from '\.\.\/\.\.\/composables\/useTreeLegend(Colors|Labels)\.js'/.test(code)) {
fails.push(`3 ${what} still imports a useTreeLegend* composable. Those are the research palettes; a kit component importing them is the library-to-plugin dependency that stops this file from moving at all.`)
}
if (!/legend/.test(code)) fails.push(`3 ${what} never reads cfg.legend, so the shell has nothing to hand it.`)
}
// Drive the rail's two fallbacks with NO legend at all.
const railSrc = read(RAIL) || ''
const neutral = /const KIT_NEUTRAL_SWATCH = '(#[0-9a-fA-F]{3,8})'/.exec(railSrc)
const cFor = /const colorFor = (\(\.\.\.a\)[^\n]*)/.exec(railSrc)
const lFor = /const legendLabelFor = (\([^\n]*)/.exec(railSrc)
if (!neutral || !cFor || !lFor) {
fails.push('3 could not lift the rail\'s no-legend fallbacks (KIT_NEUTRAL_SWATCH, colorFor, legendLabelFor) — a consumer that supplies no legend would get whatever this gate can no longer see.')
} else {
const legend = { value: null }
const KIT_NEUTRAL_SWATCH = neutral[1]
const colorFor = new Function('legend', 'KIT_NEUTRAL_SWATCH', `return ${cFor[1]}`)(legend, KIT_NEUTRAL_SWATCH)
const legendLabelFor = new Function('legend', `return ${lFor[1]}`)(legend)
if (colorFor('religion', 'religion', 'M', '') !== KIT_NEUTRAL_SWATCH) {
fails.push('3 with no legend the rail does not fall back to its neutral swatch — an unconfigured dashboard should look PLAIN, not coloured by accident.')
}
if (legendLabelFor('affinity_bloc', 'A012') !== 'A012') {
fails.push('3 with no legend the rail does not pass the raw value through as its label — a consumer with no code tables must see the code, not blank.')
}
// And the research palette's own NEUTRAL must NOT be imported to get that value.
// ★ COMMENT-STRIPPED, and this is the FIFTH time I have written this hole. The comment beside
// KIT_NEUTRAL_SWATCH explains the decision by NAMING the research NEUTRAL, so testing the raw
// source reported a coupling that does not exist — my own documentation failing my own check.
// I had already fixed this shape four times in other gates and still wrote the raw-source
// version here, which says the fix belongs in a habit rather than in each gate: every text
// search over source strips comments first, without thinking about it.
const railCode = railSrc.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
if (/NEUTRAL/.test(railCode.replace(/KIT_NEUTRAL_SWATCH/g, ''))) {
fails.push('3 the rail references the research palette\'s NEUTRAL. Coupling the kit\'s neutral to the research one re-creates the dependency this landing removes; they happen to match today and must stay independent.')
}
if (!fails.some((f) => f.startsWith('3'))) notes.push(`no-legend fallback: neutral ${KIT_NEUTRAL_SWATCH} and raw values, with the research imports gone`)
}
}
// ── 4. THE ROLLUP BUCKET: SENTINEL KEY, TRANSLATED LABEL (kit landing 4c-1) ───────────────────
// aggregate()'s topN tail becomes `{ key: 'Other', other: true }`, and that English word was
// rendered directly in both rails, in every locale. check:i18n-keys-exist could not see it because
// there was no T() call to inspect — the string was never a lookup, it was a KEY being used as a
// LABEL. Found while assessing 4c, and fixed before the module moved, because unfixed it would have
// been exported to every future dashboard the moment this became kit code.
// Both halves are asserted, and they pull in opposite directions: the KEY must stay English (the
// rail tests it to make the row inert, and the charts rail refuses to filter on it — a localized key
// would make the bucket clickable in Arabic on a value no row carries), while the LABEL must not be.
{
const AGG = 'library/composables/useFacetAggregation.js'
const src = read(AGG)
if (!src) notes.push(`${AGG}: not present yet (kit landing 4c-1 not applied) — skipped`)
else {
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
const k = /export const OTHER_KEY = '([^']*)'/.exec(code)
if (!k) fails.push(`4 ${AGG}: no OTHER_KEY export — the sentinel and its label have no single owner, which is how they drift.`)
else if (k[1] !== 'Other') fails.push(`4 OTHER_KEY is ${JSON.stringify(k[1])}. It is a SENTINEL: the rail tests it to make the bucket inert and the charts rail refuses to filter on it, so changing it silently makes the bucket clickable on a value no row carries.`)
if (!/export const otherLabel = \(\) =>[\s\S]{0,120}tStatic\('dashboard\.other'\)/.test(code)) {
fails.push(`4 ${AGG}: otherLabel() does not read dashboard.other, so the bucket renders English in every locale.`)
}
// the catalogue has to hold it, in every locale
const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
const missing = LOCALES.filter((l) => { const j = read(`library/i18n/locales/${l}/dashboard.json`); return !j || !JSON.parse(j).other })
if (missing.length) fails.push(`4 dashboard.other is missing in ${missing.length} locale(s): ${missing.join(', ')}`)
// and the render sites must project rather than print the key
const rail = read('library/components/dashboards/DashboardFacetRail.vue') || '' // moved by kit landing 4c-3
if (!/label: d\.other \? otherLabel\(\) : d\.key/.test(rail)) {
fails.push('4 the facet rail does not project the bucket label — it renders the sentinel, so every locale reads "Other".')
}
if (!/facet, value: d\.key/.test(rail)) {
fails.push('4 the facet rail no longer carries d.key as the row VALUE. The value is what inertness is tested against; projecting it would make the bucket filterable.')
}
if (!fails.some((f) => f.startsWith('4'))) notes.push(`rollup bucket: key stays ${JSON.stringify(k[1])}, label reads dashboard.other in all ${LOCALES.length} locales`)
}
}
// ── 5. THE MOVED CHIP BAR: THE FILE IS ACTUALLY GONE, AND ITS DEFAULT RESOLVER IS DRIVEN ──────
// Kit landing 4c-2 moves DashboardFilterChips.vue into library/components/dashboards/. Two things
// can go wrong in a "move" and only one of them is visible by reading the new file:
// (a) the old copy survives, so the tree holds two chip bars that drift apart — the failure this
// repo has already paid for with useNeighborT/usePplrT at Jaccard 0.82;
// (b) the kit's UNCONFIGURED behaviour is wrong, which nobody notices because every dashboard in
// this tree supplies a resolver. go-int is the consumer that will not, at first.
// So the arm asserts the move is complete and then LIFTS the default out of the source and RUNS it,
// the same way arm 3 drives the rail's fallbacks. Not a mount: a no-op renderer cannot show a chip,
// and the rendered result is rega's.
{
const NEW = 'library/components/dashboards/DashboardFilterChips.vue'
const OLD = 'plugin-designs/research-dashboard/src/components/dashboards/DashboardFilterChips.vue'
const BARREL = 'plugin-designs/research-dashboard/src/components/dashboards/index.js'
const src = read(NEW)
if (!src) {
fails.push(`5 ${NEW} is missing — the kit component the barrel now points at does not exist.`)
} else {
if (read(OLD)) fails.push(`5 ${OLD} still exists. A move that leaves the original behind is a COPY, and two chip bars over one filterSet drift apart the moment one is edited.`)
const barrel = read(BARREL) || ''
if (!/DashboardFilterChips.*@map\/components\/dashboards/.test(barrel)) {
fails.push(`5 ${BARREL} does not re-export DashboardFilterChips from the library — an importer of the barrel would resolve the path that just moved.`)
}
// Comments first: this file's own notes name cfg.legend and useTreeLegendLabels.
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
if (/from '\.\.\//.test(code) || /useTreeLegend/.test(code)) {
fails.push('5 the kit chip bar imports something relative or research-specific. A library component reaching back into plugin-designs is what check:no-cross-plugin-imports refuses, and it is the one thing 4b removed to make this move possible.')
}
for (const dead of ['dataStore', 'searchSourceId']) {
if (new RegExp(`inject\\(\\s*'${dead}'`).test(code)) {
fails.push(`5 the kit chip bar still injects '${dead}', which existed only to build useTreeLegendLabels. An unused inject is a claim about the host: a new dashboard would provide it before finding out it is never read.`)
}
}
const lf = /const labelFor = (\([^\n]*)/.exec(src)
if (!lf) {
fails.push('5 could not lift `const labelFor` out of the kit chip bar, so its unconfigured behaviour is whatever this gate can no longer see. Keep it one expression.')
} else {
const make = (cfg) => new Function('cfg', `return ${lf[1]}`)(cfg)
const bare = make({})
if (bare('affinity_bloc', 'A012') !== 'A012') {
fails.push('5 with NO resolver the kit chip bar does not pass a scalar value through — a dashboard that has not wired its code tables must see the code, not blank.')
}
if (bare('religion', ['Islam', 'Hinduism']) !== 'Islam, Hinduism') {
fails.push(`5 with NO resolver a MULTI-VALUE filter (spec D1) renders as ${JSON.stringify(String(bare('religion', ['Islam', 'Hinduism'])))} instead of a joined list. Configured and unconfigured must differ in whether CODES RESOLVE, never in punctuation — a comma-jammed "A012,A013" reads as a rendering bug rather than as missing configuration.`)
}
const wired = make({ legend: { labelFor: () => 'SUPPLIED' } })
if (wired('affinity_bloc', 'A012') !== 'SUPPLIED') {
fails.push('5 a supplied cfg.legend.labelFor does not win over the default, so a dashboard that configures its labels would still show raw codes.')
}
if (!fails.some((f) => f.startsWith('5'))) notes.push('kit chip bar: moved with no copy left behind, no dead injects, and its no-resolver default passes scalars through and joins lists')
}
}
}
for (const n of notes) console.log(' · ' + n)
if (fails.length) {
console.error(`\n${TAG}: FAIL`)

View file

@ -10,7 +10,7 @@ import { tStatic } from '@map/i18n/index.js'
*/
import { inject, computed, ref } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { aggregate, buildDonut, buildBars, lbl, buildCountryRegionColors } from '../../composables/useDashboardAggregations.js'
import { aggregate, buildDonut, buildBars, lbl, buildCountryRegionColors, OTHER_KEY, otherLabel } from '../../composables/useDashboardAggregations.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '../../composables/useDashboardShell.js'
/**
@ -95,7 +95,13 @@ const isActive = (facet, key) => bus.isFilterActive(facet, key)
// 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)
// Two projections, one function, and both follow the same rule: the key crosses, the label is read.
// OTHER_KEY is the rollup sentinel aggregate() produces; it must stay English on the wire (this
// component refuses to filter on it by testing `key === 'Other'`) and be localized for a reader.
const legendLabel = (facet, key) => {
if (key === OTHER_KEY) return otherLabel()
return 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
@ -339,7 +345,7 @@ useShadowStyles(`
<svg class="rd-donut" :class="{ 'has-sel': hasSelection(chart.facet) }" viewBox="0 0 132 132" role="img" :aria-label="chart.title + ' donut'">
<path v-for="s in chart.geometry" :key="s.key" :d="s.path" :fill="s.color" class="rd-slice"
:class="{ on: isActive(chart.facet, s.key) }" :data-facet="chart.facet" :data-key="s.key">
<title>{{ s.key }}: {{ s.value }} ({{ s.pct }}%)</title>
<title>{{ legendLabel(chart.facet, s.key) }}: {{ s.value }} ({{ s.pct }}%)</title>
</path>
</svg>
<div class="rd-legend">

View file

@ -177,6 +177,13 @@ let added = false
// container calls map.resize() on ANY container-size change, so the canvas always fills its cell.
// General-correct (not table-specific): also covers window/rail/facet reflow. rAF-coalesced.
let containerRO = null
// The scroll-zoom resync observer and its window listener. Both are CREATED inside onMounted,
// which is async — so they are declared out here and torn down by the one top-level
// onBeforeUnmount below. A lifecycle hook registered after that callback's first `await` never
// attaches to this instance (Vue has no active instance by then), so the teardown cannot live
// next to the code that creates these; it has to live in a hook registered during setup.
let scrollRO = null
let scrollResync = null
let offToggleTable = null
let offLayoutResizing = null
let _resizeRaf = 0
@ -1219,21 +1226,16 @@ onMounted(async () => {
// observer may again be useful if a future host reintroduces the unbounded-map-row precondition)
// rather than torn out, but do not read `resync` as gating anything today.
syncScrollZoomToHost(m)
const resync = () => syncScrollZoomToHost(m)
let ro = null
scrollResync = () => syncScrollZoomToHost(m)
try {
ro = new ResizeObserver(resync)
scrollRO = new ResizeObserver(scrollResync)
const host = (m.getContainer && m.getContainer()) || null
if (host) ro.observe(host)
if (host) scrollRO.observe(host)
const port = host ? findScrollportAncestor(host) : null
if (port) ro.observe(port)
if (document.body) ro.observe(document.body)
if (port) scrollRO.observe(port)
if (document.body) scrollRO.observe(document.body)
} catch (_) { /* no ResizeObserver: the initial call above still answers for a static page */ }
window.addEventListener('resize', resync)
onBeforeUnmount(() => {
try { ro && ro.disconnect() } catch (_) { /* already torn down */ }
window.removeEventListener('resize', resync)
})
window.addEventListener('resize', scrollResync)
// Keep the zoom buttons' enabled state in step with the live camera. 'zoom' fires only during
// an actual zoom; 'zoomend' catches the settle. Both just set two booleans (no camera writes).
m.on('zoom', syncZoomLimits)
@ -1277,6 +1279,8 @@ onBeforeUnmount(() => {
try { offToggleTable && offToggleTable() } catch (_) {} offToggleTable = null
try { offLayoutResizing && offLayoutResizing() } catch (_) {} offLayoutResizing = null
if (containerRO) { try { containerRO.disconnect() } catch (_) {} containerRO = null }
if (scrollRO) { try { scrollRO.disconnect() } catch (_) {} scrollRO = null }
if (scrollResync) { try { window.removeEventListener('resize', scrollResync) } catch (_) {} scrollResync = null }
if (attribRO) { try { attribRO.disconnect() } catch (_) {} attribRO = null } attribEl = null
if (_resizeRaf) { cancelAnimationFrame(_resizeRaf); _resizeRaf = 0 }
// Detach the zoom-limit listeners before destroy() so they can never run on a dead map.

View file

@ -179,7 +179,13 @@ const { labelFor } = useTreeLegendLabels(cfg, inject('dataStore', null), inject(
const activeChips = computed(() => {
const out = Object.keys(filterSet).map((k) => {
const v = filterSet[k]
return { name: k, label: `${FACETS.value[k]?.label || k}: ${k === PRAYER_KEY ? v : labelFor(k, v)}` }
// An ARRAY is a multi-value filter (D1). labelFor() joins one for display at its single owner,
// but the prayer arm skips labelFor by the note above, so it has to do its own join — otherwise
// a two-value pick renders as "A,B" with no spacing. LATENT, not live: nothing in this plugin
// calls toggleFilterValue, so no caller produces a prayer array today. Closed here because the
// next caller that does would not think to look in a chip label for the reason.
const shown = k === PRAYER_KEY ? (Array.isArray(v) ? v.join(', ') : v) : labelFor(k, v)
return { name: k, label: `${FACETS.value[k]?.label || k}: ${shown}` }
})
// ★ THE TYPED SEARCH IS A FILTER AND MUST LOOK LIKE ONE (Driver 2026-09-23: "I searched Bhutan
// and it did not switch the toggle to show the country was selected"). MEASURED: typing narrows
@ -192,7 +198,14 @@ const activeChips = computed(() => {
// (First attempt went into DashboardFilterChips.vue -- a component the topbar dedup pass had
// already unmounted; THIS band is the documented single source of truth for chips.)
const term = (bus?.searchTerm?.value || '').trim()
if (term) out.push({ name: '__search__', label: `Search: ${term}` })
// ★ THE SEARCH CHIP WAS THE ONE CHIP STILL IN ENGLISH, and the string it needed already existed.
// dashboard.search_live is in all eleven locales, written for exactly this line — and it has to
// be, because the punctuation is not assemblable here: fr is "Recherche : {term}" with a narrow
// space before the colon and zh is "搜索:{term}" with a full-width one. Composing 'Search' + ':'
// in code would get both wrong even with a translated word. It was simply never wired, so ten
// locales rendered an English label beside localized facet chips. tStatic takes no params, so
// {term} is filled here rather than by the catalogue.
if (term) out.push({ name: '__search__', label: (tStatic('dashboard.search_live') || 'Search: {term}').replace('{term}', term) })
return out
})

View file

@ -13,11 +13,18 @@
* useGeocoderSearch composable via a busAdapter sink (kind-switch stays in the composable).
*/
export { default as DashboardTopBar } from './DashboardTopBar.vue'
export { default as DashboardFacetRail } from './DashboardFacetRail.vue'
// MOVED to library/components/dashboards/ (kit landing 4c-3), re-exported for one cycle like the
// chip bar before it, so research-dashboard.vue's import list does not change in the landing that
// moves the file.
export { default as DashboardFacetRail } from '@map/components/dashboards/DashboardFacetRail.vue'
export { default as DashboardDataTable } from './DashboardDataTable.vue'
export { default as DashboardChartsRail } from './DashboardChartsRail.vue'
export { default as DashboardDetailDrawer } from './DashboardDetailDrawer.vue'
export { default as DashboardFilterChips } from './DashboardFilterChips.vue'
// MOVED to library/components/dashboards/ (kit landing 4c-2) and re-exported here for one cycle,
// so no consumer's import line has to change in the same landing that moves the file. Anything
// importing DashboardFilterChips from this barrel keeps working; the re-export goes when the rail
// follows it in 4c-3 and this barrel becomes the library's own.
export { default as DashboardFilterChips } from '@map/components/dashboards/DashboardFilterChips.vue'
export { default as DashboardBreadcrumb } from './DashboardBreadcrumb.vue'
export { default as DashboardMapPanel } from './DashboardMapPanel.vue'
export { default as DashboardSearchHeader } from './DashboardSearchHeader.vue'

View file

@ -49,15 +49,14 @@ import { PALETTE as RELIGION_PALETTE, RELIGION_FAMILIES, getReligionFamily } fro
* than re-implemented, so chart and pin can never drift.
*/
/**
* A deliberately NEUTRAL tone for a value this facet has no palette for — matching the tree
* legend's NEUTRAL exactly. An uncoloured slice must read as "no colour strategy for this key",
* never as a confident member of some other family. Critically it is STABLE: the same key gets
* the same neutral every time, so nothing changes colour when a filter reorders the list.
*/
export const NEUTRAL = '#c4c8c0'
// ★ THE GENERIC HALF MOVED OUT (kit landing 4c-1) AND IS RE-EXPORTED HERE FOR ONE CYCLE.
// lbl() and aggregate() are pure counting and now live in @map/composables/useFacetAggregation.js.
// Everything still in this file — colorFor and its hardcoded facet branches, the religion palettes,
// the donut and bar builders — is research-specific and stays. DashboardChartsRail imports those and
// is untouched by this commit.
export { lbl, aggregate, OTHER_KEY, otherLabel } from '@map/composables/useFacetAggregation.js'
import { lbl, aggregate } from '@map/composables/useFacetAggregation.js'
export const lbl = (v) => (v === null || v === undefined ? '' : (typeof v === 'object' ? (v.label ?? v.value ?? '') : v))
// Place: the facet emits the localized label but the authoritative palette is slug-keyed
// (doxa-region.js documents that the label is localized per `lang` and the slug is stable).
@ -134,19 +133,6 @@ function religionLetterFromLabel(key, families) {
return 'U'
}
/** Count rows by a key accessor; collapse the long tail into 'Other'. */
export function aggregate(rows, keyFn, topN) {
const m = new Map()
for (const r of rows) { const k = keyFn(r); if (k === null || k === undefined || k === '') continue; m.set(k, (m.get(k) || 0) + 1) }
let arr = [...m.entries()].map(([key, value]) => ({ key, value })).sort((a, b) => b.value - a.value)
if (topN && arr.length > topN) {
const top = arr.slice(0, topN)
const other = arr.slice(topN).reduce((s, x) => s + x.value, 0)
if (other > 0) top.push({ key: 'Other', value: other, other: true })
arr = top
}
return arr
}
/**
* COUNTRY -> REGION HUE. The Driver's ask: "countries should match the colour of their regions."

View file

@ -23,12 +23,13 @@
* responsive switch, collapsible table dock, segmented mobile view, and the location.hash sync.
* Only the SOURCE of the per-dashboard constants changed: from hard-coded → config.
*/
import { exactMatches, fuzzyMatches, resolveFuzzyValues } from '@map/components/search/fuzzy.js'
import { provide, inject, ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { createSelectionBus } from '@map/composables/useSelectionBus.js'
import { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
import { useDashboardData } from './useDashboardData.js'
import { useCsvExport } from './useCsvExport.js'
import { useCsvExport } from '@map/composables/useCsvExport.js'
import { useTreeLegendLabels } from './useTreeLegendLabels.js'
import { useTreeLegendColors } from './useTreeLegendColors.js'
import { DataSourceManager } from '@map/api/DataSourceManager.js'
import sourcesConfig from '@map/api/sources.json'
import { tStatic } from '@map/i18n/index.js'
@ -40,12 +41,80 @@ const mapValues = (obj, fn) =>
// number formatter (same as the old inline `fmt`); exported for the profile's KPI strip.
export const fmt = (n) => (n === null || n === undefined ? '—' : Number(n).toLocaleString('en-US'))
// ★ THE SEARCH MOVED OUT (kit landing 4a) AND IS RE-EXPORTED FROM HERE FOR ONE CYCLE.
// Eight files import these three names from this module; re-exporting means the move and its
// consumers do not have to land together, exactly as the composable shims did in landings 1-3.
// Unlike those, this one is a barrel line rather than a file, so there is nothing to delete later —
// only a line to remove once the eight importers point at the library directly.
export { buildSearchAccessors, makeSearchMatcher, filterRowsBySearch } from '@map/composables/useDashboardSearch.js'
/**
* Build the free-text search accessors for a dashboard config (header-1 search bar).
* config.searchKeys (facet keys) → those facet accessors; else the string columns' accessors.
* ONE declaration reused by the shell (KPIs) AND every panel (table/map/charts/facets) so the
* searchable text can never drift from what the panels show, and the substring match is not
* copy-pasted into each panel (kills the very duplication DOXA-LIB-2 removed).
*/
export function buildSearchAccessors(config) {
const FACET_ACC = mapValues(config.facets, (f) => f.get)
const COL_ACC = Object.fromEntries((config.columns || []).map((c) => [c.key, c.get]))
const keys = Array.isArray(config.searchKeys) ? config.searchKeys : null
if (keys) return keys.map((k) => FACET_ACC[k] || COL_ACC[k]).filter((fn) => typeof fn === 'function')
return (config.columns || []).filter((c) => c.type !== 'num' && typeof c.get === 'function').map((c) => c.get)
}
/**
* Reusable predicate: does `row` match the bus's current free-text search term?
* Returns true when there is no term (identical to pre-search behaviour). `accessors` is the
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/
export function makeSearchMatcher(bus, accessors) {
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
// It now delegates to the shared fuzzyMatches() in @map fuzzy.js -- the same matcher the
// grid's Fuse uses -- with the same DX-life options, so every search surface agrees.
// Row-level predicates for the TIERED contract (see fuzzy.js exactMatches): the caller
// (filterRowsBySearch below) runs the exact tier over the whole row set first and falls back
// to the fuzzy tier only when exact yields nothing, so an exact term agrees with the facet.
const test = (row, pred) => {
const needle = (bus?.searchTerm?.value || '').trim()
if (!needle) return true
for (const acc of accessors) {
const v = acc(row)
if (v != null && pred(String(v), needle, { threshold: 0.4, minMatchCharLength: 3 })) return true
}
return false
}
const matcher = (row) => test(row, fuzzyMatches)
matcher.exact = (row) => test(row, exactMatches)
matcher.active = () => !!(bus?.searchTerm?.value || '').trim()
matcher.term = () => (bus?.searchTerm?.value || '').trim()
matcher.accessors = accessors
return matcher
}
/**
* Apply the TIERED search to a row set:
* 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects.
* 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country --
* the table under the map shows the result for that country"): the term is resolved by
* whole-value edit distance against each accessor's DISTINCT values, and the rows returned
* are those carrying a resolved value -- byte-identical to picking it as a filter.
* 3. the generic windowed fuzzy rescue, only when nothing resolves.
*/
export function filterRowsBySearch(rows, matcher) {
if (!matcher.active()) return rows
const exact = rows.filter(matcher.exact)
if (exact.length) return exact
const term = matcher.term()
// ONE pool across every accessor's distinct values, so the GLOBAL best match wins (see
// resolveFuzzyValues: ties at the minimal distance stay, everything farther drops --
// 'Canadian' resolves to Canada, not to 47 rows of <=3-edit neighbours across facets).
const distinct = new Set()
for (const acc of matcher.accessors) for (const r of rows) { const v = acc(r); if (v != null && v !== '') distinct.add(String(v)) }
const winners = new Set(resolveFuzzyValues(term, [...distinct], { threshold: 0.4 }))
if (winners.size) {
const resolved = rows.filter((r) => matcher.accessors.some((acc) => { const v = acc(r); return v != null && winners.has(String(v)) }))
if (resolved.length) return resolved
}
return rows.filter(matcher)
}
/**
* Generic KPI computation driven by config.kpis. Each kpi def declares a `kind`:
@ -308,6 +377,22 @@ export function useDashboardShell(config) {
useHashSync(bus, labelFor)
const exporter = useCsvExport({ rowStore: data.rowStore, rows: data.rows, selectionSet: bus.selectionSet, filterSet: bus.filterSet, FACET_ACC, dataOpts: config.data, labelFor, rowMatches: bus.rowMatchesFilters })
// ★ THE RESEARCH LEGEND, HANDED TO THE PANELS AS DATA (kit landing 4b).
// The facet rail and the chip bar used to IMPORT useTreeLegendColors and useTreeLegendLabels
// directly. Those two files are the DOXA palettes and the People code tables — research-specific
// by this kit's own reckoning, and they stay in plugin-designs. A library component importing
// them would be a library-to-plugin dependency, which check:no-cross-plugin-imports rightly
// refuses, so the rail could never have become kit code by moving alone.
// So the dependency inverts: the panels declare what they need (two functions) and the SHELL
// supplies it. The shell is the right supplier because it is the only thing that already has the
// config, the dataStore and the source id these composables require, and because a composable
// cannot be called at config-module scope.
// It goes on dashboardConfig rather than through a new inject key, per section 5.1 of the spec: a
// panel that needs a new capability gets a new CONFIG key, never a new channel. Merged rather than
// assigned, so a profile that supplies its own `legend` wins and is not silently overwritten.
const { colorFor } = useTreeLegendColors()
if (!config.legend) config.legend = { colorFor, labelFor }
provide('selectionBus', bus)
provide('dashboardData', data)
provide('dashboardConfig', config)

View file

@ -93,7 +93,7 @@ const { createSelectionBus } = await import(BUS)
// re-export shim, found no predicate to object to, and PASSED VACUOUSLY — a gate going quiet
// about the thing it guards, which is worse than one that fails.
'library/composables/useCsvExport.js',
'plugin-designs/research-dashboard/src/components/dashboards/DashboardFacetRail.vue',
'library/components/dashboards/DashboardFacetRail.vue', // follows the file (kit landing 4c-3)
'plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue',
'plugin-designs/research-dashboard/src/components/dashboards/DashboardChartsRail.vue',
]

View file

@ -119,6 +119,34 @@ for (const p of catalogues) {
// missing keys — a check that fails on its own documentation is a check nobody trusts.
const files = listFiles((p) => /\.(vue|js|mjs)$/.test(p) && !p.includes('/scripts/') && !p.includes('/locales/'))
// ── ★ A TRANSLATOR CAN ARRIVE FROM A COMPOSABLE, NOT ONLY FROM A LOCAL WRAPPER ───────────────
// go-int planted a poison key behind `const T = useGiT()` and this gate PASSED. Three failures of
// the same shape stacked up: (1) the file never writes the word tStatic, so the precondition below
// skipped it outright; (2) `useGiT` is not `tStatic`, so aliasesIn() found no alias at all; and
// (3) the alias is PREFIXED — useOptionalT carries a namespace — while its call sites pass BARE
// keys, which the full-key pattern cannot match because it requires a dot. Any one of the three
// alone would have hidden the key. A census says this was never one bundle's problem: 26 alias
// sites across three factories (useOptionalT, useNeighborT, usePplrT).
//
// THE NAMESPACE IS READ, NOT LISTED, for the same reason the wrapper names are. Every factory in
// this tree is one line — `return useOptionalT('neighbor')` — so following it costs nothing, and
// the next useXxxT() is covered the day it is written instead of the day someone remembers to come
// back here. A factory whose namespace this gate cannot read is left alone rather than guessed at.
const FACTORY_NS = new Map([['useOptionalT', null], ['useOptionalI18n', null]]) // null = read the call site's own first argument
const factoriesIn = (src, into) => {
for (const m of src.matchAll(/export\s+function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{\s*return\s+useOptional(?:T|I18n)\s*\(\s*'([^']*)'/g)) into.set(m[1], m[2])
return into
}
for (const f of files) {
let src = ''
try { src = readFile(f) || '' } catch (_) { continue }
if (src.includes('useOptional')) factoriesIn(src, FACTORY_NS)
}
let FACTORIES = [...FACTORY_NS.keys()].join('|')
let fallbackOnly = 0
const seesFile = (src) => !!src && (src.includes('tStatic') || src.includes('$t(') || src.includes(' t(')
|| new RegExp(`(?:${FACTORIES})\\s*\\(`).test(src))
// ── ★ ALIASES ARE DISCOVERED PER FILE, NOT HARDCODED ─────────────────────────────────────────
// The first version of this gate matched tStatic/$t/t and found four missing keys while sailing
// past the ten it was written for, because the house pattern is a file-local wrapper. Adding `T` by
@ -153,9 +181,78 @@ function aliasesIn(src) {
else if (/=\s*tStatic\s*$/.test(body.trim())) full.add(name)
}
}
// A NAME BOUND FROM A FACTORY: `const t = useOptionalT('connectivity')`, `const nt =
// useNeighborT()`, `const { t: T, te } = useOptionalI18n('gospel')`.
for (const m of src.matchAll(new RegExp(`(?:const|let|var)\\s+(?:([A-Za-z_$][\\w$]*)|\\{([^}]*)\\})\\s*=\\s*(${FACTORIES})\\s*\\(([^\\n]*)`, 'g'))) {
const [, plain, destructured, factory, argsTail] = m
const declared = FACTORY_NS.get(factory)
const ns = declared === null || declared === undefined ? (/^\s*'([^']*)'/.exec(argsTail)?.[1] ?? null) : declared
if (ns === null) continue
const names = plain ? [plain]
: [...String(destructured).matchAll(/\bt\s*:\s*([A-Za-z_$][\w$]*)|(?:^|,)\s*(t)\s*(?=,|$)/g)].map((x) => x[1] || x[2]).filter(Boolean)
for (const n of names) {
if (ns) {
// qualify() prefixes a BARE key and leaves an already-qualified one alone, so ONE name is
// both shapes: prefixed for t('map_title'), full-key for t('connectivity.map_title'). The
// two patterns below are disjoint — one demands a dot, the other forbids it — so
// registering the name twice double-counts nothing.
prefixed.set(n, [...new Set([...(prefixed.get(n) || []), ns])])
full.add(n)
} else {
// ★ AN EMPTY NAMESPACE PLUS A BARE KEY WOULD BE OUTSIDE THIS GATE'S MODEL, so it is counted
// rather than assumed away. useOptionalT('') leaves the key alone, so t('approx') would ask
// for a TOP-LEVEL key, while the catalogue is indexed area-first ("aria.foo") the way
// tStatic walks it — unfindable here either way. MEASURED at zero: all five empty-namespace
// sites (poster-wall, upg-100-mfe, resources-map, ApproxPinNote, FilterDropdown) pass FULLY
// DOTTED keys, so every one of them is checked by the full-key pass above and nothing is
// being waved through. The counter stays because the shape is writable, and the day someone
// writes it this prints a stated limit instead of a clean result.
full.add(n)
fallbackOnly += (src.match(new RegExp(`(?:^|[^\\w$.])${n}\\(\\s*'[\\w-]+'`, 'g')) || []).length
}
}
}
return { full, prefixed }
}
// ── ★ SELF-TEST, BECAUSE THIS GATE ALREADY PASSED A PLANTED POISON KEY ────────────────────────
// go-int planted `T('poison_key')` behind `const T = useGiT()` and this gate reported the tree
// clean. That is the failure a check cannot be forgiven for: a red gate gets fixed, a green one that
// should be red gets BELIEVED, and this one guards goal (d). Measured before and after, with the
// same poison on disk: the version on main PASSES it, this version FAILS it as
// `neighbor.zzz_not_a_real_key (via nt())`. Coverage went 683 -> 743 full-key and 49 -> 56 prefixed
// call sites, so 67 sites that were being scored as clean were simply not being read.
//
// The three things asserted are the three that were independently broken; any one of them alone
// hid the key, so testing them together would not have told me which was fixed.
{
const FIXTURE_FACTORY = "export function useGiT() {\n return useOptionalT('gospelInternet')\n}"
const FIXTURE_SITE = "const T = useGiT()\nconst a = T('poison_key')\nconst b = T('gospelInternet.qualified_key')"
// Registered in the REAL map, because aliasesIn() resolves a factory's namespace through it — a
// copy would have the self-test exercise a lookup the scan does not use.
const probe = factoriesIn(FIXTURE_FACTORY, FACTORY_NS)
const saved = FACTORIES
FACTORIES = [...probe.keys()].join('|')
const fails = []
// 1. the factory's namespace is READ from its one-line body, not guessed.
if (probe.get('useGiT') !== 'gospelInternet') fails.push('a useXxxT() factory\'s namespace is not read from its body')
// 2. a file whose ONLY translator comes from a composable is not skipped before anything runs.
if (!seesFile(FIXTURE_SITE)) fails.push('a file with no tStatic but a composable translator is still skipped')
// 3. the binding becomes BOTH shapes, because qualify() prefixes a bare key and leaves a
// qualified one alone — bare-only would miss T('poison_key'), full-only would miss nothing
// visible and is exactly how this passed before.
const al = aliasesIn(FIXTURE_SITE)
if (!(al.prefixed.get('T') || []).includes('gospelInternet')) fails.push('a composable-bound alias is not registered as PREFIXED')
if (!al.full.has('T')) fails.push('a composable-bound alias is not also registered as FULL-key')
FACTORIES = saved
FACTORY_NS.delete('useGiT') // the fixture's factory is not part of this tree
if (fails.length) {
console.error(`${TAG}: SELF-TEST FAILED — ${fails.join('; ')}.`)
console.error(' This gate cannot be trusted while blind to a composable-bound translator: that is how a planted key passed.')
process.exit(3)
}
}
const missing = new Map() // key -> { sites: [], via: Set }
let literalCount = 0, prefixedCount = 0, dynamicCount = 0
const lineAt = (src, i) => src.slice(0, i).split('\n').length
@ -173,7 +270,10 @@ const record = (key, site, via) => {
const callSites = new Map() // bundle -> count
for (const f of files) {
const src = readFile(f)
if (!src || !src.includes('tStatic') && !src.includes('$t(') && !src.includes(' t(')) continue
// seesFile()'s last clause is not decoration: a file whose only translator comes from a composable
// never writes the word tStatic, and skipping it here is how the poison key got past everything
// below. The self-test above drives this exact predicate for that reason.
if (!seesFile(src)) continue
const { full, prefixed } = aliasesIn(src)
const names = [...full, ...prefixed.keys()].map((n) => n.replace('$', '\\$')).join('|')
if (!names) continue
@ -222,6 +322,11 @@ console.log(` · ${KEYS.size} English keys across ${catalogues.length} catalogu
if (dynamicCount) {
console.log(` · ${dynamicCount} DYNAMIC key expression(s) not checked — this gate will not invent the key set a runtime expression might produce.`)
}
if (fallbackOnly) {
console.log(` · ${fallbackOnly} bare-key call site(s) behind useOptionalT('') are OUTSIDE this model and not checked: an empty namespace leaves the key`)
console.log(` unqualified, while the catalogue is indexed area-first the way tStatic walks it. Those sites pass their own literal fallback MAP`)
console.log(` rather than the catalogue, by the decision recorded in library/composables/useOptionalT.js — a limit, not a clean result.`)
}
{
const bundles = listFiles((p) => /^plugin-designs\/[^/]+\//.test(p)).map((p) => p.split('/')[1])
const silent = [...new Set(bundles)].filter((b) => !callSites.get(b)).sort()

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-3rrykWo456uGE2YmJ/i7AeehqxCWQe0bVDjDOj5VEwaPZV5pFw4iegauoNaZMyDR" crossorigin="anonymous"></script>
<script src="../all-people-groups.js" integrity="sha384-I45T4m8wem7bZa+vwYOeNA2bLE0UQwsQ44OPqWnGXO569NQ/822/a013lKWpUC0U" 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-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-4//gka76np8NjhhynRAoi+IwIomgQsIz8IG27CXFo9rBWS/isxI+Rka1nRLjj1fY" 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-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" crossorigin="anonymous"></script>
<script src="../../big-picture.js" integrity="sha384-4//gka76np8NjhhynRAoi+IwIomgQsIz8IG27CXFo9rBWS/isxI+Rka1nRLjj1fY" 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-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf" crossorigin="anonymous"></script>
<script src="../big-picture.js" integrity="sha384-4//gka76np8NjhhynRAoi+IwIomgQsIz8IG27CXFo9rBWS/isxI+Rka1nRLjj1fY" 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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00" 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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00" 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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO" crossorigin="anonymous"></script>
<script src="../cities-internet.js" integrity="sha384-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00" 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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr" 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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr" 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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo" crossorigin="anonymous"></script>
<script src="../country-posters.js" integrity="sha384-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr" 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-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI" crossorigin="anonymous"></script>
<script src="../../dashboard-plus-grid.js" integrity="sha384-SwGjy6nKza+3ths+kDMc7srH9hg4Qgcx7SZF/UzGuw9oXEQuDYtXDY9nsCsQXN5s" 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-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI" crossorigin="anonymous"></script>
<script src="../dashboard-plus-grid.js" integrity="sha384-SwGjy6nKza+3ths+kDMc7srH9hg4Qgcx7SZF/UzGuw9oXEQuDYtXDY9nsCsQXN5s" 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-S96oNhtx2xtbFL2YLgOlHA0IkLgz80K08+NVoO8qpHXb2xsJSz3CqVHL89QxlSUX" crossorigin="anonymous"></script>
<script src="../demo-map.js" integrity="sha384-U61Eq4Jc5slgktUQnAG2Hgy+131x34DxfqDzGwXVcKFSwpr/lCIxmuWn8LkWROXp" 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-S96oNhtx2xtbFL2YLgOlHA0IkLgz80K08+NVoO8qpHXb2xsJSz3CqVHL89QxlSUX" crossorigin="anonymous"></script>
<script src="../../demo-map.js" integrity="sha384-U61Eq4Jc5slgktUQnAG2Hgy+131x34DxfqDzGwXVcKFSwpr/lCIxmuWn8LkWROXp" 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 <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-ZaUaF0Dx4UBlHI8JhLyifYVUt9vkrjG64kS+DIwF/YcownDDi5uh8zJ5GRqllen5" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-4D7c47asfYu6h4aR9CXkO35ayNbjl72ZotuI8NcgVfZRE6OrgKXCMYaWEC+Y+uJz" 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 <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-ZaUaF0Dx4UBlHI8JhLyifYVUt9vkrjG64kS+DIwF/YcownDDi5uh8zJ5GRqllen5" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-4D7c47asfYu6h4aR9CXkO35ayNbjl72ZotuI8NcgVfZRE6OrgKXCMYaWEC+Y+uJz" 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-3rrykWo456uGE2YmJ/i7AeehqxCWQe0bVDjDOj5VEwaPZV5pFw4iegauoNaZMyDR",
"integrity": "sha384-I45T4m8wem7bZa+vwYOeNA2bLE0UQwsQ44OPqWnGXO569NQ/822/a013lKWpUC0U",
"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-3rrykWo456uGE2YmJ/i7AeehqxCWQe0bVDjDOj5VEwaPZV5pFw4iegauoNaZMyDR\" 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-I45T4m8wem7bZa+vwYOeNA2bLE0UQwsQ44OPqWnGXO569NQ/822/a013lKWpUC0U\" 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-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf",
"integrity": "sha384-4//gka76np8NjhhynRAoi+IwIomgQsIz8IG27CXFo9rBWS/isxI+Rka1nRLjj1fY",
"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-SftV2pzJtasZnTIxHPqlL4QTf6juY2qb+eoUEwSsjtU/ldg7qPmbjAjr+uNrD7Cf\" crossorigin=\"anonymous\"></script>\n<bp-big-picture profile-config='{\"profile\":\"big-picture\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></bp-big-picture>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./big-picture/big-picture.js\" integrity=\"sha384-4//gka76np8NjhhynRAoi+IwIomgQsIz8IG27CXFo9rBWS/isxI+Rka1nRLjj1fY\" 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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO",
"integrity": "sha384-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00",
"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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO\" 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-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00\" 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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO\" 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-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00\" 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-07lCkgnIAJa8y6BMWEnw2cKXpHnC2gnfNaM8v1dZvvVXS+rF/dqV6ZH4ULXQrVsO\" 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-xcQGL99SboRUkzjl0kv3CY5IK9ysI1ijOW1WBMTvRi5wLGxl8MgxOK5zni3OvW00\" 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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo",
"integrity": "sha384-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr",
"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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo\" 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-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr\" 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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo\" 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-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr\" 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-eIqFr2k0+NvcvJiIuMymyN88gxKXYH/DQwOeQhcHwjnh4Fp1j3xV8AmO52OC/CZo\" 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-GeeqYm+/7MdvlVROUr5CkLqhLIhMBOAN1sYjoCuKQ/STWl5B4y6QViRjMFH7zQqr\" 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-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI",
"integrity": "sha384-SwGjy6nKza+3ths+kDMc7srH9hg4Qgcx7SZF/UzGuw9oXEQuDYtXDY9nsCsQXN5s",
"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-PX/Ip+YNyNzpLKKUMO7ud3cQRKx39yp5Xom4+y/HgK5x4JhtEdJnaamoNo4ooEPI\" 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-SwGjy6nKza+3ths+kDMc7srH9hg4Qgcx7SZF/UzGuw9oXEQuDYtXDY9nsCsQXN5s\" 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-S96oNhtx2xtbFL2YLgOlHA0IkLgz80K08+NVoO8qpHXb2xsJSz3CqVHL89QxlSUX",
"integrity": "sha384-U61Eq4Jc5slgktUQnAG2Hgy+131x34DxfqDzGwXVcKFSwpr/lCIxmuWn8LkWROXp",
"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-S96oNhtx2xtbFL2YLgOlHA0IkLgz80K08+NVoO8qpHXb2xsJSz3CqVHL89QxlSUX\" 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-U61Eq4Jc5slgktUQnAG2Hgy+131x34DxfqDzGwXVcKFSwpr/lCIxmuWn8LkWROXp\" 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": [
{
@ -301,7 +301,7 @@
"bundle": "doxa-maps-spa",
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"output": "dx-maps/doxa-maps-spa/doxa-maps-spa.js",
"integrity": "sha384-ZaUaF0Dx4UBlHI8JhLyifYVUt9vkrjG64kS+DIwF/YcownDDi5uh8zJ5GRqllen5",
"integrity": "sha384-4D7c47asfYu6h4aR9CXkO35ayNbjl72ZotuI8NcgVfZRE6OrgKXCMYaWEC+Y+uJz",
"elements": [
"doxa-maps-spa"
],
@ -309,7 +309,6 @@
"dashboard-plus-grid/dashboard-plus-grid",
"research-map-plus-grid/research-map-plus-grid",
"regions-dashboard/regions-mfe",
"gospel-internet/board",
"my-upg-100-list/upg-100-mfe",
"doxa-simple-map/doxa-simple-map",
"cities-internet/connectivity-map",
@ -327,7 +326,7 @@
"scriptUrl": "./doxa-maps-spa/doxa-maps-spa.js",
"embed": {
"iframe": "<iframe src=\"./doxa-maps-spa/doxa-maps-spa/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=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-ZaUaF0Dx4UBlHI8JhLyifYVUt9vkrjG64kS+DIwF/YcownDDi5uh8zJ5GRqllen5\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./doxa-maps-spa/doxa-maps-spa.js\" integrity=\"sha384-4D7c47asfYu6h4aR9CXkO35ayNbjl72ZotuI8NcgVfZRE6OrgKXCMYaWEC+Y+uJz\" crossorigin=\"anonymous\"></script>\n<doxa-maps-spa profile-config='{\"profile\":\"doxa-maps-spa\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></doxa-maps-spa>"
},
"instances": [
{
@ -354,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-SVlzGcpHBhm5LtBu6CndA/qSFs88jyD9MK3eI2d2B0Ursn2grdvGWlDkl0vxVm53",
"integrity": "sha384-nW33n+BmoeR0K/iI9apdAnWxew5RbSHAAg8PHy+ypiwcQA6+BMyF4anc7JwXeD5U",
"elements": [
"doxa-research-map"
],
@ -370,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-SVlzGcpHBhm5LtBu6CndA/qSFs88jyD9MK3eI2d2B0Ursn2grdvGWlDkl0vxVm53\" 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-nW33n+BmoeR0K/iI9apdAnWxew5RbSHAAg8PHy+ypiwcQA6+BMyF4anc7JwXeD5U\" 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": [
{
@ -396,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-m+IYUL1Olw/De0exTI0vll3SMhItlrMysGUng9oLBC9RUkbg2bOba4iXEi/6Kpa4",
"integrity": "sha384-xVSokdr9nSgDxbGS14E41FI3aiDhfTfHQXbKler2QDU4LwWX3/un29Z6hVl5uuz4",
"elements": [
"doxa-map",
"doxa-simple-map"
@ -413,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-m+IYUL1Olw/De0exTI0vll3SMhItlrMysGUng9oLBC9RUkbg2bOba4iXEi/6Kpa4\" 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-xVSokdr9nSgDxbGS14E41FI3aiDhfTfHQXbKler2QDU4LwWX3/un29Z6hVl5uuz4\" 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": [
{
@ -579,7 +578,7 @@
"bundle": "gospel-internet",
"scriptUrl": "./gospel-internet/gospel-internet.js",
"output": "dx-maps/gospel-internet/gospel-internet.js",
"integrity": "sha384-U7bazICc5JfAz9tkdielIA3nCFEuNae3pLV4xZ67dchqj2+kYF8H3ULU1HfWQgmw",
"integrity": "sha384-NVgxHdVCb5aUY7aSYhuffQ3upM9FZCTOVLq80etKnDXCr8ZnVlhgmeu/FNwo/0ld",
"elements": [
"gospel-internet-board"
],
@ -595,7 +594,7 @@
"scriptUrl": "./gospel-internet/gospel-internet.js",
"embed": {
"iframe": "<iframe src=\"./gospel-internet/board/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-internet/gospel-internet.js\" integrity=\"sha384-U7bazICc5JfAz9tkdielIA3nCFEuNae3pLV4xZ67dchqj2+kYF8H3ULU1HfWQgmw\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
"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-internet/gospel-internet.js\" integrity=\"sha384-NVgxHdVCb5aUY7aSYhuffQ3upM9FZCTOVLq80etKnDXCr8ZnVlhgmeu/FNwo/0ld\" crossorigin=\"anonymous\"></script>\n<gospel-internet-board profile-config='{\"profile\":\"board\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></gospel-internet-board>"
},
"instances": [
{
@ -695,7 +694,7 @@
"bundle": "gospel-resources",
"scriptUrl": "./gospel-resources/gospel-resources.js",
"output": "dx-maps/gospel-resources/gospel-resources.js",
"integrity": "sha384-kXVNzR8RP0GizvIAyxlbFGQw4UZU24kFVvvMjJqGZuSLMxqVa/UFwJ3EYhyqKdQY",
"integrity": "sha384-C/5d5nGkmOyzO2EbPddlvYy5s1dTUkC+ttXWErExDmkJFEuqQ75vwd5kwGpsDlmg",
"elements": [
"gospel-resources-map"
],
@ -715,7 +714,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-kXVNzR8RP0GizvIAyxlbFGQw4UZU24kFVvvMjJqGZuSLMxqVa/UFwJ3EYhyqKdQY\" 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-C/5d5nGkmOyzO2EbPddlvYy5s1dTUkC+ttXWErExDmkJFEuqQ75vwd5kwGpsDlmg\" 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>"
}
},
{
@ -729,7 +728,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-kXVNzR8RP0GizvIAyxlbFGQw4UZU24kFVvvMjJqGZuSLMxqVa/UFwJ3EYhyqKdQY\" 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-C/5d5nGkmOyzO2EbPddlvYy5s1dTUkC+ttXWErExDmkJFEuqQ75vwd5kwGpsDlmg\" 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>"
}
},
{
@ -743,7 +742,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-kXVNzR8RP0GizvIAyxlbFGQw4UZU24kFVvvMjJqGZuSLMxqVa/UFwJ3EYhyqKdQY\" 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-C/5d5nGkmOyzO2EbPddlvYy5s1dTUkC+ttXWErExDmkJFEuqQ75vwd5kwGpsDlmg\" 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>"
}
},
{
@ -757,7 +756,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-kXVNzR8RP0GizvIAyxlbFGQw4UZU24kFVvvMjJqGZuSLMxqVa/UFwJ3EYhyqKdQY\" 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-C/5d5nGkmOyzO2EbPddlvYy5s1dTUkC+ttXWErExDmkJFEuqQ75vwd5kwGpsDlmg\" 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>"
}
},
{
@ -771,7 +770,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-kXVNzR8RP0GizvIAyxlbFGQw4UZU24kFVvvMjJqGZuSLMxqVa/UFwJ3EYhyqKdQY\" 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-C/5d5nGkmOyzO2EbPddlvYy5s1dTUkC+ttXWErExDmkJFEuqQ75vwd5kwGpsDlmg\" 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>"
}
}
]
@ -780,7 +779,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-Gz3XNgcuG2TIpbSCY+VHxzh3HmE/CRsFTExJmybR0+ddZq5mwAU3aLkkmns+Mext",
"integrity": "sha384-J3l3xsNrTnZg76LcoKSEaioXa9UcoDT9p9iI/ouWx8JuEK93iKU7quj/F1hIkWrH",
"elements": [
"my-upg-100-list-map"
],
@ -800,7 +799,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-Gz3XNgcuG2TIpbSCY+VHxzh3HmE/CRsFTExJmybR0+ddZq5mwAU3aLkkmns+Mext\" 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-J3l3xsNrTnZg76LcoKSEaioXa9UcoDT9p9iI/ouWx8JuEK93iKU7quj/F1hIkWrH\" 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>"
}
},
{
@ -814,7 +813,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-Gz3XNgcuG2TIpbSCY+VHxzh3HmE/CRsFTExJmybR0+ddZq5mwAU3aLkkmns+Mext\" 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-J3l3xsNrTnZg76LcoKSEaioXa9UcoDT9p9iI/ouWx8JuEK93iKU7quj/F1hIkWrH\" 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>"
}
},
{
@ -828,7 +827,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-Gz3XNgcuG2TIpbSCY+VHxzh3HmE/CRsFTExJmybR0+ddZq5mwAU3aLkkmns+Mext\" 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-J3l3xsNrTnZg76LcoKSEaioXa9UcoDT9p9iI/ouWx8JuEK93iKU7quj/F1hIkWrH\" 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>"
}
}
]
@ -837,7 +836,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-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC",
"integrity": "sha384-tp61XV2xYud3vQ487gn6dAdZ7POrL8AevcdZP6aCzetjcvgkU416Z/fhTRD/kcH3",
"elements": [
"people-groups-grid"
],
@ -853,7 +852,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-Oz37OCp7FfJyAzSIaCYmse5VhkiwC2dai2wkwLMagEV6WGl5QMaUVFlsK4KqVsCC\" crossorigin=\"anonymous\"></script>\n<people-groups-grid profile-config='{\"profile\":\"people-groups-grid\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></people-groups-grid>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./people-groups-grid/people-groups-grid.js\" integrity=\"sha384-tp61XV2xYud3vQ487gn6dAdZ7POrL8AevcdZP6aCzetjcvgkU416Z/fhTRD/kcH3\" 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": [
{
@ -898,7 +897,7 @@
"bundle": "plugin-studio",
"scriptUrl": "./plugin-studio/plugin-studio.js",
"output": "dx-maps/plugin-studio/plugin-studio.js",
"integrity": "sha384-Jc2znVVtfkv01BBMY6IpyHr/W2r1JtqflWXd/s8LWPfJ+Ge62BgIK2vUbmvtvZH+",
"integrity": "sha384-IfQjP4zwT8qoAdLAv0bNgMvN0mwsqIZYEqMfwbn/MZtcb41kmOVwRkzdANs8kLGS",
"elements": [
"plugin-studio"
],
@ -926,7 +925,7 @@
"scriptUrl": "./plugin-studio/plugin-studio.js",
"embed": {
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/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=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-Jc2znVVtfkv01BBMY6IpyHr/W2r1JtqflWXd/s8LWPfJ+Ge62BgIK2vUbmvtvZH+\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-IfQjP4zwT8qoAdLAv0bNgMvN0mwsqIZYEqMfwbn/MZtcb41kmOVwRkzdANs8kLGS\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
},
"instances": [
{
@ -953,7 +952,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-OFc3TlXMyY/aABsA3Hg0rJqeZ0h9iQmT+O4rgxoAzVXU+UJc/+DbDd2l6ippqDKr",
"integrity": "sha384-fqyrXAqslnCC8sOScI19p4IGV89NbHCrE+w1B1Gnfk/EmHLLbdlkii52FBGBSqmB",
"elements": [
"pplr-data-maps"
],
@ -969,7 +968,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-OFc3TlXMyY/aABsA3Hg0rJqeZ0h9iQmT+O4rgxoAzVXU+UJc/+DbDd2l6ippqDKr\" 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-fqyrXAqslnCC8sOScI19p4IGV89NbHCrE+w1B1Gnfk/EmHLLbdlkii52FBGBSqmB\" 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"
}
@ -1004,7 +1003,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "dx-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-4vm0RpawduoUDKxpuvOuiUJlclNFb8rMhQhChKTg6yVXfIel4jqIfUXef7fVW2MF",
"integrity": "sha384-wkk1KnfGYwr+dnT8pKvoLQUHLk1x2PsvxB2EdEDeu/Z5HB+MqB0EL29imRMHthCE",
"elements": [
"regions-dashboard"
],
@ -1024,7 +1023,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-4vm0RpawduoUDKxpuvOuiUJlclNFb8rMhQhChKTg6yVXfIel4jqIfUXef7fVW2MF\" 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-wkk1KnfGYwr+dnT8pKvoLQUHLk1x2PsvxB2EdEDeu/Z5HB+MqB0EL29imRMHthCE\" 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>"
}
},
{
@ -1038,7 +1037,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-4vm0RpawduoUDKxpuvOuiUJlclNFb8rMhQhChKTg6yVXfIel4jqIfUXef7fVW2MF\" 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-wkk1KnfGYwr+dnT8pKvoLQUHLk1x2PsvxB2EdEDeu/Z5HB+MqB0EL29imRMHthCE\" 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>"
}
},
{
@ -1052,7 +1051,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-4vm0RpawduoUDKxpuvOuiUJlclNFb8rMhQhChKTg6yVXfIel4jqIfUXef7fVW2MF\" 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-wkk1KnfGYwr+dnT8pKvoLQUHLk1x2PsvxB2EdEDeu/Z5HB+MqB0EL29imRMHthCE\" 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>"
}
}
]
@ -1061,7 +1060,7 @@
"bundle": "research-dashboard",
"scriptUrl": "./research-dashboard/research-dashboard.js",
"output": "dx-maps/research-dashboard/research-dashboard.js",
"integrity": "sha384-PQlB58I+fZ8b9Ukt03YyDHUDRgkjDZkw3M7WBdUCFvDy9jwqESSClOryUHTogRyp",
"integrity": "sha384-ppPrvC/o+iuZaAtg5bgKFp+s29lcdncUa1uw4h/RaGVmJxjn48TXJi3dyzVxZS7U",
"elements": [
"research-dashboard-map"
],
@ -1077,7 +1076,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-PQlB58I+fZ8b9Ukt03YyDHUDRgkjDZkw3M7WBdUCFvDy9jwqESSClOryUHTogRyp\" 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-ppPrvC/o+iuZaAtg5bgKFp+s29lcdncUa1uw4h/RaGVmJxjn48TXJi3dyzVxZS7U\" 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"
}
@ -1175,7 +1174,7 @@
"bundle": "upg-neighbor",
"scriptUrl": "./upg-neighbor/upg-neighbor.js",
"output": "dx-maps/upg-neighbor/upg-neighbor.js",
"integrity": "sha384-lT4ZpxDXicGkM7cvBbADGEspxp1e980qMRDDb9f+7yGvfS5KIR7QXbC4EqrNlxe3",
"integrity": "sha384-JmNCukhu5CosIRLQDKefoZZj/yPlD6fnJVpScTsjpfT8y/7OFIskmcS+PjfbOpGu",
"elements": [
"upg-neighbor"
],
@ -1191,7 +1190,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-lT4ZpxDXicGkM7cvBbADGEspxp1e980qMRDDb9f+7yGvfS5KIR7QXbC4EqrNlxe3\" 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-JmNCukhu5CosIRLQDKefoZZj/yPlD6fnJVpScTsjpfT8y/7OFIskmcS+PjfbOpGu\" 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": [
{
@ -1274,7 +1273,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-Nabfkk/WeIHWXHKIVpl7aF/A+DlvvqLNvR2qEZ0DeKlPfh4ntH4rqABRawDDultp",
"integrity": "sha384-iCCdBzu4FrSv9Y4uE+jXvkc1maEDPguf+bXHV3IOJq8rv8oEpG9qzfC553PZpUGY",
"elements": [
"wall-of-people-groups"
],
@ -1290,7 +1289,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-Nabfkk/WeIHWXHKIVpl7aF/A+DlvvqLNvR2qEZ0DeKlPfh4ntH4rqABRawDDultp\" 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-iCCdBzu4FrSv9Y4uE+jXvkc1maEDPguf+bXHV3IOJq8rv8oEpG9qzfC553PZpUGY\" 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-SVlzGcpHBhm5LtBu6CndA/qSFs88jyD9MK3eI2d2B0Ursn2grdvGWlDkl0vxVm53" crossorigin="anonymous"></script>
<script src="../doxa-research-map.js" integrity="sha384-nW33n+BmoeR0K/iI9apdAnWxew5RbSHAAg8PHy+ypiwcQA6+BMyF4anc7JwXeD5U" 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-SVlzGcpHBhm5LtBu6CndA/qSFs88jyD9MK3eI2d2B0Ursn2grdvGWlDkl0vxVm53" crossorigin="anonymous"></script>
<script src="../../doxa-research-map.js" integrity="sha384-nW33n+BmoeR0K/iI9apdAnWxew5RbSHAAg8PHy+ypiwcQA6+BMyF4anc7JwXeD5U" 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-m+IYUL1Olw/De0exTI0vll3SMhItlrMysGUng9oLBC9RUkbg2bOba4iXEi/6Kpa4" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-xVSokdr9nSgDxbGS14E41FI3aiDhfTfHQXbKler2QDU4LwWX3/un29Z6hVl5uuz4" 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-m+IYUL1Olw/De0exTI0vll3SMhItlrMysGUng9oLBC9RUkbg2bOba4iXEi/6Kpa4" crossorigin="anonymous"></script>
<script src="../../doxa-simple-map.js" integrity="sha384-xVSokdr9nSgDxbGS14E41FI3aiDhfTfHQXbKler2QDU4LwWX3/un29Z6hVl5uuz4" 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